ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

定时器PWM实现呼吸灯:嵌入式Day9完整学习笔记

定时器PWM实现呼吸灯:嵌入式Day9完整学习笔记

从单片机裸机点亮一颗LED,到开始碰定时器PWM,这条路我走了整整九天。Day9算是一个小分水岭:前面几天的GPIO翻转、外部中断、串口打印,本质上都还停留在数字逻辑的“通”与“断”,而今天要做的呼吸灯,第一次把“时间”这个维度拉进了程序里。这次记录就来聊聊我完成“学习嵌入式Day9”这个课题的全过程,包括定时器PWM的原理、代码怎么写、踩了哪些坑,以及这套东西到底还能用在哪些地方。

如果你跟我一样正在自学嵌入式,或者刚把单片机基础外设过了一遍,正愁下一步该学什么,这篇文章应该对你有用。整个Day9的设计目标很简单:用定时器输出PWM波形,驱动一颗LED做循环呼吸效果,同时把PWM频率、占空比、定时器分频、周期计算这些概念一次性搞清楚,为后面控制舵机、电机调速、逆变电源输出做铺垫。

1. 整体学习思路与课题拆解

1.1 为什么把“呼吸灯”作为Day9的核心课题

刚开始学定时器的时候,我差点直接去翻数据手册里的定时器章节,对着十几个寄存器发呆。后来一个做嵌入式开发的朋友提醒我:不要上来就啃手册,先找一个必须用定时器才能完成的小项目,用需求倒逼学习,效率会高很多。呼吸灯就是这样一个恰到好处的课题。

呼吸灯的核心是LED亮度从暗到亮、再从亮到暗,循环往复。如果只用GPIO的数字高低电平,无非是快速翻转引脚,靠人眼的视觉暂留模拟出亮度变化,但那样做不仅占用CPU,效果还很粗糙。真正工程上控制亮度的手段是PWM——脉冲宽度调制,通过调整高电平在一个周期内所占的比例(占空比),改变LED的平均功率,从而改变亮度。这个操作如果靠软件延时循环去翻转引脚,精度和实时性都很难保证,所以必须让硬件定时器来干这个事。

选定呼吸灯之后,整个课题的价值链就出来了:定时器产生时基信号、预分频器决定计数时钟频率、自动重装载寄存器决定PWM周期、捕获比较寄存器决定占空比、输出通道把比较结果映射到引脚电平。这一条链正好覆盖了定时器最核心的几个知识点,比单纯读手册要形象得多。

1.2 把知识链拆分到可执行的学习任务

我在规划Day9的时候,把整个学习过程拆成了五个子步骤:

  1. 弄清楚定时器在MCU内部的工作流程,画清楚分频、计数、重装载、比较输出这条路径。
  2. 学会计算PWM频率和占空比,把预分频器和自动重装载值的公式吃透。
  3. 编写初始化代码,配置好时钟、引脚复用、定时器时基和输出比较通道。
  4. 写一个随时间改变占空比的主循环,实现呼吸效果。
  5. 用逻辑分析仪或示波器验证波形,把理论和实际对上。

这个拆解顺序很关键,因为很多人一上来就急着看代码,结果PWM频率是怎么来的、占空比怎么算的一塌糊涂。我自己的体会是,先花半小时把计算逻辑想明白,后面写代码十分钟就能写完。思维上的阻塞点往往不在代码,而在对硬件工作模型的理解。

另外,我还给自己立了一个规矩:每个外设学到的东西,必须能跟前面学过的东西串起来。比如Day9的PWM配置,要用到前面学的GPIO复用功能;验证波形时要用的电平翻转逻辑,又要用到Day3学的外部中断思想。所谓的“嵌入式学习”,很大程度就是把这些零散的知识点拼成一张网。

2. 核心原理拆解:定时器PWM的底层逻辑

2.1 定时器到底在干什么

嵌入式里的定时器,本质上就是一个对脉冲进行计数的数字计数器。给它一个时钟源,它会一拍一拍地往上数,数到某个值可以清零重来,也可以触发中断、触发DMA、改变某个引脚的电平。这就是定时器全部的秘密,所有复杂功能都是在这几个基本行为上演化出来的。

Day9用的定时器有一个非常重要的时间基准单位,叫定时器时钟。这个时钟通常来自芯片内部的系统时钟,经过一个预分频器(Prescaler,缩写PSC)降频之后,才送到计数器的输入端。预分频器的作用就是把高频时钟“稀释”成一个合适计数的频率,不然计数器每微秒翻几百次,用户很难设计出几十毫秒、几百毫秒这种时间尺度。

