☰
PWM原理与STM32实战:从LED调光到电机调速的完整指南
2026/10/8 16:00:09 网站建设 项目流程

1. 从一颗LED的呼吸效果说起:PWM到底在干什么

很多人第一次接触PWM,都是在调LED亮度的时候。代码里写一个TIM_SetCompare1(TIM3, 500),灯就暗一点,改成900,灯就亮一点。看起来很简单,但如果你停下来问自己一句:为什么改变一个"比较值",灯就会变亮变暗?这个问题如果答不上来,那后面调电机、调舵机、调加热丝,基本就是照抄代码,出了问题也不知道从哪查。

PWM的全称是Pulse Width Modulation,中文叫脉冲宽度调制。它的本质其实就一句话:用一串频率固定的方波,通过改变高电平占整个周期的比例,来等效地控制输出功率。注意这里的关键词是"等效",因为PWM并没有真的去改变电压大小,它改变的是"通电时间占比"。

拿LED举例。假设你用3.3V的GPIO去驱动一颗LED,串了一个限流电阻。如果你一直输出高电平,LED就是最亮;一直输出低电平,LED就灭。那如果我想让它半亮呢?直接输出1.65V?普通GPIO做不到,就算用DAC做到了,效率也很低。PWM的思路是:我让它在1毫秒内,前0.5毫秒输出高,后0.5毫秒输出低,然后不断重复。因为LED和人的眼睛都有"惯性",人眼存在视觉暂留效应,它看到的不是一闪一闪,而是"平均亮度"——也就是50%的亮度。

这就是PWM最朴素的理解:它不是调电压,而是调"开的时间比例"。这个比例有个专门的名字,叫占空比(Duty Cycle),公式是:

占空比 = 高电平时间 / 整个周期时间 × 100%

比如周期是1ms,高电平0.3ms,占空比就是30%。周期是20ms,高电平1.5ms,占空比就是7.5%。周期不变,只改高电平时间,平均功率就跟着变。这就是PWM能"调速""调光""调温"的根本原因。

这里有个特别容易被忽略的点:PWM的频率必须足够高,高到负载"反应不过来"。对LED来说,频率低于50Hz人眼就能看出闪烁,所以一般用几百Hz到几kHz。对直流电机来说,频率太低电机会听到"嗡嗡"声、转动不连续,太高又会让驱动MOS管发热增加,所以常用1kHz到20kHz之间。对加热丝这种热惯性极大的负载,频率甚至可以低到1Hz都没问题,因为热量根本来不及散掉。

所以你看,PWM不是一个"玄学",它是一个非常物理的东西:用时间换功率。理解了这一点,后面所有的配置、寄存器、库函数,都只是实现手段而已。

2. 占空比、频率、分辨率:三个参数决定PWM好不好用

真正开始写代码之前,必须把PWM的三个核心参数搞清楚,否则你会遇到"灯调不亮""电机有异响""舵机抖个不停"这些典型问题。这三个参数就是:频率、占空比、分辨率。它们互相牵制,不是随便设的。

2.1 频率决定了"快慢",也决定了适用场景

频率就是PWM波形一秒钟重复多少次,单位Hz。它决定了这个PWM信号适合驱动什么。

应用场景常用PWM频率原因
LED调光200Hz ~ 5kHz太低会闪,太高开关损耗大
直流电机调速1kHz ~ 20kHz太低有啸叫、转动不平滑,太高驱动发热
舵机控制50Hz(周期20ms)标准舵机协议固定,脉宽0.5~2.5ms
步进电机细分20kHz以上需要高于人耳听觉范围,避免噪音
开关电源几十kHz ~ 几百kHz频率越高,电感电容可以越小

这张表不是死规定,但它反映了一个规律:频率的选择,本质是在"负载能不能接受"和"开关损耗能不能承受"之间找平衡。频率低,开关次数少,损耗小,但负载会"感觉到"断续;频率高,负载感觉平滑,但每次开关都要消耗能量,驱动器件会热。

2.2 占空比决定了"输出多少",但它有边界

