☰
手写PWM呼吸灯:定时器、占空比与寄存器配置从零讲透
2026/10/15 6:46:07 网站建设 项目流程

1. 第十二天,我决定把呼吸灯做出来再说

今天是我学习嵌入式的第十二天。前面十一天,我基本都在跟GPIO和串口打交道,点灯、按键、外部中断、串口打印,一个个实验做过来,功能倒是都能跑通,但心里总有一种“好像什么都会,又什么都说不透”的感觉。尤其是定时器,前面只是照着例程初始化,根本没搞懂它内部到底发生了什么。所以我今天没急着往后赶进度,而是给自己立了一个明确的小目标:不用找现成工程,自己手动配出一个PWM呼吸灯。

这个呼吸灯实验看起来简单,背后牵出来的东西却不少。定时器时钟怎么分频,自动重装值设多大,比较值如何决定占空比,输出极性高有效还是低有效,引脚复用配置对不对,这些问题只要有一个没想明白,灯就不会按你想象的效果亮。也正因为如此,把呼吸灯做出来并且完全理解它,比我前面跑的任何一个单纯“亮”的实验都有价值。后面做电机调速、舵机控制、蜂鸣器播放、屏幕背光调节,本质上都是在玩同一套机制。

我把今天的学习安排分成三条线:第一,彻底弄懂定时器输出PWM的底层原理;第二,用寄存器方式从零配置一个PWM通道,不依赖自动生成的初始化代码;第三,做一个小实验验证输出波形,顺便把踩到的坑记录下来。写这篇博客的时候,我的实验板还摆在桌上,LED正在做周期性的亮度变化,效果算不上惊艳,但这次我清楚它为什么能亮、为什么能渐变,心里踏实多了。

1.1 从GPIO到定时器:这一路是怎么走过来的

前面十一天的学习路线,按时间顺序大概是这样的:先是搞明白开发板怎么下载程序,然后学习GPIO的输入输出模式和上下拉电阻,控制一个LED闪烁;之后接触外部中断,写了按键轮询和中断两套逻辑;接着是串口通信,把调试信息打印到电脑上,也学会了用上位机给开发板发数据。到第十一天结束时,我对单片机的认识还停留在“外设都是开开关关”的阶段,串口也只是一堆寄存器配置。

定时器这门课我一直拖着,因为看资料的时候总被“预分频系数”“自动重装载值”“输出比较”这些名词劝退。真正开始学之后发现,它没有想象中可怕。核心就一句话:定时器本质上是一个不断累加的计数器,到了某个数就触发动作,然后再从头重新计。PWM只是把“到了某个数”这个动作变成“翻转引脚电平”,不管有多少术语,底层逻辑都是一样的。

1.2 为什么偏偏在第十二天才碰PWM

有人可能会问,PWM这么基础的东西,为什么等到十二天才学?我觉得这跟学习节奏有关。前十天如果直接上手PWM,我大概率只会抄初始化函数,改一改占空比,然后看着灯亮,根本理解不了预分频和周期到底怎么算。现在我对MCU的时钟树、GPIO复用、寄存器读写作风有了基本概念,再回头学PWM,每一步配置都能主动问一句“为什么是这个值”,学习效率反而更高。

从我看到的很多自学者的经验来看,嵌入式最容易踩的坑就是“过早陷入复杂外设”,一上来就想做电机驱动,结果连时钟树都搞不清,最后全靠复制代码。先把基础外设逐个吃透,再进入PWM这类“组合外设”的阶段,进阶会顺畅很多。这也是我把第十二天当作一个节点的原因:基础部分告一段落,开始迎接真正有意思的控制逻辑。

2. 先把PWM这件事讲明白:一个计数器决定一个波形

PWM的全称是脉冲宽度调制,中文一般叫脉宽调制。它输出的不是稳定的高或低电平,而是一串高低交替的方波,通过改变“高电平在一个周期里占的比例”来实现不同效果。LED亮度、电机转速、舵机角度,这些看似完全不同的东西,底层都是靠这个比例在控制。

2.1 定时器到底在做什么:预分频器、自动重装值与比较值

我先用一个生活化的例子说明。想象你手上有只秒表,每隔一秒加一,这叫计数。如果秒表跑得太快,你可以让它在每次真实时间到1毫秒时才加一,这相当于预分频器;计数器累加到某个设定值后清零并重新开始,这个设定值就是自动重装载值(ARR);计数过程中你可以在某个特定数值上安排一个动作,这个值就是比较值(CCR)。

  • 预分频器决定“多久才让计数器加一次”
  • 自动重装值决定“计数器最多数到多少,数完就清零”
  • 比较值决定“计到什么时候就翻转电平”

