单片机PWM从原理到实战:硬件与软件实现及常见问题解析
2026/9/23 11:05:26 网站建设 项目流程

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。这样万用表读到的就是平均电压,能直观反映占空比变化。这个方法我经常用,简单有效,尤其适合现场没有示波器的情况。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询