占空比从0%到100%,0%就是一直低电平,100%就是一直高电平。中间的值就是"部分导通"。但实际使用中,有两个边界要注意:

  • 0%和100%不是所有定时器都能干净输出。有些定时器的PWM模式在比较值等于自动重装载值时,输出电平会有一个周期的毛刺。如果你用PWM控制MOS管,这个毛刺可能导致误导通。
  • 占空比不能无限细分。这就引出了分辨率的问题。

2.3 分辨率决定了"能调多细"

分辨率指的是占空比能分成多少档。它由定时器的计数位数决定。比如一个16位定时器,自动重装载寄存器ARR设为65535,那占空比就能分成65536档,分辨率就是1/65536,约0.0015%。如果ARR只设成99,那就只有100档,分辨率1%。

这里有个非常重要的公式,必须记住:

PWM频率 = 定时器时钟 / ((ARR + 1) × (PSC + 1))

其中ARR是自动重装载值,PSC是预分频值。而分辨率就是ARR+1的倒数。

关键矛盾来了:在定时器时钟固定的情况下,频率和分辨率是此消彼长的。你想要频率高,ARR就得小,分辨率就低;你想要分辨率高,ARR就得大,频率就低。

举个例子。STM32F103的TIM3挂在72MHz总线上。如果我想输出1kHz的PWM,同时想要1000档分辨率:

  • 先定ARR = 999(1000档)
  • 那么 1000 = 72,000,000 / ((999+1) × (PSC+1))
  • 算出来 PSC+1 = 72,PSC = 71

这样频率是1kHz,分辨率是1/1000。如果我想把分辨率提高到10000档,ARR=9999,那PSC+1=7.2,不是整数,只能取7或8,频率就会变成1.028kHz或900Hz,不再是精确的1kHz。

这就是为什么很多教程里说"PWM频率和分辨率要折中"。在实际项目里,先确定负载需要的频率,再在满足频率的前提下,把ARR尽量设大,以获取更高的分辨率。这个顺序不能反。

3. 从寄存器到库函数:STM32上配置一路PWM的完整链路

理论说完了,现在进入实操。我以STM32F103的TIM3通道1(对应PA6引脚)为例,把配置PWM的完整链路走一遍。之所以选TIM3,是因为它挂在APB1总线上,时钟是72MHz,比较有代表性。如果你用的是其他型号,比如GD32、HC32F460,逻辑是一样的,只是寄存器和库函数名字不同。

3.1 第一步:搞清楚时钟从哪来

很多人配置PWM失败,第一步就错了——没开时钟。STM32的外设默认是不工作的,你必须手动使能它的时钟。TIM3挂在APB1上,GPIOA挂在APB2上,所以:

RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

如果你用的是HAL库,那就是:

__HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE();

这一步看起来简单,但它是"灯不亮"最常见的原因之一。我见过太多人代码逻辑全对,就是忘了开时钟,查了半天。

3.2 第二步:把引脚配成复用推挽输出

PA6要输出PWM,不能配成普通的推挽输出,必须配成复用推挽输出(AF_PP)。为什么?因为普通输出模式下,引脚的电平由GPIO的ODR寄存器控制;而复用模式下,引脚的控制权交给了片上外设(这里是TIM3)。如果你配错了,TIM3内部再怎么计数,引脚也不会有反应。

GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure);

这里顺便说一句GPIO的8种工作模式,很多人搞不清。简单记:输入四种(浮空、上拉、下拉、模拟),输出四种(推挽、开漏、复用推挽、复用开漏)。PWM输出用复用推挽,I2C用复用开漏,ADC采样用模拟输入,按键检测用上拉或下拉输入。选错了不会烧芯片,但功能一定不对。

3.3 第三步:配置时基单元,算出PSC和ARR

时基单元决定了PWM的频率。假设我要输出1kHz、分辨率1000档的PWM:

TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 999; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);

验证一下:72MHz / (72 × 1000) = 1kHz。对的。

这里有个细节:TIM_Period写的是999,不是1000。因为计数器是从0数到ARR,一共ARR+1个数。写1000的话,实际是1001档,频率会变成999Hz。这种"差一"错误在嵌入式里非常常见,配置定时器、串口波特率、ADC采样时间时都要注意。

3.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); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable);

