简介:这是一份面向嵌入式初学者与STM32开发者的PWM驱动LED呼吸灯实战工程,围绕脉宽调制原理、定时器PWM模式配置、PB0引脚复用输出以及占空比渐变控制等核心知识点展开,帮助读者理解如何用定时器比较寄存器调节LED亮度,并掌握从时钟使能、GPIO配置到PWM通道设置的完整流程。资源包共221个文件,约6.35MB,包含31个c源文件与33个h头文件构成的外设驱动代码,44个crf与45个o、45个d等编译中间文件,以及uvprojx、uvoptx工程配置、hex烧录文件和map、lst等链接映射信息,覆盖STM32标准外设库的定时器、RCC、Flash、ADC等模块,目录结构完整,可直接导入Keil打开编译。目前已有577人学习下载,适合作为课程设计、实验报告或自学练手的参考工程,便于对照代码理解呼吸灯渐变逻辑与定时器中断更新比较值的实现方式。
1. 从“灯为什么能呼吸”说起:STM32 的 PWM 到底在调什么
很多人第一次在 STM32 上点亮 LED 之后,下一步就想让它“呼吸”起来——亮度缓缓上升,再缓缓下降,像在呼吸一样。这个效果听起来像模拟电路才能做的事,但用一颗数字芯片就能实现,靠的就是 PWM。PWM 全称脉冲宽度调制,它不改变输出电压的高低,而是改变一个周期内高电平所占的时间比例,也就是占空比。占空比越大,LED 在一个周期内导通的时间越长,人眼因为视觉暂留,感受到的平均亮度就越高。反过来,占空比越小,灯就越暗。所以呼吸灯的本质,不是去调电压,而是让定时器按一定节奏不断改写占空比。
这件事适合谁做?适合已经能用 GPIO 点亮 LED、但对定时器还停留在“知道有这个东西”阶段的嵌入式初学者,也适合想快速验证 PWM 输出是否正常的工程师。它不需要额外硬件,一块常见的 STM32 开发板、一个 LED、一个限流电阻就够了。真正要弄明白的,是定时器怎么产生 PWM、占空比由哪个寄存器决定、频率和分辨率怎么权衡,以及为什么你写的代码灯不亮或者只会闪不会呼吸。把这些搞清楚,后面驱动电机、调光、做音频输出,都是同一套逻辑。
2. 定时器怎么把占空比变成亮度:从计数器到 CCR 的完整链路
2.1 PWM 的硬件来源:通用定时器与高级定时器的差别
STM32 里能输出 PWM 的定时器分几类。常见的是通用定时器,比如 TIM2、TIM3、TIM4,它们有四个独立通道,每个通道可以映射到不同的 GPIO 引脚上。高级定时器如 TIM1、TIM8 功能更多,支持互补输出和死区插入,适合驱动电机,但做呼吸灯属于杀鸡用牛刀。基本定时器如 TIM6、TIM7 没有输出比较通道,不能直接输出 PWM。所以选型上,我一般优先用通用定时器,资源够用,配置也简单。
每个通用定时器内部有一个计数器,它按照时钟频率不停地加一或者减一。还有一个自动重装载寄存器,简称 ARR,它决定计数器的上限。计数器从 0 数到 ARR 再回到 0,这就是一个 PWM 周期。另外每个通道有一个捕获比较寄存器,简称 CCR。当计数器的值小于 CCR 时,通道输出一种电平;大于 CCR 时,输出另一种电平。于是 CCR 和 ARR 的比值就决定了占空比。这个关系是整件事的核心,后面所有参数计算都围绕它展开。
2.2 频率与分辨率的取舍:ARR 和预分频器怎么算
假设定时器的时钟源是 72 MHz,这是很多 STM32 型号在标准配置下的定时器时钟。我们要得到一个 PWM 周期,需要先经过预分频器分频,再让计数器数到 ARR。最终 PWM 频率的公式是:定时器时钟除以(预分频器加一)再除以(ARR 加一)。举个例子,预分频器设为 71,ARR 设为 999,那么频率就是 72 000 000 / 72 / 1000 = 1000 Hz,也就是 1 kHz。这个频率对人眼来说足够高,不会看到闪烁。
分辨率指的是占空比可以调节的档位数,它等于 ARR 加一。ARR 为 999 时,占空比可以从 0 到 1000 共 1001 个档位,调节精度大约是千分之一。如果你把 ARR 设得很小,比如 99,频率会变高,但档位只有 100 个,呼吸的过渡就会显得台阶感明显。所以频率和分辨率是一对矛盾:在时钟固定时,ARR 越大,分辨率越高,但频率越低。呼吸灯一般选 1 kHz 到 5 kHz 之间,ARR 取 1000 左右,兼顾平滑和响应。
2.3 用 HAL 库配置一路 PWM 输出的最小代码
下面这段代码基于常见的 HAL 库风格,展示如何初始化 TIM3 的通道 1,并让它输出 PWM。这里假设 LED 接在对应的 GPIO 上,且已经使能了相关时钟。
// 1. 定义定时器句柄 TIM_HandleTypeDef htim3; // 2. 初始化定时器基础参数 void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 71; // 预分频器,72MHz / (71+1) = 1MHz htim3.Init.CounterMode = TIM_COUNTERMODE_UP; // 向上计数 htim3.Init.Period = 999; // ARR,1MHz / (999+1) = 1kHz htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim3); // 3. 配置时钟源为内部时钟 sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig); // 4. 配置 PWM 通道 HAL_TIM_PWM_Init(&htim3); sConfigOC.OCMode = TIM_OCMODE_PWM1; // PWM 模式 1 sConfigOC.Pulse = 0; // 初始占空比为 0 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; // 有效电平为高 sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); // 5. 启动 PWM 输出 HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); }这段代码里,预分频器设为 71,是因为定时器时钟为 72 MHz,分频后得到 1 MHz 的计数时钟。Period 设为 999,意味着计数器每数 1000 次产生一个周期,于是 PWM 频率为 1 kHz。Pulse 就是 CCR 的值,初始为 0,占空比为 0,灯不亮。OCMode 选 PWM1 模式,表示计数器小于 CCR 时输出有效电平,这里有效电平为高。最后调用启动函数,PWM 就开始在对应引脚上输出了。注意,GPIO 的复用功能配置没有贴出来,实际使用时必须把对应引脚设为复用推挽输出,并映射到正确的定时器通道上,否则引脚上不会有波形。
2.4 呼吸效果的实现:用循环改写 CCR 并控制节奏
有了 PWM 输出,接下来就是让 CCR 的值按一定规律变化。最简单的做法是在主循环里写一个 for 循环,让占空比从 0 慢慢加到最大值,再从最大值减到 0,中间加一个延时来控制呼吸速度。
// 呼吸灯主循环 while (1) { // 渐亮 for (uint16_t i = 0; i <= 1000; i += 10) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, i); HAL_Delay(10); // 每档停留 10ms,总渐亮时间约 1 秒 } // 渐暗 for (uint16_t i = 1000; i > 0; i -= 10) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, i); HAL_Delay(10); } }这里每次改变 CCR 的步进是 10,总共 100 步,每步延时 10 毫秒,所以渐亮和渐暗各约 1 秒。步进越小,呼吸越平滑,但循环次数越多;延时越长,呼吸越慢。这两个参数可以根据实际观感调整。注意 HAL_Delay 是阻塞延时,在这期间 CPU 不能做别的事。如果呼吸灯只是整个系统里的一个小功能,建议改用定时器中断来更新 CCR,把主循环解放出来。另外,__HAL_TIM_SET_COMPARE 是一个宏,直接写寄存器,比调用 HAL 库函数更高效,适合在循环里频繁调用。
3. 从能亮到好看:占空比曲线、更新中断与 DMA 的进阶做法
3.1 线性变化为什么看着不自然:人眼对亮度的非线性响应
如果你用上面那段线性递增的代码,会发现灯在低亮度区变化很快,到了高亮度区反而感觉变化不明显。这不是错觉,而是人眼对亮度的感知近似对数关系。LED 的电流和亮度基本成正比,但人眼感受到的“亮度等级”和实际光通量不是线性对应的。所以要让呼吸看起来均匀,占空比不应该线性增加,而应该按照指数或查表的方式变化。
一个简单的改进是使用查表法。预先计算一组符合人眼感知的占空比数值,存在数组里,循环时依次取出写入 CCR。这样既能保证平滑,又不会增加太多计算量。如果不想查表,也可以用平方或指数近似,但要注意计算量,在低端单片机上避免浮点运算。
3.2 用更新中断替代阻塞延时:让呼吸灯不再拖住 CPU
阻塞延时的缺点是 CPU 在延时期间什么也做不了。更好的做法是利用定时器的更新中断,在每次计数器溢出时更新一次 CCR。这样呼吸节奏由硬件定时器控制,主循环可以处理其他任务。
// 在定时器初始化后使能更新中断 HAL_TIM_Base_Start_IT(&htim3); // 更新中断回调函数 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { static int16_t duty = 0; static int8_t dir = 1; // 1 表示渐亮,-1 表示渐暗 duty += dir * 10; if (duty >= 1000) { duty = 1000; dir = -1; } if (duty <= 0) { duty = 0; dir = 1; } __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty); } }这里把更新中断的频率设为 PWM 频率,也就是 1 kHz,那么每次中断间隔 1 毫秒。每进一次中断,占空比变化 10 个档位,100 次中断完成一次渐亮,耗时 100 毫秒。这个速度比之前快很多,实际使用时可以降低中断频率,比如把定时器周期设大一些,或者用单独的定时器来产生呼吸节奏。关键点是:中断里只做最简单的加减和赋值,不要放延时或复杂运算,否则会阻塞其他中断。
3.3 用 DMA 搬运占空比序列:适合多通道同步呼吸
如果同时驱动多个 LED,并且希望它们同步呼吸,用中断逐个改 CCR 会占用较多 CPU 时间。这时可以用 DMA 把预先算好的占空比数组自动搬运到 CCR 寄存器。DMA 传输不需要 CPU 干预,可以做到非常平滑的多通道同步。
配置思路是:把 CCR 寄存器的地址作为 DMA 的目标地址,把占空比数组作为源地址,设置传输长度和循环模式。每次定时器更新事件触发一次 DMA 请求,DMA 就搬运一个数据到 CCR。这样 CPU 只需要在初始化时准备好数组,之后完全不参与。不过这种用法对内存和 DMA 通道有要求,适合中高端型号。对于只有一路呼吸灯的场景,中断方式已经足够。
4. 呼吸灯调不出来的五个血泪坑:从现象到解决
4.1 灯完全不亮,示波器也看不到波形
现象:代码编译下载后,LED 一点反应都没有,用示波器测对应引脚也没有 PWM 波形。
原因:最常见的是 GPIO 复用功能没有配置。很多人只初始化了定时器,却忘了把引脚设为复用推挽输出,或者没有把引脚映射到正确的定时器通道上。另一个可能是定时器时钟没有使能,或者启动函数没有调用。
解决:检查 GPIO 初始化代码,确认模式为 GPIO_MODE_AF_PP,并且 AF 选择正确。用调试器查看定时器寄存器,确认计数器在跑、CCR 有值。如果计数器不动,检查预分频器和 ARR 是否写进去了。
4.2 灯只会闪,不会呼吸
现象:LED 以固定频率闪烁,亮度没有渐变过程。
原因:PWM 频率设得太低,比如只有几十赫兹,人眼就能看到闪烁。或者呼吸循环的延时太短,占空比变化太快,看起来就像在闪。
解决:把 PWM 频率提高到 1 kHz 以上。检查呼吸循环里的延时,确保每档停留时间在毫秒级,让渐变过程持续几百毫秒到一秒。如果用的是中断方式,确认中断频率和步进值匹配。
4.3 呼吸过程中有明显的台阶感
现象:亮度变化不平滑,能看出一段一段的跳跃。
原因:ARR 设得太小,占空比档位太少。比如 ARR 只有 99,那么只有 100 个亮度等级,低亮度区尤其明显。
解决:增大 ARR,比如设为 999 或 1999,同时调整预分频器保持频率不变。步进值也要相应调整,不要一次跳太多档。如果还不行,考虑用查表法代替线性变化。
4.4 改了 CCR 但亮度不变
现象:代码里明明在改 CCR 的值,LED 亮度却纹丝不动。
原因:可能没有使能自动重装载预装载,导致 CCR 的写入要等到下一个更新事件才生效,而更新事件可能被其他配置影响。或者你改的是错误的通道,比如初始化的是通道 1,改的却是通道 2 的 CCR。
解决:确认 __HAL_TIM_SET_COMPARE 里的通道参数和初始化时一致。检查定时器的自动重装载预装载使能位,一般建议打开。如果用的是 HAL 库函数,注意有些函数需要先停止 PWM 再改参数。
4.5 呼吸灯影响其他任务,系统变卡
现象:加入呼吸灯后,按键响应变慢,串口数据丢包。
原因:用了阻塞延时,CPU 在延时期间无法处理其他事件。或者更新中断频率太高,中断服务程序占用太多时间。
解决:改用更新中断或 DMA 方式,把呼吸控制放到后台。中断频率不要超过几 kHz,中断里只做必要操作。如果多个任务都需要定时,考虑用一个系统滴答定时器统一调度。
5. 把呼吸灯做成可复用的调光模块:参数封装与验证方法
5.1 封装成独立模块:结构体保存状态,接口只留三个函数
呼吸灯写一次不难,难的是每次换项目都要重写一遍。我习惯把它封装成一个独立模块,用一个结构体保存定时器句柄、通道、当前占空比、方向、步进和延时计数。对外只暴露三个函数:初始化、启动、停止。初始化时传入定时器句柄和通道,启动后模块自己靠中断或滴答定时器更新占空比。这样主循环里只需要调用一次启动,其他什么都不用管。
结构体里还可以加一个亮度曲线数组指针,默认用线性,需要时换成指数表。步进和更新周期也做成可配置参数,方便调整呼吸速度。这样同一个模块可以驱动单色呼吸灯,也可以驱动多路 RGB 做颜色渐变,只要多实例化几个结构体就行。
5.2 用示波器和万用表验证:占空比、频率和实际亮度的对应关系
代码写完之后,最好用示波器看一下引脚波形。把探头接在 LED 对应的引脚上,应该能看到一个频率稳定、占空比不断变化的方波。频率应该和你的计算值一致,占空比的变化范围应该在 0 到接近 100% 之间。如果占空比最大到不了 100%,检查 ARR 和 CCR 的关系,CCR 等于 ARR 时占空比才接近 100%。
没有示波器的话,可以用万用表测 LED 两端的平均电压。占空比变化时,平均电压应该跟着变。如果电压不变,说明 PWM 没有真正输出。另外,观察 LED 在最亮和最暗时是否达到预期,如果最亮不够亮,可能是限流电阻太大;如果最暗时还有微亮,可能是占空比没有降到 0,或者 LED 存在漏电流。
5.3 一个我常犯的错误:忘了关中断就改配置
最后说一个我踩过的坑。在运行中动态修改 PWM 频率时,我直接改了 ARR 和预分频器,结果定时器行为异常,PWM 输出乱了一阵。后来才明白,改这些寄存器最好在更新事件时进行,或者先停止定时器再改。如果开了更新中断,改配置时还要注意中断里可能会读到不一致的状态。现在我的习惯是:任何涉及定时器核心参数的修改,都先关掉更新中断,改完再打开。这个习惯帮我省了很多调试时间。
希望帮到你。
本文还有配套的精品资源,点击获取