
做单片机开发这几年总有人问我同一个问题PWM到底怎么玩其实这问题不算复杂但确实值得好好讲一讲。因为PWM用好了呼吸灯、电机调速、舵机控制、LED灯带驱动、逆变器、开关电源这些项目都能顺手搞定用不好轻则输出波形不对重则烧管子烧单片机。我最早被PWM坑过一次是在一个直流电机调速的小板子上当时偷懒直接用IO口软件翻转结果频率上不去、电机滋滋响后来换用定时器硬件PWM才解决。所以这次我把这几年积累的PWM开发案例和源码思路整理成一篇完整的实操笔记从51单片机入门到STM32高级定时器再到PWMDMA驱动WS2811灯带尽量把关键步骤、参数计算、避坑经验都写清楚。无论你是刚接触单片机的新手还是已经做了几个项目的进阶选手这篇笔记都能让你少走不少弯路。1. 先搞清楚PWM的本质与三个关键参数开始写代码之前建议先把PWM这层窗户纸捅破。PWM的全称是脉冲宽度调制说白了就是控制一个GPIO引脚在固定的周期内高电平保持多长时间、低电平保持多长时间。输出的这一串方波并不会直接告诉你我在输出多少伏电压但它能告诉负载一个等效功率高电平比例越高等效输出的平均电压就越高负载获得的功率也越大。1.1 占空比、频率、分辨率这三个词到底什么意思占空比指一个周期内高电平时间占总周期的百分比。占空比50%意味着高电平一半、低电平一半直流电机转起来大约是半功率占空比0%等于一直输出低电平电机停转占空比100%等于一直输出高电平电机全速。这个比例就是我们控制对象的核心变量。频率指一秒内输出多少个周期单位Hz。频率会影响负载的响应特性。控制LED灯频率太低会看到闪烁控制电机频率太低会听到尖锐的啸叫或感觉电机扭矩不足控制舵机频率一般锁死在50Hz也就是20ms一个周期因为舵机的位置环就按这个节拍工作。分辨率指占空比能够调节的最小变化量。用8位定时器做PWM占空比调节步长是1/256用16位定时器做PWM步长可以做到1/65536。分辨率越高控制越细腻但对定时器位宽和中断开销的要求也更高。这三者经常互相制约想要高分辨率又要高频率还得硬件支持足够高的时钟源否则只能牺牲其中一个。1.2 不同应用场景对频率的真实要求LED呼吸灯通常用1kHz到10kHz。1kHz以下肉眼能察觉亮度波动10kHz以上其实也没必要因为人眼对高频变化的感知已经饱和了。直流电机调速一般在10kHz到20kHz比较合适。低于10kHz会有明显的开关噪音高于20kHz电机驱动芯片的开关损耗会变大而且有些驱动芯片的上桥和下桥死区时间跟高频不匹配容易出现直通烧管子。舵机控制固定用50Hz这个跟电机不一样。舵机内部的信号解码电路只认50Hz的周期你的高电平时长在0.5ms到2.5ms之间对应舵机从0度到180度。DCDC开关电源的PWM频率通常在100kHz到2MHz之间这个领域已经超出普通单片机直接控制的范畴一般要用专门的电源控制器芯片或者用带高频PWM输出的高性能MCU。1.3 生成PWM的两条技术路线硬件外设与软件模拟做PWM方案时先想清楚一个问题用硬件定时器输出还是用软件延时翻转IO。硬件PWM是由单片机的定时器比较单元直接控制的。定时器计数器累加到比较值引脚自动翻转整个过程不需要CPU介入。CPU只需要在需要改变占空比的时候更新一下比较寄存器。这种方法波形稳定、频率准确、占空比分辨率高是绝大多数项目的首选。软件模拟PWM是在定时器中断服务函数里手动翻转IO电平。虽然灵活但波形质量完全取决于中断响应是否及时。如果有更高优先级的中断插入PWM周期就会出现抖动。我见过不少新手用delay函数实现PWM结果整个程序几乎跑不了别的任务。软件模拟只适合对波形要求不高、资源受限的情况比如只需要驱动一个LED的玩具项目。2. 入门案例51单片机实现LED呼吸灯与简易风扇调速很多初学者接触到的第一个MCU就是51单片机。STC89C52这类经典51单片机内部没有硬件PWM外设所以入门阶段必须先掌握如何在51上模拟PWM。这里我拆解两个非常实用的案例一个是呼吸灯一个是风扇调速。2.1 51没有硬件PWM怎么办——定时器中断模拟51单片机的定时器可以工作在16位定时模式下利用它产生一个固定的时间基准比如每50微秒进入一次中断。在这个中断函数里维护一个计数器比如目标周期是5毫秒那一个周期就是100个节拍。在每个节拍到来时比较当前计数和高电平阈值决定IO引脚输出高还是低。这样做思路很直观阈值就是占空比。计数器每加一就对应1%的占空比变化精度。如果要更细腻可以把节拍时间调小或者把周期调大但代价是中断频率上升、CPU占用率增加。实测下来在12MHz晶振下这种方案能稳定输出1kHz的PWM占空比分辨率到1%。2.2 呼吸灯代码拆解与定时器初值计算先看定时器初值的计算。STC89C52在12MHz晶振下机器周期是1微秒。要让定时器0每50微秒中断一次采用16位模式65536 - 50 65486换算成十六进制是0xFFCE。所以初值高字节TH00xFF低字节TL00xCE。中断服务函数里做这样几件事关闭定时器、重新赋初值、处理PWM计数、判断输出状态、打开定时器。之所以要先关闭再赋值是为了防止在写入TH0和TL0的间隙定时器用中间值重新开始计数导致时间基准漂移。核心代码如下#include REGX52.H sbit PWM_PIN P1^0; unsigned char pwm_counter 0; unsigned char pwm_duty 0; unsigned char pwm_period 100; void timer0_init(void) { TMOD 0xF0; TMOD | 0x01; TH0 0xFF; TL0 0xCE; ET0 1; EA 1; TR0 1; } void timer0_isr(void) interrupt 1 { TR0 0; TH0 0xFF; TL0 0xCE; pwm_counter; if (pwm_counter pwm_period) { pwm_counter 0; } if (pwm_counter pwm_duty) { PWM_PIN 1; } else { PWM_PIN 0; } TR0 1; } void main(void) { unsigned int i; unsigned char direction 1; timer0_init(); while (1) { for (i 0; i 5000; i); // 简单的延时控制呼吸速度 if (direction) { pwm_duty; if (pwm_duty 99) { direction 0; } } else { pwm_duty--; if (pwm_duty 1) { direction 1; } } } }主循环里不断更新pwm_duty从1逐渐增加到99再逐渐降下来LED的亮度就会从暗到亮、从亮到暗形成呼吸效果。实际测试时如果呼吸速度太快就把for循环的延时加大如果感觉LED在某个亮度区间有跳变就把pwm_period提高比如改成200占空比步进变成0.5%。2.3 用PWM驱动直流电机小风扇做完呼吸灯再把同样的PWM思路搬到直流电机上。需要注意电机是感性负载和LED不一样不能直接用单片机引脚驱动必须通过三极管或MOS管作为功率开关。拿经典的8050三极管电路举例。单片机引脚输出PWM信号经过一个1kΩ电阻接到三极管基极发射极接地集电极接电机负极电机正极接电源。这里有个重要细节电机两端必须反向并联一个续流二极管型号用1N5819或1N4007都行。因为PWM关断瞬间电机绕组会产生反向感应电动势不接续流二极管这个高压尖峰很容易击穿三极管。电机转速控制逻辑和呼吸灯完全一致区别在于占空比和转向处理。风扇正转时占空比从10%慢慢加到90%转速跟随上升。如果单片机引脚输出占空比90%以上的波形给三极管基极三极管处于饱和导通状态电机两端近似直接接电源此时PWM的作用就接近普通开关。所以PWM调速真正有效的区间通常控制在10%到90%之间两头都留一点余量。2.4 关于频率与驱动管选型的一点体会用软件模拟PWM驱动电机时我把频率定在2kHz到5kHz之间。这个区间有几个好处51单片机的定时器中断压力不大三极管开关损耗也能接受电机运转时的噪音在可接受范围。如果频率超过10kHz8050这类普通三极管的开关特性就跟不上了波形边沿会变得圆润轻则效率下降重则管子发热严重。对于电机功率在5W以内的场景8050或S8050足够如果电机功率更大换成MOS管比如AO3400这类N沟道MOS管。MOS管是电压驱动器件栅极只需要电压不需要持续电流比三极管更适合高频PWM而且导通内阻低发热小。我早期用三极管驱动12V直流电机PWM频率调到8kHz以上三极管温升明显换AO3400之后问题立刻消失了。3. 进阶案例舵机PWM控制与多路信号生成舵机在机器人、摄像头云台、智能小车这些项目里太常见了也是PWM应用里最讲究的一个场景。舵机不像电机那样占空比越高转速越快它需要严格的50Hz周期和特定范围的高电平时长占空比只是外在表现真正被解码的是高电平的脉冲宽度。3.1 舵机需要什么样的PWM信号舵机内部有一个基准电路和一个电位器反馈外部输入的高电平脉冲宽度决定目标位置。标准舵机的控制关系是20ms周期前提下0.5ms高电平对应0度1ms对应45度1.5ms对应90度2ms对应135度2.5ms对应180度。不同品牌舵机可能存在少量差异比如有些舵机1ms对应0度、2ms对应180度拿到舵机之后最好用示波器或逻辑分析仪实测确认。需要特别注意的是舵机的PWM周期并非恒定20ms才能工作。实际上周期在10ms到30ms之间舵机都能解码因为舵机内部判断的是高电平宽度周期只要处在一定范围内就行。但既然资料都标注20ms程序里就按20ms做最保险。3.2 用STC单片机输出稳定舵机控制信号用51单片机控制一路舵机方案不止一种。我推荐用定时器中断的方式在中断里精确控制高电平时长和周期时长。以12MHz晶振为例如果让定时器每10微秒中断一次中断里维护一个计数变量一个20ms周期就是2000个节拍。这样输出高电平0.5ms就是节拍50输出2.5ms就是节拍250。代码结构大致如下中断服务函数里通过状态切换实现#include REGX52.H sbit SERVO_PIN P1^1; unsigned int servo_count 0; unsigned int servo_pulse_ticks 150; // 默认1.5ms90度 void timer0_init(void) { TMOD 0xF0; TMOD | 0x01; TH0 0xFF; TL0 0xF6; // 10微秒定时初值12MHz下65536-10 ET0 1; EA 1; TR0 1; } void timer0_isr(void) interrupt 1 { TR0 0; TH0 0xFF; TL0 0xF6; servo_count; if (servo_count 2000) { servo_count 0; SERVO_PIN 1; } else if (servo_count servo_pulse_ticks) { SERVO_PIN 0; } TR0 1; }这里有个细节10微秒中断一次单片机在中断里做的事情非常少所以CPU负载很低。而且周期计数逻辑在最开始就判定servo_count是否到达2000一旦到达就从下一个节拍重新拉高电平这样能够自动保证20ms周期不漂移。舵机转动角度到指定占空比后主程序只需要修改servo_pulse_ticks这个变量。比如要舵机转到180度就把servo_pulse_ticks设成250。实际测试发现有些舵机的PWM信号不能从占空比0%突然跳到99%最好在程序里做一个平滑过渡每次只改变几个节拍否则舵机会猛甩过去容易打齿。3.3 多路舵机控制的常见做法控制多路舵机时最简单的思路就是一个定时器中断在中断函数里依次处理每路舵机的翻转时刻。比如要控制4路舵机可以维护一个数组存放每路的脉冲节拍中断里统一计数每到一个舵机的翻转时刻就翻转对应的IO引脚。关键点是所有舵机的周期必须同步。如果在中断里维护4个独立的20ms周期标志因为翻转时刻不同周期会互相错开虽然舵机一般也能工作但脉动电流会叠加电源容易被拉垮。更好的做法是让所有舵机共享同一个周期起点在周期的前2.5ms内依次输出高电平脉冲。这样整个周期的前2.5ms内所有舵机信号都处理完毕剩余17.5ms的静默期给电源一个缓冲。4. STM32硬件定时器PWM从呼吸灯到电机驱动STM32跟51单片机最大的区别之一就是内部有完整的定时器PWM硬件外设。拿STM32F103来说高级定时器TIM1和TIM8能输出带死区、带互补的PWM通用定时器TIM2到TIM5也能输出标准PWM。用硬件PWM的好处是CPU完全解放占空比更新只需要写一个寄存器。4.1 输出比较与PWM模式的原理先理解STM32定时器产生PWM的底层机制。定时器的计数器CNT从0开始每个时钟周期加一加到自动重装载值ARR之后归零重新开始。有两个比较重要的寄存器ARR决定PWM周期CCR决定占空比。PWM模式下当CNT小于CCR时输出有效电平当CNT大于等于CCR时输出无效电平这样就形成了一个周期方波。占空比计算很直接占空比等于CCR除以ARR。要输出20kHz、50%占空比的PWM如果ARR设成999CCR就设成500。这里的ARR只是一个中间值实际PWM频率由定时器时钟、预分频值和ARR共同决定。输出频率 定时器时钟 / (预分频值 1) / (ARR 1)。这个公式一定要理解透因为后面配置CubeMX、手写寄存器都靠它。4.2 CubeMX配置PWM的操作要点用STM32CubeMX配置PWM步骤不复杂但有几个细节影响很大。第一步在Pinout视图中选中目标引脚选择TIMx_CHx功能。第二步在Timers里打开对应定时器Clock Source选择Internal Clock在PWM Generation Channel x处勾选Enabled。第三步设置Prescaler和Counter Period也就是预分频器和自动重装载值。以STM32F103C8T6、72MHz系统时钟、TIM2输出20kHz PWM为例如果要求分辨率到0.1%那么计数周期就是1000预分频值就要保证72MHz除以(预分频值1)后乘以1000正好等于20kHz。计算出来预分频器填3ARR填899。这里我直接给出常用算式(72MHz / 4) / 1000 18kHz所以真正对上的配置是预分频值3、ARR填899、得到20kHz。注意CubeMX里预分频值会自动减1所以配置界面上看到的PSC是3实际代码里寄存器值是3对应分频系数为4。这块易混淆的地方就是填多少和实际分频多少之间差1的问题配置完最好在Clock Configuration里核对一下实际输出频率。生成代码后在main函数中启动PWMHAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1);修改占空比直接调用__HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, 500);把CCR设成500对应50%占空比。如果要对占空比进行平滑渐变只需在程序循环里递增或递减这个宏的第二个参数。4.3 高级定时器的互补输出与死区设置做电机驱动、逆变器、H桥电路时需要两个互补的PWM信号驱动上桥和下桥MOS管还要在这两个信号之间插入死区时间。死区是必须的因为上管关断和下管开启之间存在延迟如果上下管同时导通电源会直接短路瞬间烧毁MOS管或驱动芯片。STM32的TIM1和TIM8专门支持互补输出和死区插入。在CubeMX的TIM1配置里选择PWM Generation Channel 1和Channel 1N这两个互补通道会自动出现在同一个定时器的不同引脚上。死区时间设置一般填一个数字比如填100表示死区100个时钟周期。在72MHz定时器时钟下100个周期约等于1.4微秒对绝大多数MOSFET驱动来说够用。如果我们要产生一组20kHz、死区1微秒的互补PWM定时器时钟72MHz下死区时间72个周期比较合适。CubeMX里配置死区值的计算规则跟定时器版本有关通常在40到180之间取一个数。更保险的做法是生成代码后在主程序里查看TIM_BreakDeadTimeConfigTypeDef结构体中的DeadTime值确认实际插入时间符合预期。除了死区高级定时器还带有刹车输入。刹车功能的意义在于当外部电路异常比如电机过流信号触发时可以一键强制PWM输出为安全状态防止炸机。我一般会把驱动板的过流保护信号引到刹车引脚一旦电流超标硬件自动封锁输出连CPU干预都不需要。4.4 中心对齐模式与ADC采样时刻点设置平时用的PWM是边沿对齐模式计数器从0加到ARR再立刻归零波形上升沿对齐。中心对齐模式则不同计数器先加到ARR再向下递减到0如此往复。这样输出的PWM波形中高电平的中间点正好处于计数器峰值或谷值的时刻。中心对齐模式在电机控制里很常见因为电流采样需要避开上下桥开关切换的噪声区域。如果我们在PWM周期的某些固定时刻启动ADC采样就能躲开开关噪声采到稳定的电流值。设置方法是在CubeMX的TIM配置里Counter Mode选择Center Aligned Mode1或Mode2然后启用触发输出TRGO再把ADC配置成由定时器触发启动。ADC采样时刻的调整主要靠修改CCR值达到。因为采样点相对PWM上升沿和下降沿的位置会随占空比变化想要始终在PWM高电平中间的稳定区域采样则需要在ADC采样完成前留出足够的稳定时间。实际操作中最重要的经验是ADC采样保持时间不要设得太短。用1.5周期的采样时间在噪声严重的电机系统里经常采到尖峰改成7.5周期甚至13.5周期后采样值就稳定很多。5. PWM加DMA方案用最短CPU开销驱动WS2811这些年智能灯带、全彩氛围灯项目特别火WS2811和WS2812系列灯带随处可见。驱动这类灯带的时候单纯用PWM而不配合DMA很容易把CPU占满。所以这里单独讲讲PWM加DMA如何驱动WS2811顺便解答热搜里51单片机PWM驱动WS2811和PWM加DMA的问题。5.1 为什么WS2811要用PWM加DMAWS2811芯片要求在一个约1.25微秒的周期内高电平占900纳秒表示数据1高电平占350纳秒表示数据0。每个颜色通道8位数据一路灯带上有几十个上百个灯珠而每个灯珠需要24位数据总数据量非常大。如果CPU用普通延时函数一位一位翻转IO单片机将长时间阻塞在发送逻辑里CPU什么都干不了。用定时器的DMA功能就可以把每一位数据设计成一次PWM输出DMA控制器自动把内存中的占空比配置搬运到定时器的CCR寄存器CPU从头到尾不需要干预。换一种通俗的理解传统方式是人手一把一把往炉子里扔煤PWM加DMA是建了一条传送带煤自己沿着轨道往里送人只是提前把煤码好。这也是PWM加DMA方案最大的价值让单片机在处理灯带的同时还能跑蓝牙协议栈、按键扫描、屏幕刷新等其他任务。5.2 DMA发送的时序设计与源码思路先测CS从数据到灯珠的时间关系通常90kHz左右比较合理。为了给每一位数据生成独立的高电平宽度定时器运行在比较模式下DMA在每次比较匹配时更新CCR值。数据位的排列顺序是GRB不是RGB这个细节坑了很多人灯带颜色不对多数是顺序弄反了。设计思路是准备一个比较大的数组每个灯珠24个元素每个元素的值对应定时器CCR寄存器需要写入的值。针对1码和0码分别计算出对应的CCR值高电平时间长的编码为1码短编码为0码。再算上RESET信号低电平时间要超过50微秒。部分关键代码示意如下#define WS2811_PWM_HIGH 78 // 约900ns对应1码 #define WS2811_PWM_LOW 35 // 约350ns对应0码 uint32_t ws2811_buffer[LED_COUNT * 24 50]; void ws2811_send(void) { HAL_TIM_PWM_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t *)ws2811_buffer, LED_COUNT * 24 50); }每个字节转换时把每一位数据按从高位到低位的顺序展开。整个过程用DMA搬运CPU几乎不花时间。需要注意的是DMA搬运的缓冲区必须保持有效不能定义为函数内的局部变量否则函数退出后数据被释放DMA还在读这块内存就会读到随机值。我在第一次做这个项目时把缓冲区定义在main函数内结果灯带前几个灯珠的颜色乱七八糟排查半天才发现是DMA访问了已失效的栈内存。5.3 实现中的避坑经验与硬件注意点WS2811灯带的电源问题特别容易被忽视。一个灯珠全亮时电流能达到60毫安100个灯珠全亮就是6安培。USB供电远远不够必须用5V大电流电源并且电源地和单片机地要共地。逻辑电平匹配也很关键。大部分WS2811芯片能识别3.3V的输入高电平但个别批次和长距离传输后信号衰减明显。遇到灯带颜色不对、后面灯珠不亮的情况优先检查最后一个正常灯珠附近的接线其次是检查信号线是否过长。信号线超过20厘米最好加一个电平转换芯片或74HC245缓冲器否则波形边沿变缓数据传输会出错。另外PWM加DMA发送过程如果被中断频繁打断可能导致发送时序不连续。DMA请求本身不受中断影响但它依赖定时器持续运行。如果有更高优先级任务需要占用定时器比如用同一个定时器输出其他通道的PWM信号要注意通道之间不能互相干扰。6. PWM常见问题与排查技巧实录做PWM项目踩坑是难免的这里把高频出现的问题整理成速查表方便直接对照排查。表现可能原因排查与解决舵机抖动或行程不到位PWM周期不是20ms或高电平脉宽超范围用示波器测引脚波形确认周期和脉宽电机不转但发热占空比太小电机启动扭矩不足启动瞬间给一个较高的占空比稳定后再降低电机全速无法调速驱动管深度饱和PWM信号未真正传递到功率端检查驱动管基极电阻是否太大或改用MOS管灯带颜色乱、尾部灯珠闪烁信号线过长或逻辑电平不匹配缩短线缆加74HC245电平缓冲模拟PWM时CPU卡死中断函数耗时过长中断里只做计数和IO比较状态逻辑移到主循环高级定时器无输出互补输出和刹车引脚未正确配置检查CubeMX中Channel和ChannelN是否都使能这里再展开讲几个容易忽略的快速定位技巧。6.1 用万用表的频率档和占空比档做初检没有示波器的初学者可以先拿万用表做初检。数字万用表一般都有频率测量功能把红表笔接到PWM输出引脚黑表笔接地切换到Hz档能读出当前频率。占空比档通常在电压档附近有个%标识测出来的值就是当前占空比。这个方法虽然没法看波形形状但排查频率和占空比设置是否合理已经够了。需要特别提醒的是万用表测PWM输出电压档得到的电压值是平均电压。比如5V、50%占空比的方波直流档会显示约2.5V。这个读数可以用来反向验证占空比是否真的改变了。6.2 示波器或用逻辑分析仪看上升沿和下降沿有条件的话强烈建议上示波器或者逻辑分析仪。PWM看起来是简单的方波但波形上升沿和下降沿能看出很多问题。上升沿太缓说明引脚驱动能力不足或者线缆电容太大下冲过深说明阻抗不匹配长线上尤其明显。用逻辑分析仪看舵机信号时可以直接测量高电平脉冲宽度是不是0.5ms到2.5ms之间比读示波器还直观。有个印象很深的排查经历一个项目里舵机偶尔抽搐一下查了半天程序没问题最后用逻辑分析仪一看发现定时器中断里有一小段代码执行时间太长导致脉冲宽度偶尔抖动。把那段代码移出中断后问题彻底消失。6.3 关于电机飞车的安全提示热词里有一条pwm电机飞车这个情况值得单独提醒。PWM信号异常时比如程序跑飞、定时器配置被意外修改、驱动芯片损坏电机可能突然全速运转。如果在机械臂或小车上飞车会造成安全事故。所以电机供电电路里最好加一个独立于单片机的硬件看门狗超过一定时间没有收到PWM信号就自动切断电源这才是更稳妥的方案。6.4 频率测量误差与定时器时钟源校准最后一个小细节测量PWM频率时万用表或逻辑分析仪本身有参考时钟误差测出来的值和理论值存在百分之零点几的偏差是正常的。如果做高精度PWM需要校准单片机的时钟源。STM32使用内部RC振荡器时频率误差可能达到1%到2%对LED灯无所谓但对舵机或同步信号来说就不够了需要改用外部晶振或经过校准的时钟源。做过的项目越多越能体会PWM不是输出一个方波这么简单。它背后涉及定时器资源分配、功率电路设计、信号完整性问题以及系统和硬件的协同配合。希望这篇笔记能帮你在实际项目中少踩几个坑用更稳的方案把PWM用起来。