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路PWM,STM32随便一个定时器就能出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; // CCR,50%占空比 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,模式1,16位 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,周期1ms,RC选10ms左右,R取10k,C取1uF。这样万用表读到的就是平均电压,能直观反映占空比变化。这个方法我经常用,简单有效,尤其适合现场没有示波器的情况。