STM32F103定时器PWM实现LED呼吸灯:从原理到工程实践
2026/9/16 1:27:01 网站建设 项目流程

简介:基于STM32F103C8T6的LED呼吸灯完整工程,面向刚入门STM32的开发者与嵌入式课程实验,演示利用定时器脉冲宽度调制技术控制引脚输出,使发光二极管按缓慢增亮再熄灭的节奏循环,呈现柔和呼吸效果。资源包共48个文件,大小约676KB,除MDK工程配置与调试文件外,还包含C语言源码、头文件、启动代码、链接脚本及编译生成的目标文件、映射文件等,可直接在Keil中打开查看或烧录到开发板验证现象。工程将代码划分为主程序和多个底层驱动模块,可对照学习引脚工作模式配置、定时器通道映射、占空比调节、循环节奏控制和延时程序设计,进而掌握单片机基础外设的开发思路。项目已有708人学习浏览,适合用于实验教学、毕业设计预研或个人入门练手,也便于后续扩展为其他PWM调光应用。

1. 呼吸灯不是“灯”,是定时器在替 CPU 打工

LED 呼吸灯这个题目,看起来是点灯,实际上是在逼你把 STM32F103 的定时器、GPIO 复用、时钟树一次性理清楚。单纯用 GPIO 翻转加延时也能让 LED 忽亮忽暗,但那是在浪费 CPU,而且波形毛糙、亮度跳变明显,放到手机状态灯或嵌入式设备指示灯上根本没法看。换成 TIM3 输出 PWM,占空比按预设曲线自动变化,CPU 只在更新比较值和处理业务逻辑时介入,这才是工程上真正会用的做法。这套机制从 Arduino 流水灯到工业调光模块都用同一个原理,区别只在于谁在管理占空比的变化节奏。下面直接按 STM32F103C8T6 最小系统板的视角,把从初始化到波形验证的完整路径走一遍,顺带拆几个新手几乎必踩的坑。

2. 定时器选型与引脚映射:为什么是 TIM3,以及 PB3/PB4 的坑

2.1 STM32F103 的 PWM 通路:从 72MHz 内核时钟到 GPIO 引脚的完整链路

呼吸灯本质上是一个低频变化的 PWM 信号。STM32F103C8T6 的 TIM3 属于通用定时器,挂在 APB1 总线上,但这里有一个容易误判的细节:APB1 的预分频如果设为 2,TIM3 的时钟会自动变成 PCLK1 的两倍,也就是 72MHz。很多人在初始化时把RCC_PCLK1_Div2误当成 TIM3 最终时钟只有 36MHz,导致后续计算预分频和周期时对不上号。

PWM 输出频率的完整公式是:

PWM频率 = TIMxCLK / ((PSC + 1) * (ARR + 1))

其中 PSC 是预分频值,ARR 是自动重装载值。例如 TIM3CLK 为 72MHz、PSC 设为 71、ARR 设为 999,输出频率就是 72MHz / 72 / 1000 = 1kHz。这个频率用来驱动 LED 呼吸很合适:远超人眼闪烁融合频率,又不至于高到让 LED 驱动电路出现寄生振荡。PWM 分辨率则由 ARR 决定,ARR 越大,占空比可以切分的等级越多,呼吸过渡越细腻。

2.2 TIM3 通道重映射表与 GPIO 复用配置

标准库写 PWM 之前,先要确认你用的引脚属于哪个通道的哪套映射。TIM3 在 STM32F103 上有三套映射,下面是 C8T6 上实际可用的对应关系:

通道默认映射部分重映射完全重映射
CH1PA6PB4PC6
CH2PA7PB5PC7
CH3PB0PB0PC8
CH4PB1PB1PC9

注意一个很多人会被绕进去的地方:有资料写“GPIOB 的 PIN3 接 TIM3 的 CH1”,这个说法在 F103 上不成立。PB3 默认是 JTDO,任何 TIM 通道都不直接映射到它。如果你的板子 LED 恰好接在 PB3,要么换到 PA6,要么用 TIM3 的部分重映射把 CH1 挪到 PB4。PB4 对应 NJTRST,使用前必须关闭 JTAG 释放引脚:

GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_TIM3, ENABLE);