这里出现了PWM模式1和模式2的区别,这是热词里经常被问到的。简单说:

  • PWM模式1:计数器小于CCR时输出有效电平,大于等于CCR时输出无效电平。
  • PWM模式2:反过来,计数器小于CCR时输出无效电平,大于等于CCR时输出有效电平。

"有效电平"是高还是低,由TIM_OCPolarity决定。所以模式1+高极性,就是"计数值小于CCR时输出高"。模式2+高极性,就是"计数值小于CCR时输出低"。两者波形正好相反。实际用哪个都行,看你驱动电路的需要。比如驱动一个低边MOS管,你可能希望默认输出低,那就用模式2。

3.5 第五步:使能预装载和定时器

TIM_ARRPreloadConfig(TIM3, ENABLE); TIM_Cmd(TIM3, ENABLE);

TIM_ARRPreloadConfig和TIM_OC1PreloadConfig这两个预装载功能很重要。它们的作用是:你写入的新ARR或CCR值不会立即生效,而是等到下一次更新事件时才生效。这样可以避免在计数过程中改值导致波形出现毛刺。如果你在做电机调速,占空比是动态变化的,不开预装载就可能出现电机抖动。

到这里,PA6上就应该有1kHz、50%占空比的方波了。用示波器一测就能看到。如果没有,按这个顺序查:时钟开了吗?引脚模式对吗?定时器使能了吗?CCR值是不是大于ARR了?

4. 为什么LED能调光,电机能调速:负载视角下的PWM

配置跑通了,但很多人还是有个疑问:同样是PWM,为什么LED是"变暗",电机是"变慢",加热丝是"变热"?这背后其实是不同负载对PWM的响应方式不同。理解了这个,你才能针对不同负载选对频率和驱动电路。

4.1 LED:靠视觉暂留和平均电流

LED是二极管,它的亮度基本正比于流过它的平均电流。PWM让LED在"亮"和"灭"之间快速切换,只要频率高于人眼闪烁融合频率(大约60Hz以上),人眼看到的就是平均亮度。占空比50%,平均电流就是最大电流的一半,亮度也就大约一半。

但这里有个坑:LED的亮度并不严格正比于占空比。因为人眼对亮度的感知是对数关系,占空比从0%到10%的变化,看起来比从90%到100%的变化明显得多。所以如果你做呼吸灯,用线性占空比变化,会感觉"暗的时候变化快,亮的时候变化慢"。想要视觉上均匀,得做伽马校正,或者用查表法。

另外,驱动大功率LED时,不能直接用GPIO。GPIO一般只能输出20mA左右,驱动1W的LED根本不够。这时候要用专门的LED驱动芯片,比如恒流驱动IC。这类芯片通常有FB(反馈)脚和CS(电流采样)脚,通过调整采样电阻或PWM调光脚来控制亮度。PWM调光脚的本质,还是我们说的占空比控制,只不过芯片内部帮你做了恒流。

4.2 直流电机:靠平均电压和机械惯性

直流电机的转速近似正比于电枢电压。PWM通过快速开关,让电机两端得到"平均电压"。比如24V供电,50%占空比,电机等效得到12V,转速大约就是额定转速的一半。

但电机和LED不一样,它是感性负载,而且有机械惯性。这带来两个问题:

第一,电机是电感,电流不能突变。PWM关断瞬间,电机线圈会产生反向电动势,如果不加续流二极管,这个高压可能击穿驱动管。所以驱动直流电机,必须用H桥或MOS管加续流二极管。

第二,频率选择很讲究。频率太低(比如100Hz),电机会听到明显的啸叫,转动也不平滑;频率太高(比如50kHz),MOS管的开关损耗会显著增加,驱动板发热。一般1kHz到20kHz是甜区。我个人的经验是,小电机用8kHz左右,大电机用16kHz左右,既避开听觉敏感区,又不至于让驱动太热。

还有一点:电机启动瞬间电流很大,可能是额定电流的5到10倍。如果你用PWM直接软启动,从0%慢慢加到目标占空比,可以有效减小冲击电流。这个技巧在电池供电的设备里特别有用。

4.3 舵机:PWM的"异类",靠脉宽而不是占空比

