☰
STM32 PWM驱动LED呼吸灯:定时器配置与占空比调制实战
2026/10/14 11:17:42 网站建设 项目流程

简介:这份资源面向嵌入式开发初学者与STM32入门实践者,围绕PWM脉宽调制驱动LED呼吸灯这一经典实验展开,帮助读者理解定时器输出比较、占空比调节与GPIO复用功能的核心原理。压缩包共221个文件,约6.35MB,包含31个c源文件与33个h头文件(如stm32f10x_tim.c、stm32f10x_rcc.c等标准外设库代码)、45个o目标文件、44个crf编译中间文件,以及uvprojx工程文件、hex烧录文件、map映射表与asm汇编清单,覆盖从源码到可执行固件的完整工程链路。资源已有577人学习下载,适合对照Keil工程结构梳理PB0引脚PWM输出配置、TIM2预分频与自动重载值设定、比较值渐变实现亮度平滑过渡等关键环节,也可用于排查时钟使能、通道映射与占空比更新中的常见问题,是掌握STM32定时器PWM应用的实用参考。

1. 从“灯怎么呼吸”说起:STM32 PWM 驱动 LED 的完整落地路径

很多人第一次接触 STM32 的定时器,都是从“让一盏 LED 慢慢亮、慢慢灭”开始的。这个需求听起来简单,但真正动手时你会发现,卡住新手的往往不是 C 语言,而是三个具体问题:PWM 频率设多少才不闪、占空比怎么按正弦规律变化、以及为什么我的灯一亮就常亮或者干脆不亮。这篇笔记拆的就是一份围绕“STM32 PWM 驱动 LED 呼吸灯”的实战资源,它把定时器配置、PWM 输出、占空比调制这条链路完整串了起来。适合刚上手 STM32 的嵌入式开发者,也适合想回头把定时器 PWM 模式彻底搞明白的熟手。下面按“这是什么 → 怎么配 → 坑在哪 → 怎么调得更好”的顺序走一遍。

2. 定时器与 PWM 的底层逻辑:为什么呼吸灯非得用定时器

2.1 PWM 到底在做什么:频率决定闪不闪,占空比决定亮不亮

PWM 全称脉冲宽度调制,本质是一个方波。它有两个关键参数:频率和占空比。频率决定这个方波一秒钟重复多少次,占空比决定高电平占整个周期的比例。LED 接在 PWM 输出脚上时,如果频率足够高,人眼因为视觉暂留不会察觉到闪烁,只会感知到“平均亮度”。平均亮度约等于占空比乘以最大亮度。所以呼吸灯的核心操作就是:固定一个足够高的频率,然后让占空比从 0% 慢慢升到 100%,再从 100% 降回 0%,循环往复。

这里有个容易混淆的点:频率不是越高越好。频率太高,比如几十 kHz,虽然不会闪,但 LED 驱动电路的响应可能跟不上,而且定时器预分频和自动重装载值的组合会变得很别扭。常见做法是把 PWM 频率设在 1 kHz 到 5 kHz 之间,这个区间人眼完全看不到闪烁,同时定时器参数也好算。

2.2 STM32 定时器输出 PWM 的硬件路径

STM32 的通用定时器(比如 TIM2、TIM3、TIM4)和高级定时器(TIM1、TIM8)都能输出 PWM。以通用定时器为例,信号路径是这样的:定时器计数器 CNT 在时钟驱动下不断累加,从 0 数到自动重装载寄存器 ARR 的值,然后归零重新开始。与此同时,比较寄存器 CCR 里存着一个阈值。当 CNT 小于 CCR 时,输出通道处于有效电平;当 CNT 大于等于 CCR 时,输出通道翻转为无效电平。这样,CCR 的值就直接决定了占空比。

占空比的计算公式是:占空比 = CCR / (ARR + 1)。注意这里是 ARR+1,因为计数器从 0 数到 ARR,一共经历了 ARR+1 个计数周期。PWM 频率的计算公式是:PWM 频率 = 定时器时钟 / ((预分频器 PSC + 1) × (ARR + 1))。这两个公式是后面所有参数配置的基础,建议先记牢。

2.3 呼吸灯为什么常用正弦波调制而不是线性变化

如果让占空比从 0 到 100 线性增加,你会发现灯的亮度变化看起来并不“自然”——前半段感觉亮得很快,后半段又好像没什么变化。这是因为人眼对亮度的感知近似对数关系,不是线性的。所以更自然的呼吸效果通常用正弦波或者指数曲线来调制占空比。常见做法是预先算好一个正弦表,或者用查表加插值的方式,让 CCR 的值按正弦规律变化。这一点在资源里通常会有对应的数组或计算公式,拿到后可以直接改表长和幅值来调整呼吸速度与深度。

3. 从零配置一路到灯亮:定时器参数、通道映射与代码落地

3.1 时钟树与定时器时钟:先搞清楚你的定时器跑多快

