
如果你跟我一样翻箱倒柜找出一颗STC15F104W想让它输出一路PWM脉冲去实现调光、调速或者模拟舵机信号那你大概率会在动手前被一个问题卡住这颗8脚小芯片没有硬件PWM模块连PCA模块都被砍掉了。别急着换芯片这个坎其实跨得过去。这篇文章就用STC15F104W这颗料把软件PWM从原理、参数计算到代码实现、踩坑排查完整串一遍告诉你它到底能输出什么样的PWM脉冲哪些场景用它性价比极高哪些场景趁早放弃。先说结论STC15F104W生成PWM主要靠“定时器中断软件翻转IO”虽然比不上自带硬件PWM的STM32也不如STC15W408AS这类带CCP/PCA的型号但只要频率和分辨率选得合适1kHz到20kHz范围内的PWM信号它都能稳定输出控制舵机、直流电机、LED调光完全够用。整个项目做下来我对这颗芯片的脾气摸得比较透下面就把思路和实操一步步展开。1. STC15F104W的资源底细为什么不能直接出PWM1.1 芯片先“照个CT”8个引脚里塞了什么STC15F104W是STC15系列里的入门型号DIP8/SOP8封装体积几乎和一颗555定时器差不多大。但内核是真正的1T增强型8051指令周期比传统12T的8051快很多主频最高能跑到35MHz左右用内部高精度IRC振荡器不需要外部晶振。Flash有4KSRAM内部256字节加扩展1K内部还有10位ADC、比较器、EEPROM该有的外设也算齐了。然而做PWM恰恰踩在它的短板上它没有硬件PWM模块没有PCA可编程计数器阵列没有CCP捕获/比较单元。很多从STC89C52或者STM32转过来的人第一反应是翻数据手册找PWM寄存器翻完整本手册会发现根本没有——这不是你没找到是真的没有。这颗芯片的设计定位偏向“小逻辑控制”和“简单采集”不是为电机控制设计的。1.2 定时器资源盘点真的不够用吗虽然外设精简但定时器还是保留了一个16位可重装载定时器Timer0外加一个独立的波特率发生器BRT也可以当定时器用。做软件PWM核心就是依赖Timer0产生固定时基中断然后在中断服务程序里控制IO电平翻转。BRT一般用来跑软件串口或者额外的低频定时PWM主时基交给Timer0就够了。举个例子如果系统时钟用12MHzTimer0工作在1T模式那么它每计数1次只花1/12MHz≈83.3ns。也就是说哪怕每100us进一次中断中间还能干很多事这为软件PWM提供了足够的时间粒度。很多人在这一步会低估Timer0的威力其实1T模式下它比传统8051的Timer0灵活得多。1.3 三条可行路线对比不只是“纯软件”一条路既然没有硬件PWM我总结出三条实际可行的路线方案实现方式最大优势最大劣势适合场景纯软件PWMTimer0定时中断 IO翻转主循环控制占空比引脚任意、代码简单、全芯片通用频率×分辨率受限中断负担重1kHz~20kHz以内PWM舵机、电机、调光比较器硬件辅助PWM利用内部比较器输出外接RC充放电产生三角波比较器输出接IO频率可做较高CPU占用少电路复杂占空比范围受RC精度影响对频率有较高要求的临时方案外部PWM发生器外接555定时器/NPN MOS驱动电路MCU只控参考电压频率稳定性极好完全不占CPU增加BOM成本占空比调节精度一般量产中对成本不敏感、对频率要求高的场合我个人实际项目里90%的情况用的是纯软件PWM因为它足够通用而且不增加任何外围元件。后面所有代码和调试经验都围绕这条路线展开。2. 软件PWM的核心原理与参数计算2.1 一个时基加一根IO就能“自造”PWM先别把软件PWM想复杂。它的本质就是用一个固定频率的中断作为“心跳”每进一次中断计数器加1当计数小于设定的占空比阈值时输出高电平超过阈值时输出低电平计数器循环跑起来IO上就出现了标准的PWM波形。打个生活化的比方你在一家小饭馆里负责翻招牌每秒钟翻一次中断周期1秒客人让你“70%的时间亮灯”你就在7秒内亮灯、3秒内关灯如此循环外人看到的平均亮度就是70%。软件PWM就是干这件事只不过把时间粒度从秒级做到微秒级。代码骨架大致是这样后面会给出完整可跑的版本定时器每T_us中断一次中断里把脉冲计数器加1当计数器小于pwm_duty时把引脚拉高其余时间拉低。2.2 占空比计算公式与分辨率取舍PWM有个基础公式任何方案都绕不开占空比 高电平时间 / 整个周期 × 100%放到软件PWM里就得写成PWM周期 定时器中断间隔 × 计数周期PWM频率 1 / (定时器中断间隔 × 计数周期)占空比分辨率 1 / 计数周期 × 100%举个例子。假设我设定定时器每10us中断一次计数器从0数到99后再回到0那么一个PWM周期是10us × 100 1000usPWM频率就是1kHz占空比最小能调1%。这时候如果我想要50%的占空比那就是高电平持续500us计数器0~49拉高50~99拉低。光看公式很简单真正的坑在于频率和分辨率是互相打架的。你想要20kHz的PWM频率又想要1%的分辨率那中断频率就得是20kHz × 100 2MHz也就是说每0.5us就要进一次中断。STC15F104W在12MHz时钟下中断响应和现场保护本身就要好几个微秒所以2MHz中断根本不现实。我实测下来这颗芯片在12MHz下软件PWM的“频率×分辨率”乘积最好控制在1MHz以内。比如5kHz PWM配200分辨率0.5%分辨率或者1kHz PWM配1000分辨率0.1%分辨率都在安全范围。2.3 进阶讨论用8051模拟16位PWM是什么玩法网上经常看到“用8051模拟16位PWM”的说法其实就是在计数器位数上做文章。刚才例子里计数器只用了8位0~255如果我把计数器扩展成16位比如用两个字节组成一个0~65535的计数变量占空比分辨率就是1/65536≈0.0015%高电平的调节极其细腻。但代价是PWM频率会大幅下降。假设中断频率还是100kHz那么16位PWM的周期是65536 × 10us ≈ 0.655秒换算成频率只有1.5Hz。这种低频高分辨率的PWM适合做什么适合做需要极缓慢变化的场景比如LED氛围灯的渐变、加热器的精密温控、模拟仪表指针的慢速摆动。它追求的不是“频率高”而是“每一级占空比变化足够小肉眼或系统几乎感觉不到跳变”。千万不要拿16位模拟PWM去驱动电机频率太低会让电机一顿一顿地转啸叫和发热都会非常严重。3. 最小系统搭建与下载烧录实操3.1 引脚分配与最小系统电路STC15F104W有DIP8和SOP8两种封装8个引脚除去电源地可用IO大概有5~6个。我常用的引脚分配是P3.0和P3.1留给串口下载ISP下载会用到P3.2作为PWM输出P3.3作为按键输入或者第二路PWM输出P3.4接LED指示灯。因为芯片内置高精度IRC振荡器不需要外部晶振和两个30pF电容这让最小系统变得极其简洁1个0.1uF去耦电容、1个10uF电解电容、一颗STC15F104W芯片再加一个自锁开关用于冷启动下载就完事了。这一点比传统51单片机省事太多也是我经常拿它当“万能胶水芯片”的原因。电源用3.3V或5V都可以内部IRC和Flash在3.3V~5.5V范围内工作都很稳。如果是做电机控制强烈建议给电机用独立电源不要和单片机的电源共用否则电机起停瞬间的电流冲击会把PWM波形搅得一塌糊涂。3.2 STC-ISP下载步骤与第一次上电的坑STC全系列单片机下载程序都用STC-ISP这个烧录软件STC15F104W也不例外。几个容易踩坑的点我直接列出来下载接线USB转串口的TXD接芯片的P3.0RXD串口的RXD接芯片的P3.1TXDGND共地。这里要特别说清楚芯片本身没有独立的下载引脚它是在出厂引导代码里通过P3.0和P3.1与电脑通信所以这两脚既当下载口又当软件串口。下载步骤打开STC-ISP选好芯片型号STC15F104W选择串口号加载编译好的hex文件然后点击“下载/编程”此时芯片必须在断电状态点完下载按钮之后马上给芯片上电也就是俗称的“冷启动”。如果顺序反了软件会一直卡在“正在检测目标单片机”不动。第一次上电如果发现程序没跑起来先检查两样一是下载时有没有选对内部IRC频率我习惯选12MHz代码里定时器初值就是按12MHz算的二是P3.0/P3.1是不是被程序复用成其他功能并且改变了电平这种情况下需要按住复位再下载或者把下载引脚上的外设断开。烧录成功后强烈建议用一个简单的LED闪烁程序验证最小系统是否工作正常不要一上来就写PWM。基础没打稳后面全是玄学问题。3.3 波形验证示波器和逻辑分析仪怎么看软件PWM写完之后第一件事不是接电机、接舵机而是用示波器看波形。如果你和我一样手头没有示波器几十块钱的逻辑分析仪也能对付但要注意逻辑分析仪只能看高低电平时序看不了真实的电压纹波。把探头夹在P3.2引脚地线夹在GND上设置好触发电平你应该能看到一串矩形波。这时候重点看三样东西周期是否等于设定值占空比是否和代码一致波形上沿和下沿有没有毛刺。如果发现频率和设定值对不上多半是内部IRC频率误差导致。STC-ISP下载时有一个“输入用户程序运行时的IRC频率”选项这里填的频率会和代码里计算的定时器初值强相关。比如我下载时选12MHz代码按12MHz算初值实际出来的频率误差通常在1%以内完全可用。如果想更高精度可以让STC-ISP对IRC做频率修正烧录后频率误差能压到0.5%以下。4. 代码实战从呼吸灯到电机调速4.1 最简中断版PWM一个呼吸灯实例我先给一个最简的呼吸灯程序。呼吸灯的本质就是PWM占空比从0%缓慢增加到100%再缓慢减少到0%循环往复。用软件PWM实现非常直观。#include STC15F104W.h sbit LED P3^2; // PWM输出引脚 unsigned char pwm_cnt 0; // 中断计数 unsigned char pwm_duty 0; // 当前占空比 0~100 unsigned char pwm_dir 1; // 渐变方向 1变亮, 0变暗 void Timer0_Init(void) { AUXR | 0x80; // Timer0 设置为 1T 模式 TMOD 0xF0; // Timer0 模式0, 16位手动重装 TH0 0xFF; // 12MHz, 1T, 每100us中断一次 TL0 0x9C; // 初值 65536-120064540(0xFC1C) ET0 1; // 开Timer0中断 TR0 1; // 启动Timer0 EA 1; // 开总中断 } void Timer0_ISR() interrupt 1 { TH0 0xFF; // 重新装载初值 TL0 0x9C; pwm_cnt; // 计数器每100us加1 if(pwm_cnt 100) // 计数到100, PWM周期100*100us10ms, 频率100Hz pwm_cnt 0; if(pwm_cnt pwm_duty) LED 1; // 高电平 else LED 0; // 低电平 } void main() { Timer0_Init(); while(1) { if(pwm_dir 1) { pwm_duty; if(pwm_duty 99) pwm_dir 0; // 到最亮, 开始变暗 } else { pwm_duty--; if(pwm_duty 1) pwm_dir 1; // 到最暗, 开始变亮 } delay_ms(5); // 这一步决定渐变速度 } }这个程序的逻辑很干净中断里只做两件事计数器加1和比较输出主循环里慢吞吞地修改pwm_duty变量让占空比以每5ms加1的速度变化。从最暗到最亮大约需要100 × 5ms 500ms视觉效果很柔和。4.2 工程化封装频率和占空比都可调的PWM模块上面例子适合教学真正做项目时建议把PWM封装成一个模块定时器初值、计数上限、占空比都抽出来方便移植和调节。我的做法是定义几个宏所有地方都引用宏以后改频率只改一处。#define FOSC 12000000UL // 系统时钟12MHz #define PWM_FREQ 1000UL // 想要的PWM频率, 单位Hz #define PWM_RES 100 // 分辨率, 即一个周期分成多少份 // 定时器中断间隔(us) 1000000 / (PWM_FREQ * PWM_RES) // 定时器计数初值 65536 - FOSC / 1000000 * 中断间隔(us) / 1(T分频) #define TICK_US (1000000UL / (PWM_FREQ * PWM_RES)) #define T0_LOAD (65536UL - (FOSC / 1000000UL) * TICK_US)有了这套宏想输出1kHz、分辨率100的PWM直接改宏定义主函数一行都不用动。占空比使用一个全局变量pwm_duty范围从0到PWM_RES主程序随时可以修改它比如按键加速、串口指令调整都只是赋值操作。4.3 热门应用映射舵机、WS2811、直流电机很多搜“STC15F104W PWM”的人其实最终想控制的对象各不相同。我把几个高频场景的实际参数整理成了表格方便你直接对号入座应用对象推荐PWM频率占空比范围注意事项舵机模拟/数字50Hz2.5%~12.5%对应0.5ms~2.5ms高电平频率必须稳定在50Hz附近否则舵机会抖WS2811灯带约800kHz每个bit用不同占空比表示软件PWM扛不住建议用IO模拟发送协议而不是传统PWM直流电机调速1kHz~20kHz0%~100%低于1kHz会听到啸叫高于20kHz开关损耗增大LED调光100Hz~1kHz0%~100%频率低于100Hz会看到闪烁人眼能明显感知先说舵机。模拟舵机要求的PWM周期是20ms也就是50Hz高电平脉宽在0.5ms到2.5ms之间。用STC15F104W做这个任务非常轻松软件PWM的分辨率可以做到0.1ms也就是说一个周期200个计数点里高电平占5到25个点。实测驱动SG90这类小舵机占空比更新得很平滑没有任何问题。WS2811就有点棘手。它的数据信号是一串连续的800kHz方波脉冲每一位0码和1码用不同占空比表示。传统意义上的“恒定PWM”对它没有意义它需要的是“按节奏翻转电平”本质上是时序模拟不是PWM。所以我一般不推荐用STC15F104W直接驱动WS2811如果非要用51系单片机做更好的办法是SPI硬件加电阻分压或者直接用IO口按位翻转但时序要求非常严格建议先看逻辑分析仪再动手。直流电机调速是软件PWM最典型的用武之地。我做过一个用STC15F104W控制小型直流减速电机的项目PWM频率定在4kHz分辨率100占空比从20%到100%可调通过按键加减速。电机工作稳定没有明显啸叫堵转保护也做得很好占空比低于某个阈值直接切断输出防止电机烧毁。5. 常见问题与排查经验5.1 频率一高波形就抖先怀疑定时器初值重装软件PWM最典型的故障是低频一切正常把频率调高之后波形开始抖动占空比和频率都变得不稳定。排查顺序我建议从硬件到软件这么来先看电源电机启动瞬间电压跌落会导致波形抖动这种情况在单片机电源和电机电源不分开时特别常见。再看中断负载是不是中断服务程序里塞了太多东西我见过有人在中断里调用delay函数直接把整个时基打乱。最后看定时器初值16位定时器手动重装初值时如果先装TH0再装TL0中间会有极短的时间差这个时间差在低频时无所谓高频时会被放大。解决办法是把重装初值的语句放在中断服务程序的第一行并且计算好初值让中断发生时定时器已经溢出不久误差最小。5.2 共享变量的“重入”问题volatile不能省主循环和中断服务程序共享变量时有一个新手必踩的坑变量忘记加volatile修饰。比如pwm_duty这个变量主循环里修改它中断里读取它。如果不用volatile编译器可能把pwm_duty优化到寄存器里主循环已经改了它中断里读到的还是旧值表现就是占空比调不动。把共享变量声明成volatile unsigned char这个问题就解决了。另外对于多字节变量比如16位的占空比计数器在主循环里修改时要考虑原子性先关中断再修改再开中断。不然中断可能在修改半路插入读到一个“拼凑”出来的错误值。5.3 驱动电机和H桥先想清楚死区再谈占空比如果你用PWM去驱动H桥电路控制电机必须认真考虑“死区”问题。所谓死区就是上下桥臂换向时需要留出一段两者都关闭的时间防止电源直接短路。硬件H桥通常有专门的死区电路但用STC15F104W做软件PWM时死区得靠程序保证。我的做法是在修改占空比时先强制把所有输出置为低电平延时几微秒再按新占空比输出。这套逻辑只适合低速或不频繁换向的场景。如果电机需要频繁正反转建议改用带硬件死区的专用电机驱动芯片软件PWM只负责给它送控制信号死区由驱动芯片内部的硬件逻辑处理。5.4 频率选择影响了什么啸叫、发热与堵转PWM频率对电机的影响很多人会忽略实际影响非常大。频率太低比如几百赫兹电机绕组会发出尖锐的啸叫因为PWM频率落在了人耳听觉范围内。传统做法把PWM频率抬高到16kHz以上“人耳听不见”但代价是开关管和电机的开关损耗上升发热明显。对小功率直流电机我常用4kHz~8kHz既听不见明显叫声发热也控制得住。如果电机出现爬行或堵转先别急着加占空比检查一下频率是否过低造成电机在“一顿一顿”地走。把频率提上去低速下的平稳性会明显改善。5.5 问题速查表现象可能原因解决办法波形抖动定时器初值重装时机不对初值重装放在中断第一行使用1T模式占空比调不动共享变量未用volatile声明volatile多字节变量修改时关中断电机啸叫PWM频率落在人耳范围提到4kHz以上电机堵转频率过低或启动占空比过低提高频率启动时给一个较高初始占空比下载失败冷启动顺序不对/串口被复用按“点下载再上电”顺序检查P3.0/P3.1外设频率偏差大内部IRC频率设置不匹配STC-ISP里修正IRC频率按实际频率计算初值6. 总结与个人经验写完这么多最后说点实在的体会。STC15F104W做PWM这件事本质上是用CPU时间换硬件资源它注定不适合超高频率、超高分辨率的PWM场景但在它舒适的区间里——比如1kHz到20kHz、分辨率100到1000——它的表现完全不输一颗带硬件PWM的芯片甚至因为引脚随便选、代码随便改反而更灵活。我自己的经验是中断频率超过200kHz趁早上硬件方案软件PWM扛不住硬扛只会把自己逼疯中断频率在100kHz以内软件PWM完全可以放心用。这个经验后来在多个项目里都救过我。最后再免费送一个小技巧如果想让占空比变化得非常平滑千万别在中断里做加减运算。中断里只负责比较和翻转IO渐变逻辑丢到主循环每次只加1让它随着主循环的节奏“顺便”完成。这个习惯能让你的中断服务程序始终保持在几十条指令以内PWM波形稳定程度会有一个质的提升。