1. 从一个现象说起:为什么调亮度不能靠“调电压”
很多人第一次接触PWM,都是从“让一盏LED灯慢慢变亮再慢慢变暗”这个实验开始的。你手里可能有一块STM32最小系统板,PA8上接了一颗LED,串了一个限流电阻,然后你写了一段代码:先让GPIO输出高电平,灯亮;输出低电平,灯灭。接着你想让它“半亮”,于是你开始琢磨——能不能让PA8输出1.65V?这样灯不就半亮了吗?
这个思路听起来很合理,但实际做出来你会发现两个问题。第一,STM32的GPIO在推挽输出模式下,要么输出接近3.3V的高电平,要么输出接近0V的低电平,它不是一个可调电压源。第二,就算你用DAC输出一个中间电压,LED的亮度变化也不是线性的,因为LED的伏安特性非常陡峭,电压稍微变一点,电流可能差好几倍,亮度要么几乎不亮,要么直接过亮,中间那段可调范围非常窄。
那怎么办?答案就是PWM——脉冲宽度调制。它的核心思想特别朴素:我不改变电压的高低,我只改变“高电平占整个周期的比例”。如果高电平占20%,LED就在20%的时间里全亮、80%的时间里全灭;如果高电平占80%,LED就在80%的时间里全亮。只要这个切换速度足够快,快到人眼分辨不出来,你看到的就是一个“平均亮度”。这个“高电平时间除以周期总时间”的比例,就是占空比。
同样的逻辑放到直流电机上:电机也不是靠“降低电压”来减速的,而是靠PWM让电机在“通电”和“断电”之间快速切换。占空比大,平均电压高,转速快;占空比小,平均电压低,转速慢。而且因为电机有机械惯性,它不会因为断电的那一小段时间就立刻停下来,所以转起来非常平滑。
这就是PWM能调速的根本原因:它不是真的在调电压,而是在调“时间比例”,用时间换平均效果。理解了这一点,后面所有的配置、计算、调试,都只是围绕“怎么产生一个占空比可调的方波”来展开。
2. PWM的核心参数:频率、占空比、分辨率到底怎么选
2.1 频率选低了会闪,选高了会抖
PWM频率就是方波一秒钟重复多少次。假设你用一个100Hz的PWM去驱动LED,周期是10ms,高电平5ms、低电平5ms。人眼的视觉暂留大概在几十毫秒量级,10ms的周期你其实能隐约感觉到闪烁,尤其是眼睛快速扫过的时候。所以驱动LED做背光或者调光,频率一般要拉到1kHz以上,比较稳妥的是2kHz到5kHz。低于1kHz,很多人就能看出频闪了。
但频率也不是越高越好。频率越高,MOS管或者驱动芯片的开关损耗越大,EMI也越难处理。对于直流电机,常用的PWM频率在10kHz到20kHz之间。为什么是这个范围?因为低于10kHz,电机线圈会发出人耳能听到的啸叫声,听起来像“吱——”的高频噪音;高于20kHz,超出人耳听觉范围,电机就安静了,但开关损耗开始明显上升。所以10kHz到20kHz是一个“听不见又不太费电”的甜点区。
这里有一个实际调试中很容易踩的坑:你用STM32的定时器产生PWM,预分频器和自动重装载值决定了频率。假设系统时钟72MHz,你想输出1kHz的PWM,预分频器设为71,自动重装载值设为999,那么频率就是72MHz/(72*1000)=1kHz。但如果你把自动重装载值设成100,预分频器设成719,频率还是1kHz,可分辨率就从1000级降到了100级。频率和分辨率是一对矛盾,同样的时钟下,频率越高,一个周期内能划分的档位就越少。
2.2 占空比的计算没有你想的那么复杂
占空比就是高电平时间除以周期。在STM32的定时器里,你设置一个比较值CCR,当计数器小于CCR时输出高电平,大于等于CCR时输出低电平。那么占空比就是CCR/(ARR+1)。比如ARR=999,CCR=300,占空比就是30%。
但这里有一个细节:PWM模式1和模式2的区别。模式1是计数器小于CCR时输出有效电平,模式2是计数器小于CCR时输出无效电平。有效电平是高还是低,又取决于你设置的极性。所以同样是CCR=300,模式1加高极性,输出的是30%高电平;模式1加低极性,输出的是70%高电平。很多人调了半天发现“占空比反了”,就是极性设错了。
对于LED调光,占空比从0%到100%对应从全灭到全亮。对于直流电机,占空比从0%到100%对应从停转到全速。但实际使用中,电机在占空比低于5%左右时可能根本转不起来,因为平均电压不足以克服静摩擦;而占空比高于95%时,转速提升也不明显了。所以有效调节范围往往是5%到95%之间。
2.3 分辨率决定了你能调多细
分辨率就是你能把占空比分成多少档。如果ARR=999,那你能设置0到999共1000个档位,分辨率大约是10位。如果ARR=99,只有100个档位,分辨率不到7位。对于LED调光,100个档位其实已经够用了,因为人眼对亮度变化的感知是对数型的,低亮度区需要细调,高亮度区粗一点没关系。但对于电机调速,尤其是需要精确控制转速的场景,分辨率高一点会更平滑。
这里有一个经验公式:PWM频率 = 定时器时钟 / ((预分频器+1) * (自动重装载值+1))。你可以先确定想要的频率和分辨率,然后反推预分频器和重装载值。比如你要20kHz频率、10位分辨率,那么ARR=1023,预分频器就是72MHz/(20kHz*1024)-1,算出来大约是2.5,取整为2,实际频率会略有偏差,但完全可以用。
3. 在STM32上从零配置一路PWM:以PA8为例
3.1 为什么PA8是很多人的第一选择
PA8在STM32F103系列上对应的是TIM1的通道1,这是一个高级定时器,功能比通用定时器更全,支持互补输出、死区插入、刹车输入等。对于刚开始玩PWM的人来说,用TIM1的PA8有一个好处:它不需要重映射,默认复用功能就是PWM输出,省去了配置AFIO的麻烦。你只需要把PA8配置成复用推挽输出,然后启动定时器,波形就出来了。
但这里有一个坑:TIM1是高级定时器,它有一个MOE(主输出使能)位。如果你只配置了通道但忘记使能MOE,PA8是不会有波形输出的。很多新手在这里卡住,示波器上什么都看不到,以为是代码写错了,其实是少了一句TIM_CtrlPWMOutputs(TIM1, ENABLE)。通用定时器如TIM2、TIM3、TIM4就没有这个要求,配置完通道直接就有输出。
3.2 配置步骤拆解:从时钟到波形
第一步是开时钟。TIM1挂在APB2总线上,GPIOA也挂在APB2上,所以你要使能这两个时钟。很多人只开了TIM1的时钟,忘了开GPIOA的时钟,结果引脚一直是高阻态,波形出不来。
第二步是配置GPIO。PA8要设成复用推挽输出,速度可以选50MHz。这里不要设成普通推挽输出,因为普通推挽是GPIO外设直接控制引脚,而PWM是定时器外设控制引脚,必须走复用功能。
第三步是配置定时器时基。设定预分频器和自动重装载值,决定频率和分辨率。同时要设置计数模式为向上计数,时钟分频因子为不分频。
第四步是配置PWM通道。选择PWM模式1或模式2,设置比较值CCR,使能通道输出。对于TIM1,还要额外使能MOE。
第五步是启动定时器。调用TIM_Cmd(TIM1, ENABLE),计数器开始跑,波形就出来了。
整个过程如果用标准库写,大概二十行代码;如果用HAL库,CubeMX点几下就能生成。但不管用哪种方式,这五步的逻辑是一样的,缺一不可。
3.3 一个完整的配置示例
下面是一段基于标准库的配置代码,目标是PA8输出1kHz、占空比50%的PWM:
void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_TIM1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); }这段代码里,72MHz除以72再除以1000,正好是1kHz。CCR设为500,占空比就是50%。如果你把CCR改成100,占空比变成10%,LED会明显变暗;改成900,占空比90%,LED接近全亮。
4. 从LED到电机:负载不同,驱动电路完全不一样
4.1 LED可以直接驱动,但要注意电流
一颗普通的3mm或5mm LED,正向压降大约2V,工作电流5mA到20mA。STM32的GPIO在推挽输出模式下,单个引脚最大能拉20mA左右,但整个芯片所有引脚加起来有上限。所以如果你只驱动一颗LED,串一个1kΩ左右的电阻,直接接在PA8和GND之间,是完全可以的。电阻的计算很简单:电源3.3V减去LED压降2V,剩下1.3V落在电阻上,想要10mA电流,电阻就是130Ω。但为了安全,通常用330Ω到1kΩ,电流小一点,亮度也够用。
但如果你要驱动大功率LED,比如1W的灯珠,工作电流300mA,那就绝对不能直接用GPIO了。你需要一个MOS管或者专用的LED驱动芯片。MOS管的栅极接PWM信号,漏极接LED和电源,源极接地。PWM信号控制MOS管的开关,从而控制流过LED的平均电流。这里要注意MOS管的栅极电容,如果PWM频率太高,栅极充放电可能跟不上,波形会变形。所以驱动大功率LED时,PWM频率一般不超过几kHz,或者加一个栅极驱动芯片。
4.2 直流电机必须加驱动,而且要考虑反向电动势
直流电机更麻烦。首先,电机的工作电流通常远大于GPIO的驱动能力,小电机几百mA,大电机几安培。其次,电机是感性负载,断电瞬间会产生反向电动势,电压可能高达几十伏甚至上百伏,直接接GPIO会瞬间烧掉引脚。所以电机和GPIO之间必须有一个驱动电路,最常见的是H桥驱动芯片,比如L298N、DRV8833、DRV8323等。
H桥的作用不只是放大电流,它还允许你控制电机的正反转。PWM信号接在H桥的使能引脚上,方向引脚控制正反转。当PWM占空比变化时,电机两端的平均电压变化,转速就跟着变。这里有一个细节:PWM频率要避开H桥的开关损耗区。像L298N这种老芯片,内部是双极性晶体管,开关速度慢,PWM频率建议在1kHz到5kHz之间;而DRV8833是MOSFET输出,可以跑到20kHz以上。
还有一个实际调试中经常遇到的问题:电机启动时的电流冲击。电机静止时线圈电阻很小,启动瞬间电流可能是额定电流的好几倍。如果你的电源功率不够,电压会被拉低,单片机可能复位。解决办法是在电源端并一个大电容,或者用软启动的方式,让占空比从0慢慢增加到目标值。
4.3 驱动芯片的反馈脚和使能脚怎么用
很多专用LED驱动芯片或者电机驱动芯片除了PWM输入,还有FB(反馈)脚和CS(电流采样)脚。FB脚通常接一个采样电阻,芯片通过它监测输出电流,实现恒流驱动。CS脚也是类似的作用,用于过流保护。这些引脚和PWM配合起来,可以实现更精细的控制。
比如你用一颗恒流LED驱动芯片,PWM输入控制的是“平均电流”,而FB脚上的采样电阻决定了“峰值电流”。如果你把PWM频率设得很低,比如100Hz,那么LED会在“恒流亮”和“完全灭”之间切换,虽然平均亮度变了,但低亮度时可能会出现闪烁。更好的做法是把PWM频率设高,同时用FB脚设定一个合适的峰值电流,这样调光更平滑。
对于电机驱动,CS脚可以用来监测电流,当电流超过阈值时触发保护。有些驱动芯片支持“PWM故障保护”,当检测到过流时自动关闭输出,避免烧毁电机或驱动芯片。这个功能在堵转或者负载突变时特别有用。
5. 常见问题与排查技巧实录
5.1 示波器上没波形,先查这五个地方
PWM配置完发现引脚没输出,是新手最常见的问题。按照下面这个顺序排查,基本能覆盖90%的情况:
| 排查项 | 可能原因 | 解决方法 |
|---|---|---|
| 时钟 | GPIO或定时器时钟没开 | 检查RCC配置,确保两个时钟都使能 |
| 引脚模式 | 设成了普通输出而非复用输出 | 改为AF_PP或AF_OD |
| MOE位 | 高级定时器没使能主输出 | 调用TIM_CtrlPWMOutputs |
| 通道使能 | 忘记使能OC通道 | 设置TIM_OutputState_Enable |
| 比较值 | CCR大于ARR,占空比100%或0% | 检查CCR是否在有效范围内 |
还有一个容易被忽略的点:引脚被其他外设占用了。比如PA8在某些封装上可能和别的功能复用,如果你之前初始化过其他外设,可能会冲突。用CubeMX的话,它会自动检查引脚冲突,但手动写代码就要自己留意。
5.2 LED亮度不线性,不是代码的问题是眼睛的问题
很多人调LED亮度时会发现,占空比从0%调到10%,亮度变化很明显;从90%调到100%,几乎看不出差别。这不是PWM的问题,而是人眼对亮度的感知是非线性的。人眼对低亮度区的变化更敏感,对高亮度区的变化更迟钝。所以如果你要做“看起来均匀”的调光,不能直接用线性占空比,而要做伽马校正。
伽马校正的思路是:把占空比按指数曲线映射。比如你想要50%的“视觉亮度”,实际占空比可能只需要20%左右。具体的校正曲线取决于LED和眼睛的特性,常用的伽马值是2.2或2.8。在代码里可以用一个查找表,把0到100的视觉等级映射到0到1000的CCR值。这样调光旋钮转起来,亮度变化就均匀了。
5.3 电机低速抖动、高速上不去,怎么调
电机调速时常见的两个问题:低速抖动和高速上不去。
低速抖动的原因是PWM频率太低,电机在“通电-断电”之间切换时,转速波动太大。解决办法是提高PWM频率,比如从1kHz提到15kHz,让电流纹波变小。但频率提高后,H桥的开关损耗增加,驱动芯片可能会发热。所以要在抖动和发热之间找一个平衡点。
高速上不去的原因可能是占空比已经到100%了,但电机还是达不到目标转速。这时候要检查电源电压是否足够,电机额定电压是多少。如果电源电压低于电机额定电压,就算占空比100%,电机也达不到额定转速。另外,H桥本身有导通电阻,大电流时会产生压降,也会限制最高转速。
还有一个隐藏问题:死区时间。H桥的上下桥臂不能同时导通,否则会短路。所以驱动芯片会插入一个死区时间,在这段时间内上下桥臂都关闭。死区时间太长,有效占空比会降低,高速时尤其明显。如果你用的是带死区控制的驱动芯片,可以适当减小死区时间,但要以不出现直通为前提。
5.4 PWM背光干扰显示,怎么解决
用PWM调LED背光时,有时候会干扰LCD显示,表现为屏幕上出现横向条纹或者闪烁。这是因为PWM的开关动作通过电源线或者空间辐射耦合到了显示信号上。解决办法有几个:一是提高PWM频率,让干扰频率超出显示刷新率的敏感范围;二是给背光LED的电源加LC滤波,减少电源纹波;三是把PWM走线和显示信号线分开,避免平行走线;四是在软件上让PWM和显示刷新同步,比如在消隐期切换PWM状态。
我自己遇到过一次,背光PWM频率设成1kHz,屏幕上总有淡淡的横纹。后来把频率提到20kHz,横纹就消失了。但20kHz时MOS管有点热,加了一个小散热片就解决了。所以很多时候不是单一因素,要综合权衡。
6. 进阶思路:PWM还能做什么
PWM的用途远不止调光和调速。在电源领域,DC-DC变换器就是靠PWM控制开关管的占空比来稳定输出电压的。在通信领域,PWM可以编码信息,比如红外遥控器就是用PWM来发送0和1的。在音频领域,D类功放也是用PWM来放大声音信号的。
甚至你可以用PWM来“模拟”一个DAC。如果你需要一个0到3.3V的模拟电压,但手头没有DAC,可以用PWM加一个RC低通滤波器。PWM的高频分量被电容滤掉,剩下的平均电压就是模拟量。这个方法的精度取决于PWM分辨率和滤波器的截止频率,做得好可以做到10位以上的等效精度。
还有一个有意思的用法:用PWM监控温度。有些温度传感器输出的是频率信号,你可以用定时器的输入捕获来测量频率,反过来也可以用PWM输出去控制风扇转速。比如硬盘温度高了,就提高风扇PWM占空比;温度低了就降低。这就是一个简单的闭环控制。
我在实际项目里还用过PWM来驱动蜂鸣器。无源蜂鸣器需要一定频率的方波才能发声,频率决定音调,占空比决定音量。用PWM驱动,想发什么音就发什么音,比用GPIO翻转方便多了。
最后分享一个小技巧:如果你用STM32的定时器产生PWM,同时又想用同一个定时器做输入捕获或者编码器接口,要注意它们可能共享同一个计数器。这时候要么换一个定时器,要么用主从模式让两个定时器同步。这个坑我在做电机测速时踩过,后来用TIM1输出PWM、TIM2做编码器接口,两个定时器各司其职,问题就解决了。