单片机PWM从原理到实战:硬件与软件实现及常见问题解析
1. 从一个“灯不够亮”的抱怨说起PWM到底在解决什么问题很多刚接触单片机的朋友第一次听到PWM这个词大概率是在调LED亮度或者调电机转速的时候。我当时也一样拿着51单片机点了个LED发现它要么全亮要么全灭想让它“半亮”就傻眼了——IO口只有高电平和低电平两种状态哪来的中间值后来才知道解决这个问题的钥匙就是PWM全称Pulse Width Modulation中文叫脉冲宽度调制。说白了PWM就是通过快速切换开关状态用“开”的时间占比来等效出一个中间值。你想象一下家里的电灯开关如果我一秒钟按十次每次按下去半秒松开半秒灯其实是在闪烁的但只要频率够快人眼就分辨不出来只会觉得灯变暗了。PWM干的就是这件事只不过它把“手按开关”换成了定时器自动翻转IO口频率可以做到几千赫兹甚至几兆赫兹精度和稳定性完全不是手动能比的。这个技术点之所以在单片机领域这么重要是因为它几乎无处不在LED调光、电机调速、舵机角度控制、开关电源、音频输出、加热丝功率控制甚至红外遥控的编码发射底层都是PWM在支撑。你打开任何一块STM32或者51单片机的教程PWM输出都是必讲章节蓝桥杯单片机竞赛里PWM也是高频考点。可以说学不会PWM单片机的一大半应用场景你就没法碰。这篇文章我打算从实际使用的角度出发把PWM在单片机上的实现方式、参数计算、常见坑点、调试技巧都聊一遍。不管你是刚学51单片机的新手还是已经在用STM32做项目的朋友应该都能从中找到对自己有用的东西。我不会只讲寄存器怎么配而是会把“为什么这么配”讲清楚让你看完之后能自己推导参数而不是照抄例程。2. PWM的核心原理与关键参数拆解2.1 周期、频率、占空比三个必须搞清楚的量PWM信号本质上就是一个方波只不过这个方波的高电平时间和低电平时间可以调整。描述它需要三个核心参数周期T一个完整方波的时间长度单位通常是微秒或毫秒。频率f周期的倒数f 1/T单位是赫兹。比如周期20ms频率就是50Hz。占空比Duty Cycle高电平时间占整个周期的比例通常用百分比表示。占空比50%就是高电平低电平各占一半。这三个量里频率和占空比是最常调的。频率决定了PWM的“快慢”占空比决定了等效出来的“中间值”大小。举个例子如果你用PWM驱动LED频率选1kHz占空比从0%调到100%LED就从全灭渐变到全亮。如果你用PWM驱动电机频率选10kHz以上占空比50%时电机大概得到一半的平均电压转速就大约是满速的一半。这里有个容易混淆的点占空比和平均电压的关系。对于负载来说它感受到的是PWM信号的平均值。假设供电是5V占空比60%那平均电压就是5V × 0.6 3V。但注意这个关系只在负载对高频信号有“积分”效果时才成立比如电机线圈、LED加电容、加热丝的热惯性。如果你直接驱动一个纯电阻负载并且用示波器看看到的还是方波只是占空比变了。2.2 为什么频率选择这么关键频率选不对PWM要么没效果要么出问题。我分几种典型场景说一下LED调光频率建议在200Hz以上最好1kHz左右。低于100Hz人眼会感觉到闪烁尤其是余光看的时候特别明显。但也不能太高太高了LED的寄生电容会导致波形变形而且开关损耗增加。我一般用1kHz兼顾了无闪烁和波形质量。电机调速有刷直流电机建议频率在5kHz到20kHz之间。太低会有刺耳的啸叫声因为电机线圈在PWM频率下会振动发声。太高则MOS管开关损耗大发热严重。我实测下来12kHz左右是个比较舒服的平衡点既听不到噪音MOS管温度也可控。舵机控制舵机比较特殊它要求频率固定50Hz周期20ms然后通过高电平时间来控制角度。一般高电平0.5ms对应0度1.5ms对应90度2.5ms对应180度。这个不能随便改频率否则舵机会抖动甚至烧毁。开关电源频率通常几十kHz到几百kHz需要根据电感和电容的值来匹配这个属于比较专业的领域单片机直接做的不多。注意频率和占空比的精度是相互制约的。定时器位数固定时频率越高一个周期内的计数刻度越少占空比能调的档位就越少。比如16位定时器在1kHz时可以有65536个刻度占空比精度很高但如果你把频率提到100kHz刻度就只剩655个了占空比只能按0.15%的步进调。2.3 单片机产生PWM的两种主流方式单片机产生PWM信号归根结底有两种路子硬件PWM和软件PWM。硬件PWM靠的是单片机内部的定时器模块。你配置好定时器的周期寄存器和比较寄存器硬件就会自动在IO口上输出PWM波形完全不占用CPU时间。STM32的TIM模块、51单片机的PCA模块、STC单片机的PWM模块都属于这一类。硬件PWM的优点是频率稳定、占空比精确、不占CPU缺点是数量有限一个定时器通常只能输出几路PWM而且引脚是固定的。软件PWM则是用普通IO口靠定时器中断来手动翻转电平。比如你设置一个1ms的定时器中断中断里维护一个计数器计数值小于占空比就输出高否则输出低。软件PWM的优点是想用哪个IO就用哪个IO路数几乎不受限制缺点是占用CPU时间频率越高CPU负担越重而且波形容易受中断响应延迟影响精度不如硬件PWM。我一般的原则是能用硬件PWM就用硬件PWM实在不够用了再考虑软件PWM。比如做RGB调光需要3路PWMSTM32随便一个定时器就能出4路完全够用。但如果做16路LED调光硬件PWM不够那就得软件PWM来凑这时候频率就不能选太高500Hz左右比较稳妥。3. STM32上配置PWM输出的完整实操3.1 定时器选型与时钟树计算以STM32F103为例它有三个通用定时器TIM2、TIM3、TIM4每个都能输出4路PWM总共12路。另外还有高级定时器TIM1和TIM8每个能输出4路互补PWM加死区控制适合驱动H桥。一般应用用TIM2到TIM4就够了。配置PWM的第一步是算时钟。STM32F103的系统时钟通常是72MHz定时器的时钟来源是APB1或APB2总线。TIM2到TIM4挂在APB1上APB1的默认频率是36MHz但因为APB1预分频系数不为1时定时器时钟会倍频所以TIM2到TIM4的实际时钟是72MHz。TIM1和TIM8挂在APB2上时钟也是72MHz。假设我要输出1kHz的PWM计算过程是这样的定时器时钟72MHz目标频率1kHz周期寄存器值 72MHz / 1kHz 72000但72000超过了16位定时器的最大值65535所以需要先分频。设预分频系数为72则定时器时钟变为72MHz / 72 1MHz周期寄存器值 1MHz / 1kHz 1000。这样ARR自动重装载寄存器设为999因为计数器从0数到999正好是1000个刻度。占空比的计算就简单了CCR捕获比较寄存器值 (ARR 1) × 占空比。比如要50%占空比CCR 1000 × 0.5 500。要20%占空比CCR 200。3.2 寄存器配置步骤与代码实现用标准库配置TIM3的通道1输出PWM大致步骤如下// 1. 使能时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置GPIO为复用推挽输出 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; // TIM3_CH1对应PA6 GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置定时器时基 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period 999; // ARR TIM_TimeBaseStructure.TIM_Prescaler 71; // PSC TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 4. 配置PWM输出模式 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 500; // CCR50%占空比 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInitStructure); // 5. 使能预装载和定时器 TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE);这段代码跑起来之后PA6引脚就会输出1kHz、50%占空比的方波。如果你想动态调占空比只需要改CCR的值TIM_SetCompare1(TIM3, 300); // 改成30%占空比用STM32CubeMX配置的话更简单图形界面上点几下就生成代码了。但建议新手至少手动配一次寄存器这样能真正理解每个参数的含义。CubeMX生成的代码底层也是这些寄存器操作只是帮你封装好了。3.3 PWM模式1和模式2的区别STM32的PWM输出有两种模式PWM模式1和PWM模式2。它们的区别在于有效电平的定义。PWM模式1计数器小于CCR时输出有效电平大于等于CCR时输出无效电平。如果极性设为High那有效电平就是高电平所以占空比越大高电平时间越长。PWM模式2刚好反过来计数器小于CCR时输出无效电平大于等于CCR时输出有效电平。大部分情况下用PWM模式1就够了。但如果你驱动的是低电平有效的负载比如某些共阳LED或者PNP三极管那可以用PWM模式2或者保持模式1但把极性设为Low。两种方式效果一样看个人习惯。实操心得我建议统一用PWM模式1加极性High这样占空比和实际高电平时间的关系最直观。如果负载是低电平有效在硬件上加个反相器或者三极管倒相比在软件里绕来绕去要清晰得多。4. 51单片机上的PWM实现没有硬件PWM怎么办4.1 51单片机的定时器资源分析传统的51单片机比如AT89C51、STC89C52内部没有专门的PWM模块。它只有两个定时器T0和T1有些型号有T2。这些定时器只能做定时中断不能直接输出PWM波形。所以在51上做PWM基本都得靠软件模拟。软件PWM的思路是这样的用定时器产生一个固定频率的中断比如每100微秒中断一次。在中断服务程序里维护一个计数器每次中断计数器加一然后和设定的占空比比较小于就输出高大于就输出低。当计数器达到周期值时清零重新开始。假设我要产生1kHz的PWM周期是1ms。如果定时器每100微秒中断一次那一个周期需要中断10次。占空比的分辨率就是10级也就是10%的步进。想要更细的分辨率就得提高中断频率比如每10微秒中断一次一个周期中断100次分辨率就是1%。但中断频率越高CPU负担越重主程序能执行的时间就越少。4.2 软件PWM的代码框架与优化一个典型的51软件PWM代码框架#include reg52.h sbit PWM_OUT P1^0; unsigned char pwm_count 0; unsigned char pwm_duty 50; // 占空比0-100 unsigned char pwm_period 100; // 周期计数 void Timer0_Init() { TMOD 0xF0; TMOD | 0x01; // 定时器0模式116位 TH0 (65536 - 100) / 256; // 100us定时 TL0 (65536 - 100) % 256; ET0 1; EA 1; TR0 1; } void Timer0_ISR() interrupt 1 { TH0 (65536 - 100) / 256; TL0 (65536 - 100) % 256; pwm_count; if (pwm_count pwm_period) { pwm_count 0; } if (pwm_count pwm_duty) { PWM_OUT 1; } else { PWM_OUT 0; } }这段代码在P1.0上产生1kHz的PWM占空比由pwm_duty变量控制。主程序里随时改pwm_duty就能调亮度或转速。但这里有个问题中断服务程序里做了比较和IO操作如果主程序还有其他中断可能会产生冲突。而且100微秒一次中断每次中断大概需要几十个机器周期CPU占用率大概在10%到20%之间。如果系统里还有其他任务比如串口通信、按键扫描就得仔细安排优先级。4.3 多路软件PWM的实现技巧如果你需要多路PWM比如RGB三色灯那可以在一个定时器中断里同时处理多个IO口sbit LED_R P1^0; sbit LED_G P1^1; sbit LED_B P1^2; unsigned char duty_r 50, duty_g 50, duty_b 50; void Timer0_ISR() interrupt 1 { // 重装定时器 TH0 ...; TL0 ...; pwm_count; if (pwm_count 100) pwm_count 0; LED_R (pwm_count duty_r) ? 1 : 0; LED_G (pwm_count duty_g) ? 1 : 0; LED_B (pwm_count duty_b) ? 1 : 0; }这样三路PWM共用同一个定时器频率一致占空比各自独立。代价是中断里多做了几次比较和IO操作但51单片机跑100微秒中断还是绰绰有余的。注意软件PWM的频率不能太高。51单片机的主频通常是11.0592MHz或12MHz机器周期是1微秒左右。如果中断频率超过50kHz中断服务程序本身就可能占满CPU。我一般建议软件PWM频率控制在200Hz到2kHz之间超过2kHz就要考虑换带硬件PWM的单片机了。5. PWM实战中的典型问题与排查方法5.1 PWM驱动MOS管发热严重怎么破这是我在做电机驱动时踩过的最大的坑。当时用STM32输出20kHz的PWM通过MOS管驱动一个12V直流电机结果MOS管烫得能煎鸡蛋。排查了半天发现问题出在驱动电路上。MOS管发热的根本原因是开关损耗和导通损耗。开关损耗发生在MOS管从截止到导通或者从导通到截止的过渡过程中这时候MOS管同时有电压和电流瞬时功率很大。如果PWM频率太高开关次数多累积的开关损耗就大。如果驱动电流不够MOS管栅极充放电慢过渡时间就长开关损耗更大。解决办法有几个一是降低PWM频率从20kHz降到10kHz开关损耗直接减半二是加MOS管驱动芯片比如IR2104、TC4427它们能提供大电流快速充放电栅极缩短过渡时间三是选低栅极电荷的MOS管比如IRF3205换成IRLZ44N逻辑电平驱动栅极电荷小很多。我最后的方案是PWM频率降到12kHz加TC4427驱动MOS管换成IRLZ44N温度从烫手降到了微温问题解决。5.2 舵机抖动、嗡嗡响是什么原因舵机控制对PWM的要求比较特殊频率必须是50Hz周期20ms高电平时间在0.5ms到2.5ms之间。如果频率不对舵机会抖动甚至不转。如果高电平时间超出范围舵机会堵转电流剧增容易烧毁。我遇到过舵机嗡嗡响的情况排查下来是PWM信号的地线和电源地线没有共地。单片机的地和舵机电源的地必须连在一起否则信号电平没有参考舵机收到的信号就是乱的。另外舵机电源要足够大一个SG90舵机堵转电流能到1A以上如果电源供不上舵机会反复重启表现就是抖动。还有一个坑是软件PWM的精度问题。51单片机用软件PWM做50Hz周期20ms如果定时器中断间隔是100微秒那一个周期只有200个刻度高电平时间分辨率是100微秒。舵机要求的高电平时间精度大概在10微秒左右100微秒的步进会导致舵机角度跳动。所以舵机控制最好用硬件PWM或者把软件PWM的中断间隔降到10微秒。5.3 PWM调速时电机“飞车”怎么处理电机飞车是指电机突然全速运转不受PWM控制。这种情况通常发生在PWM占空比从0%突然跳到100%的时候或者PWM信号丢失的时候。对于H桥驱动如果上下桥臂同时导通还会烧毁MOS管。防止飞车的措施一是在软件里做斜坡启动占空比从0慢慢加到目标值不要突变二是加硬件保护比如在H桥驱动芯片的使能脚上加下拉电阻PWM信号丢失时自动关断三是用带故障保护功能的驱动芯片比如DRV8871、BTS7960它们内部有过流保护和死区控制。我在做小车项目时一开始没做斜坡启动遥控器一推杆电机就猛冲轮子打滑。后来在代码里加了占空比渐变每10ms增加1%启动就平顺多了。5.4 常见问题速查表现象可能原因排查方法解决方案LED闪烁PWM频率太低示波器看波形频率提高到200Hz以上电机啸叫PWM频率在人耳范围改变频率试听调到12kHz以上或5kHz以下MOS管发热开关损耗大测栅极波形上升时间加驱动芯片、降频率舵机抖动频率不对或地线未共地测PWM周期和地线连通性固定50Hz、共地、加大电源占空比不准定时器重装载值计算错误检查ARR和PSC重新计算周期值多路PWM互相干扰共用定时器但配置冲突检查各通道CCR确保同一定时器通道配置一致软件PWM抖动中断被高优先级打断检查中断优先级提高PWM中断优先级6. 进阶应用从PWM到实际项目落地6.1 基于PWM的智能照明调光系统智能照明是PWM最典型的应用之一。用STM32的TIM1输出三路PWM驱动RGB LED通过调节三路占空比就能混合出各种颜色。如果要加调光功能就在RGB基础上整体缩放占空比。我做过一个基于STM32F103和ESP8266的智能灯项目手机APP通过WiFi发送颜色值STM32收到后解析成三路PWM占空比。关键点是颜色过渡要平滑不能突变。我的做法是在主循环里做插值每20ms更新一次PWM值每次向目标值靠近一小步这样人眼看着就是渐变效果。代码里用一个简单的线性插值void update_pwm_smooth() { static uint8_t current_r 0, current_g 0, current_b 0; if (current_r target_r) current_r; else if (current_r target_r) current_r--; // 同理处理g和b TIM_SetCompare1(TIM1, current_r); TIM_SetCompare2(TIM1, current_g); TIM_SetCompare3(TIM1, current_b); }这个函数每20ms调用一次颜色变化就很顺滑。如果要做呼吸灯效果就是让目标值在0和最大值之间来回变化。6.2 PWM电机调速与测速闭环光有PWM调速还不够实际项目里往往需要闭环控制。比如小车要定速巡航就得测速加PID调节。测速一般用霍尔编码器或者光电码盘输出脉冲频率和转速成正比。我的做法是用STM32的另一个定时器做输入捕获测量编码器脉冲的周期换算成转速。然后和设定速度比较误差经过PID运算后调整PWM占空比。PID参数整定是个细活我一般先用纯比例Kp从0.1开始加加到系统开始振荡再回调一半然后加积分消除静差微分一般用得少。这里有个坑PWM频率和测速采样周期要匹配。如果PWM频率是12kHz测速周期是10ms那每个测速周期内PWM已经调整了120次系统响应会很快容易超调。我一般把测速周期设在50ms到100ms给系统足够的响应时间。6.3 PWM故障保护机制设计在工业控制或者汽车电子里PWM故障保护是必须的。STM32的高级定时器TIM1和TIM8自带刹车输入BKIN检测到故障信号时能自动把PWM输出拉到安全状态完全不经过CPU。这个功能在电机驱动里特别有用比如过流检测电路发现电流超标直接触发刹车PWM立刻关断保护MOS管和电机。配置刹车功能需要设置TIM_BDTR寄存器使能刹车输入设置刹车极性还可以配置自动恢复模式。刹车输入引脚通常是固定的比如TIM1_BKIN在PB12。硬件上把过流比较器的输出接到这个引脚一旦过流就触发刹车。如果用的定时器没有刹车功能那就得在软件里做。在PWM中断或者主循环里检测故障标志一旦发现异常立刻把CCR设为0或者关闭定时器。软件保护的响应速度取决于检测周期一般不如硬件刹车快但也能用。实操心得做电机驱动时我强烈建议至少加一层硬件保护。哪怕用最简单的保险丝或者自恢复保险也比纯软件保护靠谱。软件会跑飞硬件不会。7. 一些零散但重要的经验补充关于PWM极性和有效电平我再啰嗦一句。很多新手调PWM时发现占空比越大灯越暗就是因为极性搞反了。记住一个原则先确认负载是高电平有效还是低电平有效然后决定PWM模式1还是模式2或者调极性。如果实在搞不清就用示波器看波形高电平时间占的比例就是实际占空比别管代码里写的多少。关于PWM和定时器中断的优先级如果系统里同时用了PWM和串口通信要注意中断优先级安排。串口中断如果太频繁可能会打断PWM中断导致波形抖动。我一般把PWM中断设为高优先级串口设为低优先级因为PWM波形抖动是实时的串口数据晚一点处理没关系。关于软件PWM的IO口选择尽量选同一个端口的连续引脚这样可以用位操作一次性更新多个IO效率更高。比如P1口的P1.0到P1.3做四路PWM可以用P1 (P1 0xF0) | pwm_state一次赋值搞定比逐个引脚操作快很多。关于PWM频率和听觉人耳能听到20Hz到20kHz的声音。如果PWM驱动的负载会振动发声比如电机、电感、陶瓷电容那PWM频率落在人耳范围内就会听到啸叫。要么把频率提到20kHz以上要么降到20Hz以下。但20kHz以上开关损耗大20Hz以下负载会抖动所以通常选12kHz到16kHz之间刚好在听觉上限附近大部分人听不到损耗也可接受。最后说一个调试技巧调PWM的时候示波器是最好的朋友。没有示波器的话可以用一个RC低通滤波器把PWM变成模拟电压然后用万用表测。RC的时间常数要远大于PWM周期比如PWM频率1kHz周期1msRC选10ms左右R取10kC取1uF。这样万用表读到的就是平均电压能直观反映占空比变化。这个方法我经常用简单有效尤其适合现场没有示波器的情况。