具体到PWM输出,定时器内部还有一个自动重装载寄存器(Auto-Reload Register,缩写ARR)。计数器会从0数到ARR的值,然后产生一个更新事件并重新从0开始计数。一个完整的“从0到ARR”就是PWM的一个周期。与此同时,还有一个捕获比较寄存器(Capture/Compare Register,缩写CCR),它存着一个阈值。当计数器的值小于CCR时,输出引脚输出高电平;当计数器的值达到或超过CCR时,输出引脚翻转成低电平。这样,ARR决定周期,CCR决定高电平持续时间,占空比就是CCR除以ARR的比值。

2.2 频率和占空比是怎么算出来的

我当时最困惑的就是那一堆数字之间的除法关系,后面总结成一句话:预分频器决定PWM的“节奏”,自动重装载值决定“节拍长度”,比较值决定“节拍里的高电平宽度”。具体计算时,假设芯片主频是72MHz,定时器的输入时钟经过预分频器后变成72MHz除以(PSC+1),计数器数完一个周期需要(ARR+1)个时钟,那么最终PWM频率就是:

[ F_{PWM} = \frac{F_{clk}}{(PSC+1) \times (ARR+1)} ]

占空比则是CCR与ARR的比值乘以百分之百。注意国内大多数芯片手册和厂商的库函数里,这两个参数都是寄存器写多少值就按多少运行,但硬件内部是“数到溢出才归零”,所以实际参与周期的值是配置值加1。这个小细节特别容易踩坑,我一开始算出来的频率老是有微小偏差,就是忘了加1。

举个例子:主频72MHz,追求一个大约1kHz的PWM,周期1毫秒,就可以设PSC为71,让定时器时钟变成1MHz,再设ARR为999,计数器从0数到999用1000个1MHz时钟,也就是1ms,正好1kHz。如果想要20kHz的PWM(比如控制蜂鸣器),那就保持PSC为71,把ARR设成49,周期变成0.05ms,频率上到20kHz。占空比的控制就靠CCR,在ARR=999的前提下,CCR=500就是50%占空比,CCR=250就是25%。

2.3 为什么不用软件延时来实现PWM

有人会问,我直接用delay函数在循环里翻转引脚不也能做出呼吸灯吗?能,但有两个致命问题。第一个问题是占据CPU,延时期间CPU什么都干不了,真正的嵌入式系统里CPU还要去处理按键、通信、显示这些事情,不可能只伺候一颗LED。第二个问题是时序不准,中断、分支预测、编译器优化级别都会让软件延时的实际时间漂移,而定时器是完全独立的硬件电路,一旦配置好,它会按晶振的节奏准确输出波形,不需要CPU干预。

我以前在看门狗、ADC采样、串口波特率里都吃过“软件延时”的亏,所以Day9这个节点坚持用硬件定时器做PWM,哪怕代码多一点,也要把习惯养正。以后到了多任务或者实时性要求高的项目,这个决策会省下大把麻烦。这里也不是说所有PWM都得用定时器,像LED呼吸这种不追求精确频率的场景,你在学习阶段当然可以先试着用延时做一版,理解了问题之后再切换到定时器方案,两种对比着看,反而对硬件定时器的价值体会更深。

3. 实操过程:从初始化到呼吸效果

3.1 准备工作与整体环境说明

我在Day9用的是一块某型号的ARM Cortex-M内核开发板,主频72MHz,板载一颗LED接到了定时器PWM输出对应引脚上。开发环境是常见的嵌入式IDE,配合厂商提供的标准外设库。为了方便调试,我还接了一个逻辑分析仪,用来抓取PWM波形。如果你手里没有逻辑分析仪,一个几百块的示波器也行,实在都没有,可以先把PWM频率设成1kHz左右,LED亮度变化肉眼可见,也算一种验证方式。

搭建工程的时候,记得先把系统时钟初始化好,确认主频确实是72MHz。如果系统时钟没配对,后面所有频率计算都会跟着错。这是我最常犯的错,也是最容易被忽略的源头性问题。

3.2 定时器PWM初始化代码解读

下面是一段比较典型的定时器PWM初始化代码,我用它来输出1kHz、可调占空比的PWM信号:

