1. 从一个现象说起:为什么调亮度不用调电压
很多人第一次接触PWM,脑子里冒出来的问题都差不多:既然要让LED变暗,直接给它降电压不就行了?要让电机转慢点,把供电电压调低不就行了?这个思路在逻辑上没毛病,但实际工程里几乎没人这么干。原因很简单——调电压这件事,在数字电路里又贵又难做,而且效果还不好。
你想想,单片机输出的是数字信号,要么高电平要么低电平,它本身不具备“输出2.3V”这种精细的模拟能力。如果非要用模拟方式调压,你得额外加DAC、加运放、加功率管,成本上去了,电路复杂度也上去了。更关键的是,LED的亮度与电流并不是线性关系,电压稍微变一点,电流可能变化非常剧烈,控制起来很不稳定。电机也一样,降低电压会导致扭矩急剧下降,低速时甚至转都转不起来。
PWM的思路完全换了一个维度:我不调电压幅值,我调电压“出现的时间比例”。还是那个5V的方波,但我让高电平只占整个周期的30%,剩下70%的时间是低电平。对于LED来说,它的响应频率远高于人眼分辨能力,你看到的就是“平均亮度”降低了;对于电机来说,电感效应会把脉冲电流平滑成连续电流,等效于施加了一个更低的平均电压。
这就是PWM能调速、调光的核心逻辑。它用时间维度的控制替代了幅值维度的控制,完美适配数字系统的天性。
1.1 核心概念:占空比、频率、周期
在展开之前,先把三个最基础的参数说清楚,后面所有内容都围绕它们转。
周期(Period):一个完整PWM波形从上升到下一次上升的时间长度,单位通常是微秒或毫秒。比如周期20ms,意味着每20ms重复一次。
频率(Frequency):周期的倒数,单位Hz。周期20ms对应50Hz。频率决定了PWM的“刷新速度”。
占空比(Duty Cycle):高电平时间占整个周期的比例,通常用百分比表示。占空比60%意味着一个周期里60%的时间是高电平。
这三个参数的关系可以用一个很直观的公式表达:
占空比 = 高电平时间 / 周期 × 100%
假设周期是1ms,高电平持续0.3ms,那占空比就是30%。如果你把高电平时间改成0.7ms,占空比就变成70%。LED会明显更亮,电机也会转得更快。
1.2 为什么LED和电机对PWM的响应不一样
这里有一个很多人忽略的关键点:同样是PWM,LED和电机需要的频率范围完全不同。
LED的闪烁频率只要高于人眼的临界闪烁频率(大约60-90Hz),人眼就感觉不到闪烁了。所以驱动LED做调光,频率选个200Hz到1kHz就很舒服。频率太低会看到闪烁,频率太高又没必要,还会增加开关损耗。
直流电机则不一样。电机的机械惯性很大,转子不可能跟着PWM频率来回抖动。如果PWM频率太低(比如几十Hz),你会听到电机发出“嗡嗡”的啸叫声,转动也不平滑。通常有刷直流电机的PWM频率建议在1kHz到20kHz之间,超过20kHz就超出人耳听觉范围了,电机运行会非常安静。但频率也不是越高越好,太高的频率会导致MOS管开关损耗增加,驱动电路发热严重。
所以你看,PWM频率的选择从来不是随便拍脑袋定的,它取决于负载的物理特性。这是实操中最容易踩坑的地方之一。
2. STM32上PWM到底是怎么产生的
搞清楚了PWM的基本原理,接下来要解决的问题是:在STM32这类单片机上,PWM信号到底是怎么“变”出来的?很多人用CubeMX配置完就能输出PWM了,但问他为什么能输出,答不上来。这一章就把这条链路彻底拆开。
2.1 定时器:PWM的发动机
STM32内部产生PWM的核心外设是定时器(TIM)。你可以把定时器想象成一个不停计数的秒表,它从0开始往上数,数到一个设定值后归零,然后重新开始。这个“设定值”就是自动重装载寄存器(ARR)的值。
举个例子,如果ARR设置为999,定时器就从0数到999,一共数1000个数,然后重新从0开始。这1000个计数构成一个完整的PWM周期。
在定时器计数的过程中,还有一个关键寄存器叫捕获/比较寄存器(CCR)。定时器会不断把当前计数值(CNT)和CCR进行比较:
- 当CNT < CCR时,输出高电平
- 当CNT >= CCR时,输出低电平
这就是PWM输出的基本机制。占空比的计算公式就变成了:
占空比 = CCR / (ARR + 1) × 100%
比如ARR=999,CCR=300,占空比就是300/1000=30%。你想调亮度或者调转速,本质上就是在运行时修改CCR的值。
2.2 频率怎么算:一个不能死记的公式
PWM频率的公式是:
PWM频率 = 定时器时钟频率 / ((ARR + 1) × (PSC + 1))
其中PSC是预分频器(Prescaler),用来把定时器的输入时钟降频。比如STM32F103的TIM1挂在72MHz的APB2总线上,如果PSC=71,那定时器的实际计数频率就是72MHz/(71+1)=1MHz,也就是每1微秒计一个数。
假设你要生成一个1kHz的PWM信号,周期就是1ms。如果计数频率是1MHz,那1ms需要计数1000次,所以ARR应该设为999。PSC=71,ARR=999,代入公式:72,000,000/((999+1)×(71+1))=72,000,000/(1000×72)=1000Hz。刚好1kHz。
这里有个实操中很容易搞错的地方:ARR和PSC都是“减一”的关系。因为寄存器是从0开始计数的,你要1000个计数,ARR就得写999。这个细节在手动计算时经常把人绕进去。
2.3 输出通道与GPIO的复用关系
定时器算出了PWM波形,但信号还得送到具体的引脚上。STM32的定时器通道和GPIO引脚之间是复用映射关系。比如TIM1的通道1可以映射到PA8、PE9等引脚上,具体映射到哪个,取决于芯片型号和GPIO的复用功能配置。
以最常见的STM32F103C8T6为例,PA8对应的是TIM1_CH1。你要做的配置步骤是:
- 使能TIM1的时钟和GPIOA的时钟
- 把PA8配置为复用推挽输出模式
- 配置TIM1的时基单元(ARR、PSC)
- 配置TIM1的通道1为PWM模式
- 使能TIM1的通道1输出和定时器本身
这五步缺一不可。很多人卡在第一步或者第二步,要么时钟没开,要么GPIO模式配错了,结果波形死活出不来。
注意:GPIO模式必须选“复用推挽输出”(Alternate Function Push-Pull),不能选普通的推挽输出。普通推挽输出是由GPIO外设直接控制的,而PWM信号来自定时器外设,必须通过复用功能才能送到引脚上。
2.4 PWM模式1和模式2的区别
STM32的定时器支持两种PWM模式,很多人配置的时候直接默认选了模式1,但从来没想过两者的区别。
PWM模式1:CNT < CCR时输出有效电平,CNT >= CCR时输出无效电平。PWM模式2:反过来,CNT < CCR时输出无效电平,CNT >= CCR时输出有效电平。
那什么叫“有效电平”?这又涉及到输出极性的配置。你可以把有效电平设为高电平,也可以设为低电平。所以实际上有四种组合:
| 模式 | 有效极性 | CNT<CCR时输出 | CNT>=CCR时输出 |
|---|---|---|---|
| 模式1 | 高 | 高 | 低 |
| 模式1 | 低 | 低 | 高 |
| 模式2 | 高 | 低 | 高 |
| 模式2 | 低 | 高 | 低 |
看起来有点绕,但实际用起来很简单:如果你用的是常见的低边驱动(比如N沟道MOS管拉低LED阴极),通常用模式1、高极性。如果你用的是高边驱动或者反相驱动电路,可能就需要调整极性。选错了模式,你会发现占空比越大反而越暗,或者电机反转。
3. 从零配置一个PWM输出:以PA8驱动LED为例
理论说了一堆,现在进入实操环节。我以STM32F103C8T6为例,从寄存器层面到库函数层面,把PA8输出PWM的完整过程走一遍。你照着做,基本不会翻车。
3.1 硬件连接与注意事项
先看硬件。PA8接一个LED,串联一个限流电阻。假设LED的正向压降是2V,工作电流取10mA,STM32的GPIO输出高电平是3.3V,那么限流电阻的计算是:
R = (3.3V - 2V) / 10mA = 130Ω
选一个标准值150Ω或者220Ω都可以,电流会略小一点,LED稍微暗一些但更安全。
接线方式是:PA8 → 限流电阻 → LED阳极 → LED阴极 → GND。这样PA8输出高电平时LED亮,输出低电平时LED灭。
注意:STM32的单个GPIO引脚最大输出电流一般是20mA左右,驱动普通LED没问题,但如果你要驱动大功率LED或者多个LED并联,必须加三极管或者MOS管做扩流,否则会烧引脚。
3.2 时基单元的配置过程
时基单元的配置决定了PWM的频率。假设我们要生成1kHz的PWM,系统时钟72MHz,配置步骤如下:
首先确定PSC和ARR。为了让计算简单,我习惯先把计数频率设为1MHz,也就是PSC=71。然后1kHz的周期是1ms,1MHz计数频率下需要1000个计数,所以ARR=999。
用标准外设库的代码大概长这样:
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; 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);这段代码做了几件事:设置ARR为999,PSC为71,时钟分割为1分频,计数模式为向上计数。向上计数是最常用的模式,定时器从0数到ARR然后归零。
3.3 输出通道的配置细节
时基配好之后,接下来配置通道1为PWM输出模式:
TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 300; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure);这里TIM_Pulse就是CCR的值,设为300意味着初始占空比是30%。TIM_OCMode_PWM1选了模式1,TIM_OCPolarity_High表示有效电平为高。
还有一步很容易漏掉:使能预装载寄存器。
TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE);预装载的作用是:你写入新的CCR值时,不会立刻生效,而是等到下一个周期开始时才更新。这样可以避免PWM波形在周期中间突然变化导致抖动。对于LED调光来说,这个抖动可能看不出来,但对于电机控制来说,波形不连续可能会导致电流冲击。
最后使能定时器:
TIM_Cmd(TIM1, ENABLE);对于高级定时器(TIM1、TIM8),还需要额外使能主输出:
TIM_CtrlPWMOutputs(TIM1, ENABLE);这一步是高级定时器特有的,通用定时器(TIM2-TIM5)不需要。很多人用TIM1输出PWM时忘了这一句,结果通道配置全对,就是没波形。
3.4 GPIO复用的正确打开方式
GPIO的配置看似简单,但细节不少:
GPIO_InitTypeDef GPIO_InitStructure; 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);GPIO_Mode_AF_PP就是复用推挽输出。速度选了50MHz,实际上PWM频率只有1kHz,选2MHz都绰绰有余。但速度配置会影响引脚翻转的上升沿和下降沿时间,速度越高边沿越陡,EMI也越大。对于低频PWM,选低速反而更稳妥。
实操心得:如果你发现PWM波形出不来,按照这个顺序排查——时钟使能了吗?GPIO模式对吗?高级定时器的主输出使能了吗?CCR值是不是设成了0或者大于ARR?这四步能解决90%的问题。
4. 占空比动态调整:从LED呼吸灯到电机调速
PWM配好之后只是第一步,真正有用的是运行时动态调整占空比。LED呼吸灯和电机调速本质上都是不断修改CCR值的过程,但两者的调整策略和注意事项差别很大。
4.1 LED呼吸灯的实现逻辑
呼吸灯的效果是LED亮度从暗到亮再到暗,循环往复。实现思路很简单:在一个循环里逐渐增加CCR值,到达最大值后再逐渐减小。
while(1) { for(int i = 0; i <= 1000; i += 10) { TIM_SetCompare1(TIM1, i); delay_ms(10); } for(int i = 1000; i >= 0; i -= 10) { TIM_SetCompare1(TIM1, i); delay_ms(10); } }这段代码里,CCR从0增加到1000,每次加10,延时10ms。整个过程大约1秒,呼吸效果比较自然。
但这里有个问题:人眼对亮度的感知不是线性的。占空比从10%增加到20%,人眼感觉亮度变化很明显;但从80%增加到90%,感觉就没那么明显了。如果你想要更自然的呼吸效果,应该用伽马校正,把线性的CCR值映射到非线性空间。简单做法是查表或者用平方关系近似。
注意:修改CCR值时,如果预装载没使能,新的值会立即生效,可能导致当前周期波形不完整,LED会出现轻微闪烁。所以前面提到的预装载配置一定要打开。
4.2 直流电机调速的关键差异
电机调速和LED调光在代码层面看起来差不多,都是改CCR,但实际工程中要考虑的东西多得多。
首先是频率选择。前面说过,电机PWM频率建议在1kHz到20kHz之间。频率太低电机会啸叫,频率太高驱动电路发热。我一般选8kHz或者16kHz,这两个频率刚好在人耳听觉范围边缘,电机运行比较安静。
其次是死区时间。如果你用的是H桥驱动电机正反转,上下桥臂的MOS管不能同时导通,否则会短路烧管。STM32的高级定时器支持死区插入,可以在互补输出之间自动插入一段死区时间。死区时间的大小取决于MOS管的开关速度和驱动电路,一般几百纳秒到几微秒。
第三是软启动。电机从静止突然全速启动时,电流冲击很大,可能拉低电源电压导致单片机复位。正确做法是让CCR从0逐渐增加到目标值,给电机一个加速过程。这个过程可以在主循环里实现,也可以用定时器中断自动完成。
// 软启动示例 for(int i = 0; i <= target_duty; i += 5) { TIM_SetCompare1(TIM1, i); delay_ms(20); }4.3 占空比与转速的非线性关系
很多人以为占空比50%就是半速,实际上完全不是。电机的转速和占空比之间的关系受负载、摩擦、反电动势等多种因素影响,通常是非线性的。空载时可能30%占空比就能转到接近全速,带载时可能70%占空比才勉强启动。
如果你需要精确控制转速,光靠开环PWM是不够的,必须加编码器反馈做闭环控制。PID算法根据编码器测到的实际转速调整PWM占空比,这样才能在不同负载下保持转速稳定。
实操心得:调试电机PWM时,先用示波器确认波形频率和占空比是否正确,再接电机。我见过有人波形都没看就直接接电机,结果频率配成了50Hz,电机嗡嗡响就是不转,还以为是电机坏了。
5. 常见问题与排查技巧实录
PWM这个东西,配置起来不难,但出问题的时候往往让人摸不着头脑。这一章我把这些年踩过的坑和排查思路整理出来,希望能帮你少走弯路。
5.1 波形出不来?按这个顺序查
PWM配置完了但引脚上没波形,是最常见的问题。排查顺序如下:
| 排查步骤 | 检查内容 | 常见错误 |
|---|---|---|
| 1 | 定时器时钟使能了吗 | 忘了调用RCC_APB2PeriphClockCmd |
| 2 | GPIO时钟使能了吗 | 只开了定时器时钟,忘了GPIO |
| 3 | GPIO模式对吗 | 配成了普通推挽输出而非复用推挽 |
| 4 | 高级定时器主输出使能了吗 | TIM1/TIM8忘了TIM_CtrlPWMOutputs |
| 5 | CCR值合理吗 | CCR=0或CCR>ARR |
| 6 | 引脚映射对吗 | 查数据手册确认复用功能 |
这六步基本能覆盖95%以上的“无波形”问题。如果还不行,拿示波器直接量引脚,确认是单片机没输出还是后级电路把信号吃了。
5.2 占空比不对?先看预装载
有时候波形出来了,但占空比和你设的不一样。比如你设CCR=300,ARR=999,理论占空比30%,但示波器量出来是别的值。这种情况通常是预装载寄存器在作怪。
如果你在初始化时先写了CCR,然后又改了ARR,但预装载没使能,CCR的值可能在你改ARR的瞬间就被更新了,导致实际占空比偏离预期。解决办法很简单:初始化时先配时基,再配通道,最后统一使能预装载。
还有一种可能是时钟频率算错了。比如你以为系统时钟是72MHz,实际上因为时钟树配置问题只有36MHz,那PWM频率就全错了。用示波器量一下实际频率,反推时钟频率,就能确认。
5.3 LED闪烁或电机抖动?查频率和电源
LED在PWM调光时出现肉眼可见的闪烁,通常是因为PWM频率太低。把频率提高到200Hz以上基本就能解决。如果提高频率后还有闪烁,可能是电源纹波太大,LED的供电不稳定。
电机抖动的原因更多。除了频率太低之外,还可能是:
- 电源功率不足,电机启动时拉低电压
- 驱动电路没有续流二极管,电机电感产生的反电动势干扰电路
- PWM占空比变化太快,电机来不及响应
我遇到过一次电机抖动,查了半天发现是电源线太细,电机启动瞬间压降太大导致单片机复位。换了一根粗线就好了。这种问题用示波器看电源波形一目了然。
5.4 PWM干扰显示或其他外设怎么办
这个问题在热词里也出现了——“pwm背光 干扰 显示”。PWM驱动背光时,如果频率和显示刷新率产生拍频,屏幕上会出现滚动条纹。解决办法有两个:要么把PWM频率提高到远高于显示刷新率,要么让PWM频率和显示刷新率保持整数倍关系。
如果是PWM信号干扰了其他外设(比如ADC采样),可以在PWM输出引脚上加RC滤波,或者在ADC采样时避开PWM开关瞬间。STM32的定时器支持触发ADC采样的功能,可以让ADC在PWM周期的特定时刻采样,避开干扰。
实操心得:PCB布局时,PWM走线尽量远离模拟信号线。如果实在避不开,中间加一根地线做隔离。这个经验是我在一个电机控制项目里用血泪换来的,ADC采样值一直跳,最后发现是PWM走线从ADC输入引脚旁边穿过。
6. 进阶话题:从PWM到更复杂的控制
PWM的基础用法掌握之后,可以往更深的方向走。这些内容不一定每个项目都用得上,但了解一下没坏处。
6.1 互补输出与死区时间
高级定时器(TIM1、TIM8)支持互补输出,一个通道可以同时输出一对反相的PWM信号,专门用于H桥驱动。互补输出之间可以插入死区时间,防止上下桥臂直通。
死区时间的配置通过BDTR寄存器完成:
TIM_BDTRInitTypeDef TIM_BDTRInitStructure; TIM_BDTRInitStructure.TIM_OSSRState = TIM_OSSRState_Enable; TIM_BDTRInitStructure.TIM_OSSIState = TIM_OSSIState_Enable; TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_1; TIM_BDTRInitStructure.TIM_DeadTime = 0x0F; TIM_BDTRInitStructure.TIM_Break = TIM_Break_Enable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_High; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, &TIM_BDTRInitStructure);死区时间的实际长度取决于TIM_DeadTime的值和时钟频率,具体对应关系查参考手册的BDTR章节。一般来说,几百纳秒的死区时间对大多数MOS管驱动电路都够用了。
6.2 PWM输入捕获:测量外部信号
定时器不仅能输出PWM,还能捕获外部PWM信号的频率和占空比。这个功能在测量传感器输出、解码遥控信号时非常有用。
输入捕获的原理是:当引脚上检测到上升沿或下降沿时,定时器自动把当前计数值存入CCR寄存器,并触发中断。通过两次捕获的计数值之差,就能算出周期和频率。
// 输入捕获配置示例 TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0; TIM_ICInit(TIM2, &TIM_ICInitStructure);输入捕获的精度取决于定时器的计数频率。计数频率越高,测量分辨率越高,但定时器溢出也越快。需要根据被测信号的频率范围来选择合适的PSC和ARR。
6.3 用PWM控制DC-DC输出电压
这是一个比较巧妙的用法:很多DC-DC芯片的反馈引脚(FB)本来是用来接分压电阻设定输出电压的,但如果你在分压电阻网络上叠加一个PWM滤波后的直流电平,就能动态调整输出电压。
具体做法是:PWM信号经过RC低通滤波变成模拟电压,通过一个电阻注入到FB节点。调整PWM占空比就改变了注入电流,从而改变了FB节点的电压,DC-DC芯片会自动调整输出以维持FB电压恒定,最终实现输出电压的可编程控制。
这种方式的优点是成本低、电路简单,缺点是响应速度慢(受RC滤波时间常数限制),精度也不如专用DAC。适合对动态响应要求不高的场合。
注意:注入电阻的阻值需要仔细计算,太小会严重影响原有分压网络,太大会导致调节范围不够。一般取分压电阻的10到100倍比较合适。
7. 一些零散但有用的经验
最后分享几个我在实际项目中积累的小技巧,不一定系统,但都挺实用。
关于频率选择:如果你不确定PWM频率选多少,LED调光从1kHz起步,电机驱动从8kHz起步,舵机控制用50Hz。这几个值覆盖了大多数常见场景。
关于占空比分辨率:ARR越大,占空比可调的档位越多,分辨率越高。但ARR太大会导致频率降低。如果你需要高分辨率又要保持频率,只能提高定时器时钟频率。STM32的定时器时钟最高可以到72MHz甚至更高,足够大多数应用使用。
关于GPIO速度:PWM引脚的GPIO速度不需要设太高。1kHz的PWM用2MHz速度绰绰有余,设成50MHz反而会增加EMI。只有在高频PWM(比如100kHz以上)时才需要高速配置。
关于调试工具:示波器是调PWM的必备工具,没有示波器至少也要有个逻辑分析仪。靠猜和试错效率太低,而且容易把问题归咎于错误的方向。
关于代码移植:不同STM32系列的定时器寄存器名称可能不同,但PWM的工作原理完全一样。F1系列用标准库,F4/H7系列用HAL库,配置思路一致,只是函数名变了。理解了ARR、PSC、CCR这三个核心寄存器的作用,换任何芯片都能快速上手。
关于PWM故障保护:高级定时器的Break功能可以在检测到故障信号时自动关闭PWM输出,保护功率电路。这个功能在电机驱动和电源应用中非常重要,配置也不复杂,建议在涉及功率控制的场合都加上。
关于低功耗:如果PWM用于电池供电设备,注意PWM频率越高开关损耗越大。在满足应用需求的前提下尽量降低频率,可以延长电池寿命。另外,有些STM32系列支持在低功耗模式下由低功耗定时器产生PWM,进一步降低功耗。
关于LED驱动芯片的配合:如果你用的是专用LED驱动芯片而不是直接驱动LED,PWM信号可能接到芯片的使能脚或FB脚上。这时候要注意芯片的使能响应时间和PWM频率的匹配。有些驱动芯片的使能脚响应很慢,PWM频率高了它根本来不及反应,输出会变成一条直线而不是调光效果。查芯片数据手册确认最大PWM频率,别想当然。