舵机虽然也用PWM控制,但它的逻辑和调光调速完全不同。舵机看的是高电平持续时间,不是占空比。标准舵机要求周期20ms(50Hz),高电平0.5ms对应0度,1.5ms对应90度,2.5ms对应180度。

所以舵机的PWM频率是固定的50Hz,你不能随便改。改了就失控。而且舵机的分辨率取决于你能把脉宽细分到什么程度。20ms周期下,如果ARR=19999(72MHz/72/20000=50Hz),那每1个计数就是1微秒,0.5ms到2.5ms就是500到2500个计数,分辨率足够。

这就是为什么同一个定时器可以同时输出不同频率的PWM——只要它们在不同的通道,且ARR相同,频率就相同;如果ARR不同,那就不行。所以如果你要同时控制舵机和调光,最好用两个不同的定时器。

5. 那些年踩过的PWM坑:从灯不亮到电机啸叫

PWM配置本身不难,难的是出了问题怎么查。下面这几个坑,是我在实际项目中真实遇到过的,每一个都花了不少时间。

5.1 灯不亮:先查时钟,再查引脚,最后查CCR

有一次我用STM32F4的TIM1输出PWM,代码检查了十遍,灯就是不亮。后来发现是TIM1挂在APB2上,我开了APB1的时钟。F1和F4的定时器总线分配不一样,F1的TIM1在APB2,TIM2~TIM7在APB1;F4的TIM1和TIM8在APB2,其他在APB1。这个细节不看手册真的会错。

还有一个更隐蔽的:高级定时器TIM1和TIM8需要额外使能主输出。普通定时器配置完就能输出,但TIM1/TIM8有个刹车寄存器(BDTR),里面的MOE位必须置1,否则引脚就是没输出。这个坑我第一次用TIM1时踩了整整一个下午。

TIM_CtrlPWMOutputs(TIM1, ENABLE); // 高级定时器必须加这一句

5.2 电机啸叫:频率选错了,或者占空比太低

电机啸叫一般有两个原因。一是PWM频率落在人耳敏感区(2kHz~5kHz),这时候把频率调到8kHz以上或1kHz以下就能改善。二是占空比太低,电机处于"断续导通"状态,电流不连续,也会产生噪音。这时候要么提高占空比,要么提高PWM频率让电流连续。

我遇到过一次特别诡异的啸叫,频率怎么调都有。最后发现是电源去耦没做好,PWM开关时电源纹波太大,耦合到了电机上。在驱动板电源脚并一个1000uF电解加一个0.1uF陶瓷电容,问题就消失了。所以PWM问题不一定是PWM本身的错,电源完整性也很关键。

5.3 占空比改不动:预装载没开,或者改错了寄存器

动态调占空比时,如果你直接写CCR寄存器,而没开预装载,可能会出现"改了值但波形没变"或者"波形跳变"的情况。正确做法是开预装载,然后用库函数TIM_SetCompare1来改。这个函数内部会处理预装载逻辑。

还有一种情况:你改的是CCR1,但输出用的是通道2。这种低级错误在复制粘贴代码时特别容易发生。查的时候用示波器看波形,比盯着代码看有效得多。

5.4 波形有毛刺:地线没接好,或者探头补偿没调

有时候示波器上看到的毛刺,不是电路的问题,是测量的问题。示波器探头的地线夹子如果太长,会引入电感,高频PWM下就会看到振铃。把地线夹子换成短的弹簧地针,毛刺往往就消失了。这个经验对做开关电源和高速数字电路的人特别重要。

6. 进阶玩法:PWM还能这么用

PWM不只是调光调速,它在嵌入式里还有很多"骚操作"。这些用法在热词里也能看到影子,比如"PWM监控硬盘温度""PWM故障保护"。

6.1 用PWM做DAC:低通滤波出模拟电压

如果你需要输出一个模拟电压,但单片机没有DAC,可以用PWM加RC低通滤波来实现。原理是:PWM的平均值就是占空比乘以高电平电压。比如3.3V、50%占空比,平均就是1.65V。用一个电阻和电容把高频滤掉,就得到比较干净的直流电压。