GPIO 侧的配置同样容易出问题。PWM 输出引脚必须配置为复用推挽输出,也就是GPIO_Mode_AF_PP,不是普通推挽。有人初始化写成GPIO_Mode_Out_PP,LED 也能亮,但此时引脚由 GPIO 输出数据寄存器控制,定时器输出的波形根本到不了引脚,表现就是 LED 恒亮不呼吸:

void LED_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); }

GPIO_Speed在 PWM 场景下选 50MHz 不是为了快,而是为了保证输出驱动能力足够,避免在高频翻转时边沿变缓。对 1kHz 的呼吸灯来说 2MHz 也能工作,但后续如果要把同一个引脚复用到更高频信号,50MHz 更稳妥。

2.3 预分频与周期的参数选择逻辑

PSC 和 ARR 的选择直接影响呼吸效果,这里给一组实际可用的参数并解释推导过程。LED 呼吸节奏一般 2 到 4 秒一个周期,也就是从最暗到最亮然后回到最暗。假设目标 PWM 频率 1kHz,周期固定为 1ms,那么 ARR 取 999。剩下的 PSC 用来把 72MHz 降到 1MHz,所以 PSC 取 71。

选 1kHz 而不是 100Hz 是有讲究的。100Hz 的 PWM 在某些 LED 驱动电路里会出现可闻噪声,尤其在 PWM 频率落在音频范围内且驱动电路包含电感时。1kHz 以上可以避开大部分音频噪声问题,同时占空比变化步进用 2ms 一档就足够平滑。如果用的是带 RC 滤波的 LED 驱动电路,PWM 频率还要继续提高,否则输出纹波会非常大,呼吸过程能看到明显阶梯。

如果你在 stm32cubemx 里配置,直接把 TIM3 的 Clock Source 选为 Internal Clock,Prescaler 填 71,Counter Period 填 999,Pulse 填 0,生成的 HAL 代码本质上和上面标准库做的事情一样,区别只是结构体名称和句柄封装。手写标准库的好处是可以清楚看到每一步到底在操作哪个寄存器,出问题时更容易定位。

3. 标准外设库工程还原:从 Keil 工程结构到 main.c 的占空比渐变

3.1 工程文件里哪些东西值得看

下载回来的资源里有一堆 Keil 工程文件,初看很乱,其实分类明确。Template.uvprojx是工程主文件,Template.uvoptx存的是调试器和断点等用户配置,ListingsObjects目录下是编译中间产物,Template.mapTemplate.axf是链接输出。startup_stm32f10x_md.lst中的md表示中容量产品,对应的是 64KB 以上 Flash 的 F103 系列,C8T6 正好属于这一类。

对于 C8T6 这种 64KB Flash 的芯片,编译后要养成看Template.map的习惯。这个文件里能直接查到代码段、数据段的大小,如果.text段超过 Flash 容量,链接器会报错,但报错信息不一定直观。Template.sct是分散加载文件,Keil 默认生成,一般不需要手改,但当你把工程从 256KB 的芯片移植到 64KB 的 C8T6 上时,Flash 起始地址和大小要确认没写错。

RTE目录下的RTE_Components.h是 CMSIS RTE 机制生成的组件配置文件,标准库工程里它主要用来声明Device:Startup这类组件。delay.cled.c这些文件就是业务代码所在,呼吸灯的核心逻辑集中在main.c里。

3.2 PWM 初始化与主循环的完整实现

LED 接在 PA6,也就是 TIM3_CH1 的默认映射引脚,初始化 GPIO 之后配置 TIM3。下面是完整可跑的标准库代码:

#include "stm32f10x.h" #include "delay.h" #define PWM_PERIOD 999 void TIM3_PWM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = PWM_PERIOD; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }

TIM_Prescaler为 71 意味着计数时钟被分到 1MHz,TIM_Period为 999 表示计数器从 0 计到 999,也就是一个周期 1ms。TIM_OCMode_PWM1指定当计数值小于比较值时输出有效电平,TIM_OCPolarity_High表示有效电平为高,这两个参数配合决定了占空比和 LED 亮度的对应方向。TIM_OC1PreloadConfig使能比较值预装载,避免在更新计数的瞬间写入 CCR 导致一个周期内出现异常脉冲。

主循环的核心就一句话,不断改变比较值:

int main(void) { uint16_t cc = 0; int8_t dir = 1; delay_init(); LED_GPIO_Init(); TIM3_PWM_Init(); while (1) { TIM_SetCompare1(TIM3, cc); cc += dir; if (cc >= PWM_PERIOD) dir = -1; if (cc == 0) dir = 1; delay_ms(2); } }

TIM_SetCompare1的本质是修改 TIM3 的 CCR1 寄存器,下一次计数周期就会按新比较值输出。cc 从 0 递增到 999 再递减回 0,对应占空比从 0% 到 100% 再回 0%,一个完整呼吸周期约 4 秒。delay_ms(2)控制每档占空比的持续时间,改成 4ms 就是 8 秒周期,呼吸动作明显变慢。

3.3 呼吸曲线的两种写法:线性渐变与指数渐变

上面代码是线性渐变,占空比匀速变化。问题在于人眼对亮度的感知近似对数关系,LED 的亮度在低占空比时变化最敏感,线性渐变的视觉效果是快亮快灭、中间有一段漫长的“没反应区”。要做出自然呼吸感,需要让占空比按指数曲线变化。

一个不依赖浮点运算的做法是先把曲线算好存在 Flash 里,运行时只查表:

#define TABLE_SIZE 64 const uint16_t breath_table[TABLE_SIZE] = { // 由指数公式生成,避免在 MCU 上做 exp 运算 0, 1, 2, 4, 7, 11, 17, 24, 33, 44, 57, 73, 91, 112, 135, 161, 190, 222, 257, 295, 336, 380, 427, 477, 530, 586, 645, 706, 770, 836, 905, 976, 999, 999, // 后半段对称或单独生成均可 };

指数曲线的生成可以在 PC 上算好:level = (uint16_t)(999 * (1 - exp(-i / 10))),i 从 0 到 63。运行时代码只做查表和设置比较值,M3 内核在 72MHz 下查表耗时可以忽略。查表方式也方便调整呼吸节奏,把两个相邻表的索引间隔从 1ms 改成 2ms,整个周期就翻倍,而曲线形状不变。这也是 freerots 这类系统里任务睡眠维度调整呼吸速度的常见做法。

4. 亮灭节奏、延时与调试:眼睛看着舒服才是真呼吸

4.1 占空比渐变的对称性与保持时间

自然呼吸的节奏不是完全对称的,吸气通常快于呼气,且到达最亮和最暗时都会有一个短暂停留。线性上下扫在视觉上太机械,工程里常引入状态机来控制三个阶段的时长。下面是一个带保持时间的呼吸状态机:

typedef enum { RISE, FALL, HOLD_DIM, HOLD_BRIGHT } BreathState; BreathState state = RISE; uint16_t cc = 0; uint16_t hold_tick = 0; while (1) { switch (state) { case RISE: TIM_SetCompare1(TIM3, cc); cc += 2; if (cc >= PWM_PERIOD) { cc = PWM_PERIOD; state = HOLD_BRIGHT; hold_tick = 100; } break; case HOLD_BRIGHT: if (--hold_tick == 0) state = FALL; break; case FALL: TIM_SetCompare1(TIM3, cc); cc -= 2; if (cc <= 0) { cc = 0; state = HOLD_DIM; hold_tick = 50; } break; case HOLD_DIM: if (--hold_tick == 0) state = RISE; break; } delay_ms(2); }

HOLD_BRIGHT保持约 200ms,HOLD_DIM保持约 100ms,模拟吸气停顿和呼气停顿的差异。cc 的步进值也可以区分,上升用 1、下降用 2,这样亮起过程缓慢、熄灭过程稍快,观察起来更像呼吸节律。

4.2 阻塞延时与定时器中断方案的选择

上面的代码是阻塞式写法,main 循环里一直做占空比更新,CPU 无法处理按键、通信等任务。实际项目中一般把占空比更新放到定时器中断里,主循环只管业务,通过一个全局变量让定时器中断和业务代码协作。更简单的思路是使用 SysTick 做时间基准,累加器满 30ms 更新一次TIM_SetCompare1,按键扫描和串口打印都挂在这套时间片上。

阻塞和中断的取舍取决于外设数量。单 LED 呼吸灯用阻塞完全没有问题,代码可读性也最好;一旦引入按键、OLED 或多路 LED,就要切到定时器中断方案。但要小心一个常见错误:在中断里调用delay_ms。SysTick 中断优先级如果设置不当,嵌套调用delay_ms会导致延时时间指数级拉长,现象是 LED 呼吸节奏突然变得极慢。