三者合起来,就决定了PWM的频率和占空比。比较值在计数器上加到那一刻,引脚电平发生翻转;计数器继续累加直到自动重装载值,引脚电平再翻转一次,同时计数器清零。一个周期里,前半段的宽度由比较值决定,后半段的宽度由自动重装值减去比较值决定。

2.2 频率和占空比的计算:两个公式,没有第四个变量

PWM的频率和占空比,只用两个公式就能算清楚。假设定时器的输入时钟频率是Ftimer,预分频系数是PSC,自动重装值是ARR,比较值是CCR:

  • PWM频率 = Ftimer / ((PSC + 1) * (ARR + 1))
  • 占空比 = CCR / (ARR + 1)(在高电平有效的前提下)

拿我今天实验的参数来说,MCU主时钟72MHz,定时器使用这个72MHz时钟。我先设PSC为71,那么计数器每加一需要1微秒;再设ARR为999,计数器从0数到999,正好1000微秒,也就是1kHz的PWM频率,一个周期正好1毫秒。设CCR为500,占空比就是50%,正好一半高电平一半低电平。

这里有一个需要理解透彻的地方:几乎在所有MCU里,PSC和ARR实际生效的值都要“加一”。很多初学者第一次配置PWM时,把PSC设成72、ARR设成1000,结果频率怎么算都不对,就是因为把这几个引脚焊死在“字面值”上了。寄存器里写的是“减一后的值”,不是真实使用的值,这是芯片为了方便内部比较而设计的,技术手册里会写成“装载值 = 设定值 - 1”。

2.3 波形一旦错了,先检查这些寄存器

PWM输出波形不对,问题通常集中在几个寄存器上。计数器控制寄存器决定了定时器使能和计数方向;预分频寄存器决定计数节拍;自动重装寄存器决定周期长度;捕获比较寄存器决定翻转点;捕获比较模式寄存器决定PWM模式;捕获比较使能寄存器和输出极性寄存器决定最终输出到引脚的电平效果。

我第一次配置时,不小心把PWM模式配置成模式2,然后怀疑是不是极性写反了,折腾了半天。实际上PWM模式1和模式2只差一个逻辑:模式1是在计数小于比较值时输出高电平,模式2正好反过来,小于时输出低电平。如果这里配置错,占空比就算对了,最终波形也是反的。遇到输出异常,不要先怀疑代码逻辑,先把模式、极性这两个寄存器值拉出来看一遍,往往一眼就能发现问题。

3. 一个最小的PWM实验,把寄存器一行一行敲进开发板

原理讲再多,不如上手敲代码。今天的实验目标很简单:用定时器某个通道输出1kHz、50%占空比的方波,让LED以可见呼吸效果变化,然后用逻辑分析仪(或者一个直连的LED亮度观察)验证配置是否真的生效。

3.1 实验的最终目标与引脚选型

呼吸灯的实现思路不复杂:不断改变比较值CCR,从0慢慢增加到ARR,再从ARR慢慢减小到0,LED的亮度就会跟着比较值变化。系统要做的总共就三件事:

  1. 把定时器和通道对应的引脚配置为复用推挽输出
  2. 定时器按PWM模式1输出一个固定周期的方波
  3. 在主循环里用软件延时或者定时器中断,动态改变CCR,实现呼吸效果

引脚选择和电路关系密切。我选的是开发板上已经连接LED的那组引脚,查原理图确认LED正极接电源、负极经限流电阻接MCU引脚。这样当PWM输出高电平时LED点亮,输出低电平时熄灭。

我整理了一下今天用的参数表,方便后面调试的时候对照:

参数值作用
定时器时钟72MHz计数的原始时钟源
预分频PSC71把计数节拍降为1MHz
自动重装ARR999计数器从0数到999清零
PWM频率结果1kHz每周期1毫秒
比较值CCR500(可调)初始占空比50%
引脚复用模式复用推挽输出让引脚输出PWM信号

3.2 初始化代码逐行拆解

我没有使用自动生成的初始化工程,而是直接操作寄存器来配置。这样写起来的代码量比用现成库要多几行,但每一行都非常直观,适合理解外设的工作流程。以下是今天我实际用的初始化代码:

void pwm_channel_init(void) { // 开启定时器3和GPIOA的时钟 RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; // 配置PA6为复用功能,推挽输出 GPIOA->MODER &= ~(3U << (6 * 2)); GPIOA->MODER |= (2U << (6 * 2)); // 10: 复用功能模式 GPIOA->OTYPER &= ~(1U << 6); // 推挽输出 GPIOA->OSPEEDR |= (3U << (6 * 2)); // 高速 GPIOA->PUPDR &= ~(3U << (6 * 2)); // 无上下拉 GPIOA->AFR[0] &= ~(0xFU << (6 * 4)); GPIOA->AFR[0] |= (2U << (6 * 4)); // 复用功能映射到定时器3 // 初始化定时器3时钟和PWM模式 TIM3->CR1 = 0; // 先停止计数器 TIM3->PSC = 71 - 1; // 预分频,72MHz -> 1MHz TIM3->ARR = 1000 - 1; // 自动重装值,1MHz -> 1kHz TIM3->CCMR1 = (6U << 4) | (0U << 12); // 通道1 PWM模式1,不使用预装载 TIM3->CCER = 1U << 0; // 通道1输出使能,高极性 TIM3->CCR1 = 500 - 1; // 比较值,初始约50%占空比 TIM3->CR1 |= 1U; // 启动计数器 }

需要特别说明的是,我在代码里写71 - 1和1000 - 1,而不是直接写71和1000,是因为之前踩过“加一陷阱”的坑。寄存器里实际生效的装载值是设定值减一,直接写成71和999当然也行,但写成71 - 1这种样式,会让半年后的自己一看就明白这里为什么要减一。代码注释不是写给电脑看的,是写给未来那个已经忘记细节的自己看的。

CCMR1里那个6是PWM模式1的编码值。这个模式下,计数器的值小于比较值时,输出有效电平;大于等于比较值时,输出无效电平。模式1和模式2选哪个,取决于你希望“一上电”LED处于什么状态。我在本章后面会详细讲一个和它相关的坑。

3.3 验证手段:没示波器也能证明PWM在输出

很多人一开始没有示波器和逻辑分析仪,但这并不妨碍验证PWM是否真的工作。最简单的验证办法:把比较值设成0,LED应该完全熄灭;把比较值设成ARR(也就是1000),LED应该完全常亮。如果你把CCR设成500,LED亮度看起来应该介于两者之间。这个方法虽然粗糙,但可以快速排除“根本没输出PWM”的情况。

如果有条件,逻辑分析仪是更好的工具。我把PA6这根线接到逻辑分析仪上,抓到的波形是一个1kHz的方波,高电平时间大约500微秒,低电平时间大约500微秒,理论计算和实测几乎一致。看完波形之后,我把主循环改成了呼吸效果:

int brightness = 0; int direction = 1; while (1) { TIM3->CCR1 = brightness; brightness += direction; if (brightness >= 999) direction = -1; if (brightness <= 0) direction = 1; // 简单的软件延时,控制呼吸速度 for (volatile uint32_t i = 0; i < 50000; i++); }

这段代码的逻辑很简单:每循环一次,比较值向目标方向移动一步,到达边界就反向。CCR1实时改变,定时器会在下一个周期自动按新值输出,不需要重新初始化任何东西。呼吸效果实际跑起来非常流畅,大约3秒一个来回,肉眼看不到闪烁感。

4. 今天踩得最深的一个坑:占空比已经归零,LED却还微亮

写代码和调试的过程并不是一帆风顺的。今天最戏剧性的时刻,是我把CCR1设成0、满怀信心地认为LED会完全熄灭,结果它依然发出微弱的光。这实在不合常理,因为在PWM模式下,占空比为0意味着整个周期都是无效电平,LED应该完全不亮才对。

4.1 现象与初步猜测

我当时的第一反应是代码写错了,比如GPIO被配置成了普通输出而没切成复用功能,或者定时器通道根本没使能。可是翻了一圈配置,GPIO确实是复用推挽,定时器通道也确实使能了,再回头用逻辑分析仪抓波形,发现输出脚上始终是一个反相的波形:占空比看起来是100%,而不是0%。

我把这个问题发到学习群里,有位网友提醒我:查一下PWM模式和输出极性是不是组合出了问题。这一下点醒了我。在模式配置正确的情况下,极性设置也会导致“0看起来是1”的反转效果。如果极性被配置成了低电平有效,那么原本“高电平表示点亮”的逻辑就反过来了。

4.2 一条完整的排查链路:从GPIO配置到寄存器更新时序

后来我把排查过程完整走了一遍,这里把链路记录下来,以后遇到类似问题可以直接按顺序查:

  1. 查看GPIOx->MODER,确认为复用功能模式
  2. 查看GPIOx->AFR,确认为定时器通道对应的复用编号
  3. 查看TIMx->CCER,核对输出使能位和极性位
  4. 查看TIMx->CCMR,核对PWM模式是1还是2
  5. 查看TIMx->CR1,确认计数器已经启动
  6. 最后再看TIMx->CCR1,确认比较值确实写进去了

当时我查到第3步就发现问题了:CCER的极性位被设置成了1,这意味着输出极性为低电平有效。在低电平有效的情况下,计数器值小于比较值时的电平效果会反过来,所以CCR=0时有效电平(低电平)一直输出,LED反而微亮。这个逻辑虽然绕,但底层机制并不复杂:极性位的本质是决定“有效电平是哪一个”,而非“改变波形本身”。

4.3 根因分析:极性设置和PWM模式的组合问题

今天这个坑的根因,一句话就能讲完:极性设置和PWM模式叠加产生了双重翻转。我把PWM模式设成了模式1,又把极性设成了低有效,等于在输出链路上多了一次逻辑翻转。后来我直接把极性位改回高电平有效,LED的呼吸效果立刻正常了。

这个经验让我对输出极性这件事有了更深的体会。对于一个需要“高电平驱动”的负载,极性应该选高有效;对于三级管、MOS管这类“低电平驱动”的电路,极性才选低有效。不要机械地抄别人的初始化代码,先搞清楚外设与实际硬件电路的对应关系,否则配置完全可能“看似能跑,实则逻辑相反”。

另一个隐患是寄存器更新的时序。虽然在主循环里直接改CCR1,当前周期结束后新值就会生效,但如果启用了预装载,比较值要等到更新事件才会被真正加载。初学者很容易在启用预装载后怀疑“为什么改了CCR没有反应”。遇到这种情况,要么手动产生一次更新事件,要么干脆像我一样先关闭预装载,把问题简化。

5. 第十二天复盘:接下来的学习线我打算这么走

第十二天是一个合适的复盘节点。前面学习的知识分散在不同的外设上,今天通过PWM把它们串成了一条完整的链路:GPIO模式配置、时钟树、定时器、比较输出、逻辑验证。能看到几个外设协同工作,和单独点亮一颗LED的心情完全不一样。

5.1 前十二天的学习路径回顾与排序思路

我把自己前面走过的学习路径整理成了下面这个表格,给同样在自学的朋友作个参考。时间只是一个大概节奏,适合我的不一定适合你,但主线思路值得参考:先点灯、再通信、再控制。

时间阶段学习内容知识点覆盖
第1-3天开发环境搭建、程序下载、GPIO输出点灯、位操作、GPIO模式
第4-6天按键输入、外部中断、上拉电阻输入模式、中断触发机制
第7-9天串口收发、调试打印、协议基础波特率、接收中断、数据格式
第10-11天定时器基础、延时函数、时序理解时钟配置、计数原理
第12天PWM输出、呼吸灯、寄存器配置预分频、ARR、CCR、极性设置

回过头来看,这个顺序的核心思想是“每个新知识点都建立在前一个知识点的真实需求之上”。点灯逼着我去理解引脚,按键逼着我去理解中断,串口逼着我去理解数据和协议,定时器则把前面所有的东西融在了一起。如果一开始就直奔PWM,你可能会配置得很熟练,却很难理解为什么这些参数之间是这样互相制约的。

5.2 后面三天的方向:从LED到电机调速

呼吸灯做完之后,我下一个目标是电机调速。LED亮度变化和电机转速变化虽然物理机制完全不同,但控制端用的都是PWM:改变占空比,就是改变输出电压的平均值。当天晚上我已经把电机驱动模块的原理图找出来了,准备研究PWM频率对电机噪声和扭矩的影响,再做一个旋钮调节电机转速的小实验。

这个方向选得比较有挑战性,因为它会同时涉及PWM输出、模数转换(ADC)、半桥电路逻辑和硬件保护。但好处也明显:完成之后,我对PWM的理解就不再局限于“让灯亮暗变化”,而是真正进入“用软件控制物理世界”的核心地带。后面如果想再深入,还可以顺带把编码器接口、PID控制这些内容慢慢引进来。

5.3 给同样在自学的你三个建议

最后说点我在第十二天总结出来的个人体会,算是踩过坑之后的经验提炼,而不是什么高深理论。

第一,初始化外设时尽量先直接操作寄存器,而不是一上来就封装成函数。直接操作寄存器,你就是必须面对每一个位的作用;封装成函数之后,你很容易跳过这些细节,最终只知道“调用它就能工作”,不知道“为什么就能工作”。

第二,遇到输出异常,按“时钟→GPIO复用→定时器模式→极性→比较值”的顺序排查,不要东看一眼、西猜一下。我今天的极性坑之所以浪费了不少时间,就是因为一开始跳过了CCER寄存器的检查,直接怀疑代码逻辑。

第三,每完成一个实验,留三十分钟写一下心得,哪怕只在草稿纸上画一个寄存器配置表也行。今天我在整理这篇博客的时候,才发现自己原来对“预装载”和“更新事件”的理解还停留在表面,正是为了写清楚这些内容,我才把定时器用户手册又翻了两遍。

明天我会继续往电机调速的方向走,手里的LED呼吸灯已经跑了一个多小时,稳定得很。第十二天,学到的东西不多,但都是真正进入了脑子里的。

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

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

立即咨询