这个方法的精度取决于PWM分辨率和滤波效果。16位定时器理论上能做到16位DAC,但实际上受电源纹波和滤波电路限制,能做到10~12位就不错了。响应速度也慢,因为滤波电容越大,纹波越小,但建立时间越长。适合输出变化缓慢的参考电压,不适合输出音频。

6.2 用PWM控制温度:加热丝的惯性就是天然滤波器

控制加热丝这种大惯性负载,PWM频率可以很低,甚至1Hz。因为加热丝热惯性大,几秒钟的通断根本不会让温度明显波动。这时候用PWM比用继电器更平滑,寿命也更长。

但要注意,加热丝是纯阻性负载,但冷态电阻比热态小很多。刚上电时电流很大,选MOS管和电源时要留足余量。我一般按热态电流的2倍来选。

6.3 PWM故障保护:刹车输入和死区时间

高级定时器(TIM1/TIM8)有刹车输入功能。当刹车引脚检测到低电平时,PWM输出会立即被强制到安全状态(由刹车极性决定)。这个功能在电机驱动里非常重要,可以在过流、过温时快速关断PWM,保护功率管。

死区时间也是电机驱动必须考虑的。H桥的上下管如果同时导通,就会短路。死区时间就是在上下管切换时插入的一小段"都不导通"的时间,避免直通。STM32的高级定时器可以硬件插入死区,不需要软件干预。

6.4 用PWM测频率和占空比:输入捕获

PWM不只是输出,还可以输入。用定时器的输入捕获功能,可以测量外部PWM信号的频率和占空比。原理是:捕获上升沿和下降沿的时间戳,两个上升沿之间是周期,上升沿到下降沿是高电平时间。这个功能在做遥控信号解码、转速测量时很有用。

7. 不同平台上的PWM配置差异:STM32、GD32、HC32F460

虽然PWM原理一样,但不同厂商的单片机在配置细节上有差异。这里简单对比一下,方便你换平台时快速上手。

平台库风格时钟使能高级定时器特殊要求备注
STM32HAL/标准库/LL__HAL_RCC_TIMx_CLK_ENABLETIM1/TIM8需使能MOE资料最多,生态最好
GD32类似STM32类似类似寄存器基本兼容,时钟树略有不同
HC32F460华大自家库PWC_Fcg2PeriphClockCmd需配置端口复用功能时钟和外设使能方式不同

以HC32F460为例,它的PWM配置流程和STM32类似,但有几个坑:它的端口复用功能需要单独配置,不像STM32那样在GPIO初始化里就指定了AF。而且它的定时器时钟源选择更灵活,配置错了频率会差很多。我第一次用HC32F460时,就是没注意端口复用,PWM死活出不来。

换平台时,最靠谱的方法是:先找一个官方例程,把例程跑通,再在例程基础上改。不要凭STM32的经验直接写,因为时钟树、外设使能、引脚复用这些底层细节,每家都不一样。

8. 写在最后:PWM是嵌入式的"万能工具"

PWM这个东西,入门只要十分钟,但真正用好需要理解负载、理解频率、理解驱动电路。我见过太多人,代码能跑,但一问"为什么这个频率",就答不上来。这种"能跑就行"的心态,在做简单项目时没问题,但一旦遇到电机发热、LED闪烁、舵机抖动,就抓瞎了。

我的建议是:每次配置PWM,都先把频率、占空比、分辨率这三个参数算一遍,写下来。然后想清楚这个负载需要什么频率,驱动电路能不能承受这个频率。养成这个习惯,你调PWM就不会再靠"试"了。

还有一点,示波器是调PWM最好的朋友。没有示波器,你就是在盲调。一个入门级的示波器,几百块钱,能帮你省下无数个抓头发的夜晚。我刚开始学的时候,就是靠一个二手示波器,把PWM、串口、I2C这些波形一个个看明白的。看波形比看代码直观一百倍。

最后分享一个小技巧:如果你不确定PWM配置对不对,先把频率设低一点,比如10Hz,然后接一个LED。10Hz下LED会明显闪烁,你能直接看到占空比的变化。确认逻辑对了,再把频率提到正常值。这个方法特别适合初学者,比直接上示波器还直观。

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

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

立即咨询