void TIM_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 开启定时器和对应GPIO引脚的时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIMx, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOx, ENABLE); // 2. 配置PWM输出引脚为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_x; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOx, &GPIO_InitStructure); // 3. 配置定时器时基:预分频71,自动重装载999 TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIMx, &TIM_TimeBaseStructure); // 4. 配置PWM模式:向上计数,低电平有效 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; // 初始占空比50% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIMx, &TIM_OCInitStructure); // 5. 使能自动重装载预装载寄存器 TIM_OC1PreloadConfig(TIMx, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIMx, ENABLE); // 6. 启动定时器 TIM_Cmd(TIMx, ENABLE); }

代码里每一步都对应前面原理里的概念。第1步开启时钟,不开启时钟所有寄存器都写不进去;第2步配置引脚的复用功能,这是因为PWM信号不是从普通的推挽输出GPIO出来的,而是由定时器外设直接把波形送到引脚上的;第3步是整个配置的灵魂,PSC=71和ARR=999算出了1kHz的PWM频率;第4步把通道配置成PWM模式1,脉冲宽度初始值500对应50%占空比;第5步开启预装载,这个细节很关键,如果没有使能预装载,你在主循环里中途修改比较值时,它会立即改变PWM波形,可能产生不完整的脉冲,开启预装载后,修改值会等到当前周期结束才生效,波形始终干净;第6步启动定时器,计数器才开始动。

3.3 主循环里的呼吸效果实现

初始化完成后,主函数只需要负责一件事:不断修改PWM的比较值,让亮度按照正弦规律或三角波规律变化。呼吸灯用三角波就足够了,占空比从0%匀速升到100%,再匀速降回0%,循环往复。代码可以写成这样:

