STC15F104W无硬件PWM?软件模拟PWM完整实现指南
如果你在选型阶段搜过 STC15F104W大概率会看到这样的评价这是一颗便宜、封装极小、外围极简的 8 脚单片机缺点是没有硬件 PWM。前段时间我做一个小控制板尺寸被卡在指甲盖大小成本也抠得死最后选了 STC15F104W然后同样的问题就摆到了面前——要做 PWM 脉冲输出但芯片没有硬件 PWM 模块。这篇就是把当时从查资料、写代码到上示波器验证的完整过程整理出来主要包括软件模拟 PWM 的原理、计数器比较法和动态重载法两套思路以及可以直接抄的 C51 工程代码并用 LED 呼吸灯和舵机控制两个实战场景做了验证。看完之后你可以直接照搬也能按自己的项目需求改出多路 PWM。1. 先摸清STC15F104W这颗芯片的底细1.1 这颗8脚芯片的硬件家底STC15F104W 是 STC 15 系列里非常典型的低引脚型号SOP8 封装满打满算 8 个引脚除掉电源和地真正能用的 IO 只有 6 个。但是它的内在配置并不差内核1T 增强型 8051指令集兼容传统 8051执行速度比老 51 快 8~12 倍Flash4KB做小型逻辑控制、PWM 输出这种程序绰绰有余SRAM256B足够跑软件 PWM 的状态变量定时器3 个 16 位定时器T0/T1/T2这是软件 PWM 的核心资源时钟内置高精度 RC 振荡器频率范围 5MHz~35MHz下载程序时用 STC-ISP 软件设置即可默认常用 12MHz 或 11.0592MHz串口1 路 UART可以用 P3.0/P3.1 下载程序外设短板没有 ADC没有 PCA/CCP也就是没有任何硬件 PWM 模块。注意最后一条这直接决定了你在这颗芯片上要得到 PWM 信号只能靠软件模拟。1.2 没有硬件PWM为什么还能输出PWM脉冲硬件 PWM 的原理是定时器计数器溢出或比较匹配时由内部逻辑电路自动翻转引脚电平整个过程不需要 CPU 干预。软件 PWM 的思路更直白定时器产生中断在中断服务程序里手动翻转 IO 引脚。打个比方硬件 PWM 像自动挡汽车挂上 D 挡踩油门就行软件 PWM 像手动挡每个换挡动作都得你亲手操作。手动挡操作得好也能开出自动挡的效果代价是司机CPU得一直盯着路况中断。所以这里的关键结论是STC15F104W 能不能输出 PWM取决于你的中断服务程序写得快不快、中断频率选得合不合理。跟硬件 PWM 相比软件 PWM 的 CPU 占用率必然更高输出频率也有限制但胜在引脚随意、分辨率可变、程序可改对大量低频控制场景完全够用。2. 软件PWM的两条路线对应完全不同的应用网上搜软件 PWM方案五花八门但真正实际项目中常用的只有两种计数器比较法和动态重载法。选错方案轻则 CPU 忙死重则 PWM 频率根本达不到。2.1 计数器比较法多路输出和可变分辨率首选计数器比较法的思路是让定时器以一个固定周期持续中断每进一次中断软件计数器加 1然后拿这个计数器和设定的占空比阈值比较决定 IO 输出高还是低。公式非常简洁PWM 频率 中断频率 / 分辨率比如说我想要 2kHz 的 PWM希望占空比调节精度到 1%也就是分辨率 100 级那么中断频率 2kHz × 100 200kHz每次中断间隔 1 / 200kHz 5us在 12MHz 的 1T 模式下5us 对应 60 个时钟周期定时器初值就是 65536 - 60 65476。这个方法的优点很突出多路 PWM 可以共享同一个定时器中断每个通道只多一次比较赋值代码量增加极少分辨率随时可以改只要改 PWM_PERIOD 宏占空比的调节粒度均匀一个周期内高电平和低电平分布稳定。缺点就是中断频率偏高像上面 2kHz/100 级直接干到 200kHz对 CPU 压力不小。所以计数器比较法更适合中断频率控制在 100kHz 以内的场景比如 LED 调光、小电机调速、加热器功率控制这些不需要超高 PWM 频率的场合。2.2 动态重载法大周期低频PWM的省心方案动态重载法的思路完全不同。它不搞固定周期中断而是让定时器在高电平时定时高电平时长到点后翻转为低电平再重新装载低电平时长的初值到点后再翻转为高电平周而复始。一个周期内中断只进两次一次是高点转低一次是低点转高。这种方法下每次中断只需翻转一次 IO 并重新装载初值中断频率极低。比如 50Hz 的舵机控制信号周期 20ms一周期只进 2 次中断中断频率才 100HzCPU 占用几乎可以忽略。但动态重载法有个致命细节很容易忽略16 位定时器单次最长定时时间有限。在 12MHz 系统时钟、1T 模式下定时器每个时钟周期计数一次计满 65536 需要 65536 / 12MHz ≈ 5.46ms。也就是说如果舵机周期 20ms、低电平 18.5ms单靠一次定时器溢出根本定不了这么长时间。解决办法有两个把定时器切成 12T 模式计数频率变成 1MHz这样 20ms 只需要 20000 个计数轻松装进 16 位定时器或者用软件计数器配合定时器溢出做多级分频但代码复杂度明显上升没必要。实际项目里舵机控制我强烈建议动态重载法因为 12T 模式下初值计算非常舒服12MHz 晶振 12 分频后正好是 1MHz1us 计 1 个数1ms 初值就是 65536 - 1000 64536直接心算都能算出来。2.3 两种方法怎么选需求场景推荐方法原因LED 调光/呼吸灯PWM 频率 500Hz~2kHz计数器比较法分辨率可控多路扩展方便直流电机调速PWM 频率 100Hz~1kHz计数器比较法占空比调节均匀电机噪声可控舵机控制50Hz 周期 20ms动态重载法周期太大定时器初值放不下中断频率低项目对 CPU 占用要求苛刻动态重载法一周期只进 2 次中断需要多路 PWM 同步输出计数器比较法所有通道共享一个计数器相位一致记住一点中断频率超过 100kHz 之后8051 内核的 ISR 执行压力会明显增大能避则避。3. 计数器比较法完整工程寄存器、初值、代码一次性给全3.1 先搭最小系统少走弯路STC15F104W 之所以受欢迎很大程度因为最小系统实在太简单VCC 接 5V2.4V~5.5V 宽压都能跑VCC 与 GND 之间并联一个 0.1uF 陶瓷电容放在电源引脚旁边不需要外部晶振不需要外部复位电路。有一个引脚要特别处理P3.0。这颗芯片的 P3.0 默认是复位脚 RST高电平复位。如果不做任何设置直接拿来当普通 IO你会碰到各种奇怪问题比如程序偶尔重启。常规稳定做法是给 P3.0 外接一个 10K 下拉电阻到 GND让它在默认状态下保持低电平。如果你确实需要把 P3.0 当 IO 用那么在 STC-ISP 下载软件的烧录选项里勾选复位脚用作 IO 口P3.0然后重新下载程序。勾选之后 P3.0 就不再具备复位功能可以作为普通 IO 使用。不过下载程序仍然通过 P3.0/P3.1 串口进行冷启动下载不受影响。3.2 IO口模式PWM输出必须配推挽STC15F104W 的 IO 默认是准双向口输出高电平能力弱直接驱动 LED 或者后级 MOS 管时可能压降明显。软件 PWM 输出引脚建议配置为推挽输出。IO 模式由 P3M1 和 P3M0 两个寄存器控制对应关系如下P3M1 位P3M0 位IO 模式00准双向口默认01推挽输出10高阻输入11开漏输出我习惯用 P3.3 作为 PWM 输出配置代码就是P3M0 | 0x08; // P3.3 1推挽 P3M1 ~0x08; // P3.3 03.3 定时器初值计算的完整推导STM32 或者 Arduino 用户习惯了 HAL 库直接写频率回到 51 系必须自己算初值。这里把计算过程完整写一遍方便你举一反三。目标参数PWM 频率 500Hz分辨率 100 级也就是占空比最小步进 1%系统时钟 12MHz定时器 T0 用 1T 模式。第一步算中断频率中断频率 500Hz × 100 50kHz第二步算中断周期中断周期 1 / 50kHz 20us第三步算一个周期有多少个时钟脉冲20us × 12MHz 240 个时钟周期第四步算定时器初值初值 65536 - 240 65296 0xFF10所以TH0 0xFF; TL0 0x10;这里有个寄存器模式的坑必须说清楚。STC15 系列和传统 8051 的定时器模式定义不完全一样传统 8051 的方式 0 是 13 位定时器STC15 的 T0 方式 0 是16 位自动重载也就是溢出后硬件自动重新装载初值中断里完全不用手动再赋值。如果沿用老 51 的习惯写 TMOD 0x01那对应的是16 位不自动重载每次中断都得手动把 TH0/TL0 再写一遍多几条指令不说忘记写就会产生频率漂移。所以这里用方式 0TMOD 0xF0; // 清掉 T0 相关位 TMOD | 0x00; // T0 方式 016 位自动重载3.4 完整C51工程代码下面代码基于 Keil C51头文件用 STC 官方提供的 STC15F104W.H引脚定义兼容。#include STC15F104W.H sbit PWM_PIN P3^3; // PWM 输出引脚 #define PWM_PERIOD 100 // 分辨率100 级 #define PWM_FREQ_HZ 500 // PWM 频率500Hz #define SYS_CLK_MHZ 12 // 系统时钟12MHz // 中断频率 500Hz * 100 50kHz中断周期 20us // 20us 在 12MHz 1T 下 240 个时钟周期 // 定时器初值 65536 - 240 65296 0xFF10 #define TH0_INIT 0xFF #define TL0_INIT 0x10 unsigned char pwm_cnt 0; // 计数器 unsigned char pwm_high 30; // 占空比阈值30/100 30% void Timer0_Init(void) { AUXR | 0x80; // T0x12 1T0 工作在 1T 模式 TMOD 0xF0; // 清 T0 模式位 TMOD | 0x00; // T0 方式 016 位自动重载 TH0 TH0_INIT; TL0 TL0_INIT; ET0 1; // 使能 T0 中断 TR0 1; // 启动 T0 } void main(void) { P3M0 | 0x08; // P3.3 推挽输出 P3M1 ~0x08; Timer0_Init(); EA 1; // 开总中断 while (1) { // 主循环可以处理按键、通信等任务 } } void Timer0_ISR(void) interrupt 1 { if (pwm_cnt PWM_PERIOD) pwm_cnt 0; PWM_PIN (pwm_cnt pwm_high) ? 1 : 0; }这个工程跑起来P3.3 引脚输出频率 500Hz、占空比 30% 的 PWM 波形实际用示波器测量误差很小主要来自内部 RC 振荡器的精度通常 ±1% 左右对大多数应用完全够用。3.5 换频率、换分辨率、换引脚要改哪里这是我最常被问的问题直接列个对应关系换 PWM 频率重新算中断频率改 TH0_INIT 和 TL0_INIT换分辨率改 PWM_PERIOD同时重新算中断频率和初值换引脚把 sbit 定义换掉同时把 IO 推挽配置的位运算改成对应引脚占空比调节运行时改 pwm_high 变量范围 0~PWM_PERIOD。比如要把频率改成 1kHz分辨率还是 100 级中断频率 100kHz中断周期 10us时钟周期数 10 × 12 120初值 65536 - 120 65416 0xFF88改两行#define TH0_INIT 0xFF #define TL0_INIT 0x88就这么简单。核心永远是那三步频率乘分辨率得到中断频率中断频率倒数得到中断周期中断周期乘系统时钟得到计数个数。4. 实战验证LED呼吸灯和舵机控制4.1 呼吸灯验证占空比连续变化第一个实战我建议从呼吸灯开始因为它不需要任何外部硬件一个 LED 串个 220Ω 电阻接在 P3.3 和 GND 之间就能看到效果还能直观验证占空比变化时亮度是否线性。基于前面 3.4 节的工程呼吸灯效果需要在主循环里周期性调整 pwm_high 的值。我采用的是 10ms 步进从 0 增到 100 再减回 0一个完整呼吸周期 2 秒void delay_10ms(void) { unsigned char i, j; i 18; j 235; do { while (--j); } while (--i); } void main(void) { unsigned char dir 1; P3M0 | 0x08; P3M1 ~0x08; Timer0_Init(); EA 1; while (1) { if (dir) { if (pwm_high PWM_PERIOD) { pwm_high PWM_PERIOD - 1; dir 0; } } else { if (--pwm_high 0) { dir 1; } } delay_10ms(); } }我实测下来500Hz 的 PWM 频率下 LED 没有任何肉眼可见的闪烁亮度从暗到亮再到暗的过程非常平滑。如果你对电流声音敏感把 PWM 频率提到 1kHz 以上也不会增加多少 CPU 负担公式照搬上节就行。4.2 舵机控制动态重载法完整实现呼吸灯验证完计数器比较法舵机控制正好拿来验证动态重载法。常见舵机SG90、MG996R 等的控制信号是 50Hz周期 20ms高电平时间 0.5ms~2.5ms其中 1ms 对应 0 度1.5ms 对应 90 度2ms 对应 180 度。如果还用计数器比较法要做到 1us 级的高电平精度分辨率需要 20000 级中断频率 50Hz × 20000 1MHz这在 51 上根本跑不动。所以舵机控制是动态重载法的主场。前面提过在 12MHz 系统时钟下1T 模式 16 位定时器最多只能定时 5.46ms覆盖不了 20ms 周期。这里把 T0 切换到 12T 模式计数频率 12MHz / 12 1MHz1 个计数正好 1us20ms 只需要 20000 个计数完全放得下。代码#include STC15F104W.H sbit SG_PIN P3^2; // 舵机信号线接到 P3.2 unsigned int high_us 1500; // 高电平时间单位 us1ms~2ms 对应 0~180 度 void load_timer(unsigned int us) { unsigned int val 65536 - us; // 12T 模式12MHz 下 1us 计 1 个数 TL0 val 0xFF; TH0 val 8; } void Timer0_Init(void) { AUXR ~0x80; // T0x12 0T0 工作在 12T 模式 TMOD | 0x01; // T0 方式 116 位不自动重载每次中断都要重装初值 load_timer(high_us); IP | 0x02; // 提高 T0 中断优先级防止被其他中断干扰 ET0 1; TR0 1; } void Timer0_ISR(void) interrupt 1 { if (SG_PIN) { SG_PIN 0; // 高电平结束输出低电平 load_timer(20000 - high_us); // 低电平时间 周期 - 高电平时间 } else { SG_PIN 1; // 低电平结束输出高电平 load_timer(high_us); } } void main(void) { P3M0 | 0x04; // P3.2 推挽输出 P3M1 ~0x04; Timer0_Init(); EA 1; while (1) { // 这里可以定时修改 high_us控制舵机角度 // 比如 high_us 1000; // 0 度 // high_us 1500; // 90 度 // high_us 2000; // 180 度 } }这套代码跑起来后实测一个完整周期 20ms高电平时间可以通过改 high_us 变量精确控制舵机角度稳定没有抖动。动态重载法的中断频率极低CPU 占用几乎可以忽略哪怕主循环里做复杂的传感器轮询也不影响舵机输出。有个细节要注意更新 high_us 的时机。如果程序在主循环里直接修改 high_us恰好遇上中断正在执行可能出现一个周期高电平用新值、低电平用旧值的错乱情况表现为舵机偶尔卡一下。稳妥做法是在低电平阶段更新也就是等到 SG_PIN 0 时再改 high_us或者简单粗暴地关中断更新然后立即打开。舵机本身有惯性对这种偶发抖动不敏感但对精度要求高的场景还是建议处理一下。4.3 示波器验证波形别只靠感觉写完代码别急着接负载先上示波器看一眼波形再决定能不能用。用 CH1 探头夹在 PWM 输出脚接地夹子接 GND示波器时间轴先放到 1ms/div看周期是不是符合预期再切换到 20us/div看上升沿和下降沿是否陡峭有没有明显振铃重点观察一个完整周期的占空比稳定性软件 PWM 如果出现抖动说明中断里可能有其他更高优先级的中断在捣乱。我第一版呼吸灯程序就是在没接示波器的情况下调试结果亮度变化明显不均匀后来才发现是中断里多了几条乘除运算导致 ISR 执行时间超过中断周期PWM 周期被拉长了。规范化代码后用 500Hz/100 级参数测量周期误差小于 1%完全满足需求。5. 实际调PWM踩过的坑和优化建议5.1 P3.0默认是复位脚不是普通IO这个坑第一次用 STC15F104W 的人基本都会踩。P3.0 上电默认作为 RST 使用高电平复位。如果你不管它直接把它接到按键或者 LED 上大概率会出现以下现象程序上电后跑一会儿突然重启或者按下某个按键后整个系统复位。解决方式前面提过两种外接 10K 下拉电阻或者到 STC-ISP 里勾选复位脚用作 IO 口重新烧录。但要注意勾选复位脚作为 IO 后芯片就没有外部复位引脚了程序跑飞的恢复只能靠内部看门狗。STC15F104W 内置看门狗建议在初始化时打开看门狗设置合适的溢出时间程序正常运行期间定期喂狗一旦跑飞自动复位。做产品时别省这个功能。5.2 中断频率并不是越高越好很多人刚接触软件 PWM 时容易犯一个错总想追求更高的 PWM 频率和更高的分辨率结果中断频率飙到几百 kHzISR 没跑完下一次中断就到了PWM 波形反而乱掉。STC15F104W 虽然是 1T 内核12MHz 下执行一条指令通常只要 1~4 个时钟周期但中断现场保存、恢复、跳转这些开销省不掉。一个最简单的 ISR从我 3.4 节的代码看大概 20~40 条指令按平均 2 个时钟周期算需要 40~80 个时钟周期也就是 3.3us~6.7us。如果中断周期比这个时间短CPU 就会一直陷在中断处理里主循环完全卡死。根据我的经验软件 PWM 中断频率控制在 100kHz 以内比较稳妥50kHz 最舒服。对 8051 内核来说在中断频率和业务处理之间找到平衡点比一味追求参数更重要。这也是我在呼吸灯例子里用 500Hz/100 级而不是 2kHz/100 级的原因——50kHz 的中断频率让 CPU 有充足的余量处理主循环逻辑。5.3 占空比更新时的毛刺和抖动计数器比较法里ISR 每次中断都读取 pwm_high 变量。如果主循环在某个时刻修改了 pwm_high而 ISR 中正在执行一半的判断可能出现本周期占空比异常产生一个毛刺。对 LED 呼吸灯来说影响微乎其微眼睛根本看不出来。但如果你用 PWM 控制步进电机或者伺服系统这类毛刺会让执行器突然跳动就有问题了。最直接的解决办法是更新时关中断EA 0; pwm_high new_value; EA 1;还有一种更优雅的双缓冲方式主循环里先写 shadow 变量ISR 在 PWM 周期边界处把 shadow 同步给实际使用的变量保证一个周期内的 PWM 完全一致。代码量也不大适合要求严格的场景。5.4 多路PWM扩展和H桥死区提醒计数器比较法扩展多路 PWM 非常自然。因为所有通道共享同一个 pwm_cnt 计数器只要每路设定自己的阈值即可而且相位天然同步sbit PWM_CH0 P3^2; sbit PWM_CH1 P3^3; unsigned char pwm_high0 20; unsigned char pwm_high1 70; void Timer0_ISR(void) interrupt 1 { if (pwm_cnt PWM_PERIOD) pwm_cnt 0; PWM_CH0 (pwm_cnt pwm_high0) ? 1 : 0; PWM_CH1 (pwm_cnt pwm_high1) ? 1 : 0; }三路、四路也同理唯一代价是中断里多了几次比较赋值开销可以接受。最后啰嗦一个安全性问题如果你把 STC15F104W 的软件 PWM 用于 H 桥驱动直流电机必须考虑死区时间。H 桥同一桥臂上下两个 MOS 管绝对不能同时导通否则电源直接短路轻则发烫重则炸管。硬件 PWM 通常自带死区发生器软件 PWM 没有。两个思路可以解决一是两路 PWM 的占空比之和限制在 95% 以内人为留出死区二是在程序里做状态机保证切换上下管时先关闭另一个管子并延时几个微秒。用软件做死区控制逻辑不复杂但每一条时序都要经过逻辑分析仪或者示波器验证别拿自己的板子开玩笑。写到这里关于 STC15F104W 的软件 PWM 实践基本覆盖了我踩过的所有关键点。这颗芯片真正用顺手之后你会觉得没有硬件 PWM 并不是什么遗憾反而让你对定时器、中断和时序的理解深了一层。最后再分享一个实际项目里的小技巧如果只想要固定频率方波不需要调占空比可以把定时器溢出信号直接映射到引脚输出P3.4/T0 引脚的 CLKOUT 功能连中断都不用开CPU 零负担拿到一路方波。这才是把 STC15F104W 的资源彻底榨干的做法。