4.3 用示波器观察 PWM 波形时该看什么

判断呼吸灯实现是否正确的最终标准不是眼睛,而是示波器上的波形。把探头夹在 LED 引脚和 GND 之间,时基调到 200ms/格,能看到一条占据整个屏幕宽度的密集脉冲带。再调到时基 1us/格,能看到单周期 1kHz 的方波,此时测量脉冲宽度,算出的占空比应该和代码里 CCR 值一致。

几个典型异常的判断方法。LED 一直亮不呼吸,先检查TIM_OCMode是不是被写成了TIM_OCMode_PWM2,这会把有效电平反向,占空比计算方式也随之变化。LED 呼吸过程有肉眼可见的闪烁,说明 PWM 频率太低,把 PSC 改小、保持 ARR 不变,让频率升到 1kHz 以上。波形上下沿有明显圆角,检查 GPIO 是否配置成GPIO_Speed_2MHz,换到 50MHz 后再看边沿是否变陡。

4.4 HSE 起振失败与硬件限流的排查顺序

呼吸灯电路非常简单,但硬件层的坑往往比软件更隐蔽。LED 必须串联限流电阻,3.3V 供电下接 100 到 220 欧姆电阻比较合适。电阻太大,PWM 满占空比时电流不足,视觉上呼吸幅度变小;电阻太小,满占空比时电流超限,LED 虽然不一定会立刻烧坏,但亮度曲线会被钳位,呼吸过程最后一段看起来是“跳变”而不是渐变。

另一个高频问题出在时钟源上。工程如果配置了外部 8MHz 晶振加 PLL 倍频到 72MHz,但晶振没有起振,STM32 会自动切回内部 HSI 8MHz 时钟。这时 PWM 频率从 1kHz 掉到约 111Hz,呼吸节奏慢得异常,而且在示波器上能看到明显的低频闪烁。排错方法是在 main 函数开头读取RCC_GetFlagStatus(RCC_FLAG_HSERDY),如果复位标志置位,说明外部晶振失败,先检查起振电容值是否在 10pF 到 22pF 之间,而不是先去查程序逻辑。

5. 进阶玩法:按键切换速度,串口打印占空比

5.1 用按键实时改变呼吸周期

把干扰呼吸循环的外部事件接进来,思路是用 EXTI 按键中断修改一个全局周期变量,主循环判断周期变化后重新计算步长。按键接 PA0,配置为上升沿触发外部中断:

volatile uint16_t breath_period = 4000; void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { breath_period += 500; if (breath_period > 5000) breath_period = 1000; EXTI_ClearITPendingBit(EXTI_Line0); } }

中断里不放置任何延时和占空比操作,只修改breath_period,主循环里每次迭代都读取这个变量并换算延迟时间。注意breath_period必须加volatile修饰,否则编译器可能在优化后让主循环一直使用旧值。这样从 1 秒到 5 秒的呼吸周期可以循环切换,逻辑和独立按键直接控制 LED 亮灭的区别在于,按键改变的是渐变速率而不是 LED 开关状态。

5.2 串口打印当前占空比

调试 PWM 渐变时,断点调试会打断时间节奏,串口打印是更合适的手段。配置 USART1 到 PA9/PA10,重定向fputc

int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; }

printf("cc=%d\n", cc)就能把占空比打出来,配合串口助手的波形显示功能能看到一条近似三角波的曲线。如果同时用到多个串口,注意串口 1 挂在 APB2 总线,串口 3 挂在 APB1 总线,初始化时使能的 RCC 时钟分别对应RCC_APB2Periph_USART1RCC_APB1Periph_USART3,引脚复用也完全不同,选错总线外设根本不会工作。

串口日志确认占空比正确后,把呼吸灯逻辑从调试模式切换到正式运行模式,再决定是否保留打印代码。通常保留一个编译开关,例如#define BREATH_DEBUG,正式版本关掉,省掉 USART 初始化带来的功耗和中断开销。调试模式下同时打印呼吸周期和实时占空比,比用示波器逐点比对快得多,这也是一条循环里同时验证业务逻辑和外设驱动的有效路径。

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

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

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

立即咨询