在写任何定时器代码之前,必须先确认定时器挂在哪条总线上、时钟频率是多少。以常见的 STM32F103 为例,TIM2、TIM3、TIM4 挂在 APB1 总线上。如果系统时钟是 72 MHz,APB1 预分频系数不为 1 时,定时器时钟会是 APB1 时钟的两倍,也就是 72 MHz。这个细节如果搞错,算出来的 PWM 频率会差一倍,灯可能闪得厉害或者呼吸节奏完全不对。

确认时钟的步骤不复杂:查参考手册的时钟树图,找到对应定时器的时钟源,再结合 SystemInit 里的时钟配置算出实际频率。我一般会在代码里先把定时器时钟频率定义成一个宏,后面算 PSC 和 ARR 时直接引用,避免硬编码。

3.2 定时器初始化:PSC、ARR、CCR 三个参数怎么定

假设定时器时钟是 72 MHz,目标 PWM 频率是 2 kHz,希望占空比分辨率尽量高。先定 ARR。ARR 越大,占空比可调的档位越多,但频率会降低。取 ARR = 999,那么计数周期是 1000 个时钟。PWM 频率 = 72 MHz / ((PSC+1) × 1000)。要得到 2 kHz,则 (PSC+1) = 72 MHz / (2 kHz × 1000) = 36,所以 PSC = 35。这样占空比分辨率是 1/1000,足够做平滑呼吸。

下面是定时器 PWM 初始化的核心代码,以 HAL 库风格为例:

// 定时器时钟 72MHz,目标 PWM 频率 2kHz,分辨率 1000 档 #define TIM_CLK_HZ 72000000U #define PWM_FREQ_HZ 2000U #define PWM_RESOLUTION 1000U TIM_HandleTypeDef htim3; TIM_OC_InitTypeDef sConfigOC = {0}; void PWM_Init(void) { // 计算预分频值:PSC+1 = 定时器时钟 / (PWM频率 × 分辨率) uint32_t psc = (TIM_CLK_HZ / (PWM_FREQ_HZ * PWM_RESOLUTION)) - 1; htim3.Instance = TIM3; htim3.Init.Prescaler = psc; // 预分频器 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; // 向上计数 htim3.Init.Period = PWM_RESOLUTION - 1; // ARR,自动重装载值 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; 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); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 启动 PWM 输出 }

这段代码里,Prescaler和Period就是前面算出来的 PSC 和 ARR。Pulse对应 CCR,初始设为 0,灯是灭的。OCMode选 PWM1 模式,意思是 CNT 小于 CCR 时输出有效电平。OCPolarity设为 HIGH,有效电平就是高电平。如果你的 LED 是低电平点亮,这里可以改成 LOW,或者硬件上做反相。

3.3 GPIO 复用与通道映射:别把引脚配成普通输出

PWM 输出脚必须配置成复用推挽输出,而不是普通推挽输出。以 TIM3 通道 1 为例,对应引脚通常是 PA6。配置时要使能 GPIOA 时钟和 TIM3 时钟,然后把 PA6 设为GPIO_MODE_AF_PP,复用映射到 TIM3。如果这里配错,定时器内部再怎么跑,引脚上也不会有波形。

HAL 库里这一步通常在HAL_TIM_PWM_MspInit回调里完成:

void HAL_TIM_PWM_MspInit(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_6; gpio.Mode = GPIO_MODE_AF_PP; // 复用推挽 gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gpio); } }

注意Mode必须是GPIO_MODE_AF_PP,不是GPIO_MODE_OUTPUT_PP。这个坑很常见,现象是定时器配置看起来都对,但示波器测引脚没有任何波形。

3.4 占空比动态更新:用正弦表驱动 CCR 实现呼吸

呼吸效果的关键是让 CCR 随时间按正弦规律变化。常见做法是生成一个正弦表,表里每个值映射到 0 到 ARR 之间。下面是一个简单的实现:

#define BREATH_TABLE_SIZE 128 // 预计算正弦表,值域 0 ~ PWM_RESOLUTION-1 static uint16_t breath_table[BREATH_TABLE_SIZE]; void BreathTable_Init(void) { for (int i = 0; i < BREATH_TABLE_SIZE; i++) { // sin 范围 -1~1,映射到 0~1,再缩放到分辨率 float s = sinf(2.0f * 3.1415926f * i / BREATH_TABLE_SIZE); breath_table[i] = (uint16_t)((s + 1.0f) * 0.5f * (PWM_RESOLUTION - 1)); } } // 在定时器更新中断或主循环里调用,index 递增 void Breath_Update(uint32_t index) { uint16_t ccr = breath_table[index % BREATH_TABLE_SIZE]; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, ccr); }

breath_table里存的是 0 到 999 之间的值,对应占空比 0% 到 100%。Breath_Update每次把新的 CCR 写进去,灯就会平滑地亮灭。更新频率决定了呼吸速度:如果每 10 ms 更新一次,128 个点走完一轮大约 1.28 秒,呼吸节奏比较舒服。改BREATH_TABLE_SIZE可以调快慢,改更新周期也可以。

4. 避坑与排查:呼吸灯不亮、常亮、闪烁的五个真实原因

4.1 灯常亮不呼吸:CCR 没更新或更新在错误的通道上

现象是灯一直亮着,亮度不变。原因通常是__HAL_TIM_SET_COMPARE写错了通道,或者根本没调用更新函数。还有一种可能是 CCR 被设成了大于 ARR 的值,PWM1 模式下输出会一直保持有效电平。解决方法是先确认通道号与初始化一致,再检查 CCR 是否在 0 到 ARR 之间,最后用调试器观察 CCR 寄存器是否在变化。

4.2 灯完全灭:GPIO 没配成复用模式或定时器没启动

现象是引脚测不到波形,灯一点反应没有。最常见的原因是 GPIO 配成了普通输出,或者HAL_TIM_PWM_Start没调用。也有可能是时钟没使能,定时器根本没跑。排查顺序是:先量引脚波形,没有波形就查 GPIO 模式;有波形但灯不亮,查硬件接线和极性。

4.3 灯闪烁明显:PWM 频率太低或更新周期太长

现象是能看到灯在闪,而不是平滑呼吸。原因是 PWM 频率低于 100 Hz 左右,人眼就能察觉。或者呼吸表的更新周期太长,比如每 100 ms 才更新一次,亮度变化就会一跳一跳。解决办法是把 PWM 频率提到 1 kHz 以上,同时把更新周期控制在 10 ms 到 20 ms 之间。

4.4 呼吸节奏不对:正弦表映射范围算错

现象是灯亮灭不对称,或者最亮时不够亮、最暗时不够暗。原因是正弦表映射时没有正确缩放到 0 到 ARR 之间。比如用了(s+1)*0.5*ARR但忘了减 1,或者用了整数除法导致精度丢失。建议在初始化后把表里最大值和最小值打印出来,确认覆盖了 0 和 ARR。

4.5 换了个定时器就不行:通道映射和时钟总线不同

现象是在 TIM3 上跑得好好的,换到 TIM1 或 TIM4 就没输出。原因是不同定时器的通道对应引脚不同,挂载的总线也可能不同,时钟频率不一样。解决方法是查数据手册的复用功能映射表,重新确认引脚和时钟,并重新计算 PSC 和 ARR。

5. 进阶调优:用 DMA 和中断把 CPU 解放出来

5.1 用定时器更新中断驱动呼吸表

前面是在主循环里调用Breath_Update,如果主循环还有其他任务,呼吸节奏可能会被拖慢。更稳的做法是用定时器更新中断,在中断里递增索引并更新 CCR。这样呼吸节奏由硬件定时器保证,不受主循环影响。配置时把更新中断使能,在HAL_TIM_PeriodElapsedCallback里调用更新函数即可。注意中断频率不要太高,否则 CPU 开销会上去。一般 100 Hz 到 200 Hz 的更新率就足够平滑。

5.2 用 DMA 把正弦表直接搬到 CCR 寄存器

如果不想让 CPU 参与每次 CCR 更新,可以用 DMA。思路是把正弦表放在内存里,配置 DMA 通道从内存搬到定时器的 CCR 寄存器,由定时器更新事件触发 DMA 传输。这样 CPU 只需要初始化一次,之后呼吸完全由硬件自动完成。配置时注意 DMA 的源地址递增、目标地址固定,传输长度等于表长,并且要开启循环模式。这个方案适合对 CPU 占用敏感的场景,比如同时要跑其他实时任务。

5.3 呼吸曲线调优:正弦、三角、指数的选择

正弦曲线最接近自然呼吸,但如果你想要更“急促”的效果,可以用三角波或者指数曲线。三角波实现最简单,占空比线性升降,但视觉上不如正弦自然。指数曲线则更接近人眼感知,亮起快、暗下慢。实际调优时,我一般会先把表长和更新周期定下来,再微调曲线形状。一个实用技巧是:把表里的值先归一化到 0 到 1,再乘以 ARR,这样换分辨率时不用重算表。

5.4 验证方法:示波器看波形,眼睛看效果

调完之后怎么确认参数对了?最直接的是用示波器测引脚波形,看频率是不是目标值,占空比是不是在 0% 到 100% 之间来回变化。没有示波器的话,可以用定时器的输入捕获功能测频率,或者用调试器实时观察 CCR 寄存器的值。眼睛看效果时,注意在暗环境下观察,亮灭过渡是否平滑、有没有跳变。如果发现某个亮度点跳变明显,多半是表长不够或者更新周期不均匀。

从那以后我每次调 PWM 呼吸灯,都会先把定时器时钟频率和 ARR、PSC 的算式写在纸上核对一遍,再上电看波形,最后才调呼吸曲线。这个顺序能省掉很多来回改参数的功夫。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询