int main(void) { SystemClock_Config(); TIM_PWM_Init(); uint16_t duty = 0; uint8_t direction = 1; // 1表示变亮,0表示变暗 while (1) { // 每5ms调整一次占空比 TIM_SetCompare1(TIMx, duty); Delay_5ms(); if (direction) { duty += 2; if (duty >= 999) { duty = 999; direction = 0; } } else { if (duty >= 2) duty -= 2; else duty = 0; if (duty <= 0) { duty = 0; direction = 1; } } } }

这里有几个设计考量。第一是步进值取2而不是1,因为999个等级的占空比,从暗到亮如果步进1需要500步,每步延时5ms就是2.5秒一个完整呼吸周期,视觉上还不错。如果步进太大,比如10,LED亮度跳变感会很明显,呼吸就不平滑。第二是每5ms改一次占空比,这个时间尺度比PWM周期(1ms)大,保证每个占空比下有多个完整的PWM周期输出,亮度变化过渡才细腻。第三是占空比的变化范围是0到999,这里我故意没有限制下限到0以后立刻反向,而是让占空比停留为0一小段时间,让LED完全熄灭片刻,呼吸的感觉更像。

这时候有细心的朋友可能会问:占空比等于999的时候,是不是意味着PWM波形里一直是一个持续的高电平?从理论上讲,可能会有输出恒定的现象,这在部分芯片内部逻辑上是个特殊情况。为了避免这个边界状态,我通常会把最大值设在980而不是999,留一点点低电平时间。这样处理虽然呼吸灯少了一点点最亮的状态,但波形最规范,不会触发某些芯片在完全高电平时的异常输出状态。

3.4 为什么占空比要配合延时来控制

很多人看到主循环里的Delay_5ms,会觉得这跟前面说“软件延时做PWM有毛病”矛盾。其实不矛盾。定时器负责的是PWM波形的硬件生成,延时的作用只是控制“占空比变化的节奏”。打个比方,定时器像是音响的播放硬件,延时则相当于你在按歌单切歌,切歌本身慢一点没关系,但歌曲播放的精度完全由音响决定。呼吸灯过程中,占空比从10%跳到11%,这个跳变是定时器在硬件层面下一周期立刻体现的,CPU的延时只决定什么时候发生跳变。所以这里面完全没有拿软件去精确定时输出波形的问题,延时不均匀顶多是呼吸节奏轻微抖动,但PWM本身的频率和脉宽始终精确。

如果你想追求更平滑的呼吸效果,可以把延时改成基于定时器更新中断的软件定时器,比如每200次更新事件(200ms)递推一次占空比,这样不需要主循环里阻塞延时,整个系统的实时性会更好。Day9我先用简单延时跑通流程,后续Day10可以再优化成中断驱动方式。

3.5 波形验证:实测数据和预期对比

配置完成后,我用逻辑分析仪抓了PWM输出引脚的波形。实测频率是1.000kHz,和预期的1kHz完全吻合,高电平宽度500微秒,占空比50%。手动在代码里把CCR改成250之后,高电平宽度变成250微秒,占空比25%,频率不变。这说明前面的计算方法是准确的,也验证了整个配置链路上每一环都正确。

还有个小细节想强调:逻辑分析仪采样率不能太低,至少要高于PWM频率的10倍,否则波形会失真。比如测1kHz的PWM,逻辑分析仪采样率设成10kHz以上才靠谱。我当时第一次测的时候采样率只设了1kHz,出来的波形完全是一堆毛刺,还以为是代码配置错了,折腾了半天才发现是工具的问题。以后凡是测波形,第一个动作永远是确认采样率和探头接对了没,别急着怀疑代码。

4. 常见问题与排查技巧实录

4.1 输出一直为高电平或一直为低电平

这是Day9最容易碰到的问题。现象很直接:LED要么常灭,要么常亮,完全没变化。排查顺序我建议从下面几点入手:

  • 引脚复用配置对了吗?如果GPIO没有配置成复用推挽模式,而是默认的高阻输入或者普通推挽输出,PWM波形根本送不到引脚上。
  • 定时器时钟开没开?外设时钟没使能的时候,写寄存器等于白写,这个可以通过读寄存器值来确认。
  • PWM模式是不是选错了?PWM1和PWM2两种模式在计数大于比较值时的输出逻辑相反,选错了就相当于输出反相,如果负载正好是高电平驱动,就会出现一直亮或一直灭的现象。
  • 极性配置反了没?TIM_OCPolarity_High和TIM_OCPolarity_Low分别代表有效电平的极性。如果LED是低电平导通,你配置成高电平有效,那占空比大小和实际亮度就是反的,看起来像是“呼吸”反向。

这个排查过程里最有效的工具是读寄存器。直接把CNT、ARR、CCR的值读出来打印到串口,看看计数有没有在走、比较值有没有变。如果CNT一直为0,说明定时器根本没启动;如果CCR改了但输出不变,问题大概率在引脚的复用映射上。

4.2 波形频率跟计算值对不上

我遇到过两次这种情况,一次是因为忘了在预分频和重装载值上加1,导致实际频率偏高;另一次是系统时钟实际跑的不是主频,而是内部RC振荡器的值,跟预期差了不少。排查的时候,首先要确认系统时钟到底是多少。可以在main函数最开始把系统时钟标志读出来,或者直接测一个已知延时的TICK,确认主频基准是正确的,再来算PSC和ARR。

还有一个容易忽视的地方:某些定时器挂在低速总线上,它的时钟并不是主频的直连,而是经过总线分频器之后的结果。比如在一个系统里,APB1总线的定时器时钟可能是主频的1/2甚至1/4,如果代码不做倍频处理,计算就必然出偏差。这个芯片手册里会写,一定仔细核对,不要默认所有定时器都用同一个时钟频率。

4.3 LED亮度变化不平滑或有肉眼可见的跳变

这个现象通常是占空比步进值太大或者PWM频率太低导致的。PWM频率低于500Hz左右时,LED会有明显的闪烁感,那不是呼吸效果,而是人眼能分辨出单个PWM周期了。所以做LED亮度控制,PWM频率至少上到1kHz,推荐5kHz以上。占空比步进值也要合理,如果ARR是999,步进5到10就会有阶梯感,建议不超过3。还要注意修改比较值时,务必开启预装载,否则波形在周期中间被强行改掉,人眼看起来就是“毛刺闪烁”。

4.4 呼吸周期太长或太短

呼吸周期的长短取决于两个参数:占空比更新步长和更新间隔延时。步长固定的话,延时长周期就长。我试过步进2、延时5ms,一个完整周期约2.5秒,视觉比较舒服。如果希望更快,可以缩短延时或者加大步进,但步进不建议超过5,否则不平滑。如果需要慢呼吸,延长延时比减小步进更合理,因为步进太小到后期变化速率差异会很明显。还有一个经验:把变化量从线性改成正弦函数,呼吸效果会自然很多,线性呼吸在两端会有突兀的停顿感,正弦曲线则比较像真人呼吸的节奏。

4.5 代码能编译但效果不对的排查清单

现象检查项常见原因
LED常亮引脚复用、极性配置GPIO未配置为复用推挽;输出极性反了
LED常灭PWM模式、定时器时钟PWM模式选错;定时器时钟未开启
频率偏差系统时钟、总线分频系统时钟频率不对;定时器挂在分频总线上
亮度跳变步进值、预装载步进太大;预装载未开启
呼吸不对称上下限判断逻辑边界值处理,比如从暗到亮和从亮到暗的执行次数不一致
有毛刺逻辑分析仪采样率采样率过低,波形失真

这张表是我后来整理给自己看的,每次做类似项目都能复用。嵌入式调试本质上就是“怀疑一切”,从源头开始逐环节排除,定位问题的速度会快很多。

5. 从Day9之后的扩展:PWM知识的迁移价值

5.1 舵机控制与电机调速

PWM学完以后最直接的扩展方向就是舵机。普通航模舵机的控制信号是50Hz的PWM,周期20ms,占空比在5%到10%之间对应0度到180度的转角。现在你有了定时器PWM的知识,理解舵机控制就是瞬间的事:将PSC和ARR配置为50Hz周期,然后通过修改CCR来控制角度。这个需求在机械臂、云台、智能小车项目里都会遇到。

电机调速则是另一个高频应用。直流电机的转速可以通过PWM占空比近似线性调节,占空比越高平均电压越高,转速越快。结合一个PID闭环,就能做精准的速度控制,这是无人机、平衡小车、自动循迹车的底层技术。Day9的呼吸灯虽然简单,但“改占空比”这个动作和电机调速里的“改占空比”在原理上一模一样,区别只在于外部驱动电路和反馈。

我自己的经验是,学完定时器PWM之后,可以直接挑战一个“PWM控制舵机转向”的小项目,这个项目会强迫你把PWM频率精度、占空比分辨率、波形稳定度这些指标都抠一遍,比单纯做10个呼吸灯都管用。

5.2 实现简单的DAC输出

某些场景下需要输出模拟电压,但MCU不带DAC模块,这时候PWM配合RC滤波电路就是一个省钱又好用的DAC方案。原理是:PWM波的平均电压等于占空比乘以高电平电压,通过低通滤波器把高频分量滤掉,剩下的就是平滑的直流电压。调整占空比就能调整输出电压。这也是变频空调、音频合成器里常用的技术。比如要产生1V的模拟电压,在3.3V系统里设置占空比约30%就能实现,配合运放做缓冲或放大,精度还能提高。

5.3 定时器中断、输入捕获的后续学习

Day9用的只是定时器最基本的功能——输出比较。同一个定时器外设还支持输入捕获模式,可以用来测量外部信号的频率和脉宽,比如读取遥控接收头的编码、测速传感器的脉冲。理解了“计数器从0数到ARR”的模型之后,输入捕获就是多了一个“捕捉CNT当前值”的动作,概念上几乎没有门槛。建议学完PWM之后,马上去学输入捕获,因为两者在寄存器层面是互通的,都是围绕CNT、CCR做文章。

5.4 学习效率反思:Day9的沉淀清单

Day9结束之后,我给自己的知识清单做了个总结,后面复习的时候特别有用:

  • 定时器基本结构:时钟源、预分频器、计数器、自动重装载、比较寄存器、输出通道。
  • PWM关键参数:频率、占空比、极性、有效电平、预装载。
  • 控制思路:硬件生成波形,软件修改参数,系统实时性由两者配合保证。
  • 调试方法论:先查时钟,再查配置,最后查工具,不要一上来就改代码。

这个清单看起来简单,但每一条都是实际踩坑以后总结出来的。如果当初只是跑通代码没有回头整理,这些知识点大概率过两周就模糊了。学习嵌入式最好的方式,不是追求代码跑起来那一刻的快感,而是跑起来后能跟别人讲清楚“它为什么能跑起来”。

6. 写在最后的一点体会

Day9最大的收获,不是我学会了怎么让LED呼吸,而是终于理解了定时器这个外设的骨架。以前看到AHB、APB这些总线分频时总觉得头大,等到真正动手计算PWM频率时才发现,这些分频关系是必须算清楚的硬门槛——跨不过去,所有时间相关的功能都是一团浆糊。

分享一个小技巧:我在学任何一个新外设时,都会先手动算一遍所有关键参数,再写代码,而不是反过来先跑代码再猜参数。寄存器里的每一个数字都要能在纸上推导出来。实在推导不对,宁可去翻参考手册的时钟树图,也不要去猜。

呼吸灯做完以后,我把占空比变化从线性改成了正弦曲线,再把频率调到20kHz,那种呼吸感一下子自然了很多,这也是一个值得试试的进阶小改动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进