简介:面向嵌入式入门者与STM32开发者,这套工程基于Keil5 IDE与STM32F103VET6微控制器,实现LED呼吸灯与状态指示灯功能。工程涵盖GPIO初始化、定时器PWM配置及呼吸灯亮度渐变算法,可用于设备状态指示、用户界面反馈等场景,适合学习PWM调光和STM32外设驱动开发。整个工程打包为RAR压缩包,容量4.78MB,共194个文件,以C源代码和头文件为核心,并包含编译中间文件(o、crf)、烧录文件(hex、axf)以及Keil工程配置(uvprojx、uvoptx)等,目录结构完整,可直接打开参考。内容预览中可见stm32f10x_tim.c、rcc.c、adc.c、i2c.c、can.c、usart.c等标准外设库文件,说明工程不仅限于呼吸灯,还涉及多个外设模块,有助于学习者熟悉STM32固件库的组织方式与常见外设配置流程。已有916人学习浏览,对于希望快速上手Keil5工程和呼吸灯实现的读者,这套工程具有不错的参考价值。
1. 从一颗闪烁的 LED 到状态可视化:这个 Demo2 在解决什么
STM32F103 的 LED 控制几乎是每个嵌入式开发者上手的第一课,但多数人做完流水灯就停了,真正进入产品开发时才发现,指示灯远不止“亮”和“灭”这么简单。一个设备上往往同时存在电源指示、运行状态、告警、通信反馈等多路信号,如果全部用 GPIO 翻转来点灯,代码会迅速退化成难以维护的delay地狱。这个名为“状态指示灯、呼吸灯”的 Demo2 想做的事情,就是用一个工程把两类最常用的灯效统一起来:一类是二值状态的指示灯,一类是渐亮渐灭的呼吸灯,并在 Keil5 环境下基于 STM32F103 最小系统跑通完整流程。它可以作为你后续移植到 RTOS、多路传感器项目里的基础模块,也可以直接改造成独立的状态显示组件。整篇我会顺着“为什么这样接、参数怎么算、代码怎么写、出了问题看哪里”的顺序来讲,确保你在自己的板子上能一个文件一个文件地复现出来。
2. 状态指示灯与呼吸灯的基础:GPIO 推挽输出和 PWM 占空比
2.1 为什么状态灯用 GPIO、呼吸灯必须用 PWM
状态指示灯表达的是“有/无”或“是/否”,比如电源是否就绪、传感器是否在线。这类信号天然只有两个电平,直接用 STM32F103 的 GPIO 引脚配置为推挽输出即可。推挽输出模式下,引脚可以主动输出高电平或低电平,驱动能力足够点亮普通贴片 LED,不需要额外加三极管。需要注意 LED 串联限流电阻,阻值按(3.3V - Vf) / If估算,红光 LED 正向压降约 1.8V,电流取 5mA 时,限流电阻大约 300Ω,实际取 330Ω 或 470Ω 都能正常工作。
呼吸灯表达的是“渐变”或“周期变化”,人眼感知到的是亮度连续过渡。如果只靠 GPIO 翻转加延时来做,也能模拟出渐亮渐灭的效果,但有两个明显问题:一是 CPU 全程被延时占用,无法处理其他任务;二是亮度阶梯粗糙,视觉效果生硬。常见的做法是使用定时器输出 PWM,通过修改比较寄存器来逐级调整占空比。STM32F103 的定时器分辨率是 16 位,PWM 频率和步进值有较大的调节空间,做呼吸灯时占空比从 0% 到 100% 的过渡可以做到肉眼顺滑。
2.2 用定时器产生 PWM 的最小寄存器配置
STM32F103 的通用定时器 TIM3 的 CH1 对应 PA6 引脚,这是一个非常常用的 PWM 输出通道。我一般会直接操作寄存器来初始化,这样对时钟树和定时器工作方式的理解更直观。
void PWM_Init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出,交给TIM3_CH1 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 999; // 自动重装载值,决定PWM周期 TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz / 72 = 1MHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // PWM模式1:CNT < CCR时输出有效电平 TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; // 初始占空比为0 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); // 使能预装载,防止更新瞬间跳变 TIM_Cmd(TIM3, ENABLE); }这段配置的关键参数是预分频器和自动重装载值。72MHz 的系统主频经过 72 分频得到 1MHz 的计数时钟,TIM_Period = 999意味着计数器从 0 加到 999 正好用 1ms,也就是说 PWM 频率是 1kHz。这个频率做呼吸灯完全够用,人眼不会看到闪烁感。TIM_Pulse是当前比较值,占空比就是TIM_Pulse / 1000。修改占空比时只需要调用TIM_SetCompare1(TIM3, value),不需要重新初始化定时器。
提示:如果呼吸灯看起来有明显的颗粒感,优先检查
TIM_Period是否太小。低于 100 时,占空比只有 100 个档位,渐变会一档一档地跳。
2.3 状态指示灯的最小驱动代码
状态指示灯部分没有太多技巧,关键是代码结构要清晰。我会把每个 LED 抽象成“亮、灭、翻转”三个操作,并在 GPIO 配置时就把引脚和端口封装好,这样后续换引脚只需要改一处定义。
#define LED_STATUS_GPIO_PORT GPIOB #define LED_STATUS_GPIO_PIN GPIO_Pin_0 #define LED_RUN_GPIO_PORT GPIOB #define LED_RUN_GPIO_PIN GPIO_Pin_1 void LED_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = LED_STATUS_GPIO_PIN | LED_RUN_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(LED_STATUS_GPIO_PORT, &GPIO_InitStructure); GPIO_SetBits(LED_STATUS_GPIO_PORT, LED_STATUS_GPIO_PIN); GPIO_ResetBits(LED_RUN_GPIO_PORT, LED_RUN_GPIO_PIN); } void LED_Status_Set(uint8_t on) { if (on) { GPIO_SetBits(LED_STATUS_GPIO_PORT, LED_STATUS_GPIO_PIN); } else { GPIO_ResetBits(LED_STATUS_GPIO_PORT, LED_STATUS_GPIO_PIN); } }这里的GPIO_SetBits和GPIO_ResetBits是标准外设库里的位操作函数,比直接写ODR寄存器可读性好。需要注意 STM32F103 的 GPIOB 引脚默认功能是 JTAG 调试口,如果用了 PB3、PB4,需要在初始化之后关闭 JTAG 复用,否则引脚无法正常输出。本段代码选用 PB0 和 PB1,避开这个问题。
3. Keil5 工程搭建与 STM32F103 最小系统对应关系
3.1 从最小系统原理图确定 LED 和 PWM 的引脚映射
STM32F103 最小系统板的核心外围只有电源、晶振、复位和 BOOT 配置。LED 在最小系统上通常直接连接到 GPIO 和 3.3V 之间,引脚输出低电平时 LED 点亮,这样的接法叫低电平有效。原因是 GPIO 的灌电流能力通常比拉电流能力稍强,而且单片机上电瞬间引脚默认是高电平,LED 不会误亮。
先看最小系统的原理图确认三件事:LED 连接在哪个 GPIO 上、是高电平有效还是低电平有效、是否有上拉或下拉电阻。多数开发板的 LED 原理图会在引脚和 LED 之间串一个 1kΩ 左右的电阻,这样计算出来的驱动电流大约 1.5mA,用于指示已经足够。如果要做高亮度的呼吸灯效果,建议把限流电阻换成 330Ω 到 470Ω,此时电流在 4mA 到 6mA 之间,亮度变化范围更大,而且不会超过 GPIO 的绝对最大额定值。
3.2 Keil5 新建 STM32F103 工程的三个关键配置
Keil5 本身只是一个编辑器加编译器的壳,要编译 STM32F103 的代码,必须先装对应的器件支持包,最常见的是 Keil.STM32F1xx_DFP。安装完成后新建工程,在 Select Device 里找到 STMicroelectronics 下的 STM32F103C8 或具体型号。工程建好之后,最影响编译和烧录的三个配置是:
第一,Target 标签里的 ARM Compiler 版本选择。Keil5 默认可能使用 AC5 或 AC6,老工程通常选 AC5 兼容性更好,新工程建议直接用 AC6,编译速度更快,但标准外设库的头文件可能需要对语法做小调整。第二,Output 标签勾选 Create HEX File,这样编译后能直接拿到 hex 文件用于烧录。第三,Debug 标签里选择对应的调试器,常见的是 ST-Link,左侧 Utilities 标签里也要同步选择 ST-Link,否则点击下载时会提示 cannot load flash programming algorithm。
# 烧录时常遇到的错误对照 Error: Flash Download failed - "Cortex-M3" 表示算法文件或接线有问题 Error: Target not connected 表示调试器未识别到芯片 No ST-LINK detected 表示ST-Link驱动未装或USB未识别3.3 标准外设库 v3.50 的文件组织方式
虽然 STM32CubeMX 生成的 HAL 库已经是当前主流,但很多现成工程和例程仍然基于标准外设库 v3.50。这个版本的文件结构分成三层:CMSIS 核心层、标准外设驱动层、用户应用层。CMSIS 层提供寄存器地址定义和启动文件,标准外设驱动层提供stm32f10x_gpio.c、stm32f10x_tim.c等外设驱动源文件,用户应用层就是你自己写的main.c和中断处理。
把工程文件组织好对后续调试很关键。建议在 Keil5 的工程树里建五个组:Startup 放启动文件,CMSIS 放系统初始化相关文件,StdPeriph_Driver 放外设库源文件,User 放main.c和中断文件,Doc 放说明文档。这样编译时如果报未定义错误,可以快速定位到具体缺了哪个外设的源文件。使用标准外设库时,stm32f10x_conf.h里的外设头文件包含列表也要检查,TIM 和 GPIO 对应的宏必须打开,否则调用TIM_SetCompare1会直接报隐式声明错误。
注意:标准外设库 v3.50 对应的启动文件是
startup_stm32f10x_md.s,中等容量芯片用 md,高容量用 hd。选错启动文件最常见的现象是程序能编译但运行后外设中断不响应。
4. 从 GPIO 到呼吸灯:状态指示与 PWM 的循环调度实现
4.1 用一个结构体统一管理多路 LED 状态
真实产品里不可能只点一个灯,我一般会定义 LED 管理结构体,把引脚、端口、当前电平、期望状态和点亮时间都收进去。这样在主循环里扫描这个结构体数组,就能实现多路 LED 各自独立的状态切换,不互相阻塞。
typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t active_high; // 1表示高电平点亮,0表示低电平点亮 uint8_t current_state; uint32_t last_tick; } LED_Handle_t; LED_Handle_t g_status_led = { .port = GPIOB, .pin = GPIO_Pin_0, .active_high = 0, .current_state = 0, .last_tick = 0 }; void LED_Process(LED_Handle_t *led, uint8_t target_state, uint32_t hold_ms) { if (led->current_state != target_state) { if (millis() - led->last_tick >= hold_ms) { led->current_state = target_state; if ((target_state && led->active_high) || (!target_state && !led->active_high)) { GPIO_SetBits(led->port, led->pin); } else { GPIO_ResetBits(led->port, led->pin); } led->last_tick = millis(); } } }结构体里的active_high字段很关键,它把硬件接线方式从业务逻辑里隔离出来。如果你的板子是高电平点亮,只需把这个字段改成 1,代码逻辑部分完全不用动。hold_ms参数用来做状态保持,比如告警灯需要以 1Hz 频率闪烁,就可以每 500ms 翻转一次目标状态,而不是直接控制引脚的物理电平。
4.2 呼吸灯渐变参数的计算:步长、周期、占空比映射
呼吸灯的效果本质上是一个三角波或正弦波状的占空比序列。在定时器频率确定后,需要决定的是从最暗到最亮要花多少毫秒。一次完整的呼吸周期包含由暗到亮和由亮到暗两个阶段,我一般把单程时间设在 800ms 到 1500ms 之间,太快会像闪烁,太慢会显得迟钝。
以定时器中断频率 1kHz 为例,如果单程时间是 1000ms,那就需要 1000 个占空比档位,正好对应前面配置的TIM_Period = 999。每个中断里把比较值加 1 或减 1,就能实现线性的亮度变化。不过人眼对亮度变化的感知不是线性的,占空比在 5% 以下时亮度跳变很明显,而在 50% 以上时变化几乎不可察觉,因此更常见的做法是使用正弦值映射。
uint16_t breath_step = 0; uint8_t breath_direction = 1; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); uint16_t duty; if (breath_direction) { breath_step += 2; if (breath_step >= 2000) { breath_step = 2000; breath_direction = 0; } } else { breath_step -= 2; if (breath_step == 0) { breath_direction = 1; } } // 将0~2000映射到0~999,通过二次函数改善低亮度区间的视觉跳变 duty = (breath_step * breath_step) / 4000; TIM_SetCompare1(TIM3, duty); } }这个中断服务函数里的映射公式把线性步进变成二次曲线,低亮度区间的步长被压缩,视觉上过渡更均匀。breath_step到达 2000 后回程,占空比峰值约 999,也就是 100% 亮度。breath_step += 2意味着单程约 1 秒,如果希望放慢到 2 秒,改成+= 1即可。注意中断函数里不要做耗时的计算,否则会影响其他中断的响应。
4.3 主循环里协调状态灯和呼吸灯
有了 PWM 中断,呼吸灯不占用主循环时间,主循环只需要轮询状态指示灯的翻转条件。这里有一个很多新手容易犯的错:在主循环里放HAL_Delay或delay_ms来做呼吸衔接,这会把 CPU 彻底卡死。正确的模式是维护一个全局 tick,主循环每次进出只检查时间差。
int main(void) { SystemInit(); LED_GPIO_Init(); PWM_Init(); NVIC_Config(); uint32_t last_blink_time = 0; uint8_t blink_toggle = 0; while (1) { if (millis() - last_blink_time >= 500) { last_blink_time = millis(); blink_toggle = !blink_toggle; LED_Status_Set(blink_toggle); } } }millis()通常在 SysTick 中断里维护,这要求你提前配置 SysTick 并写好中断服务函数。在这种结构下,呼吸灯完全由 TIM3 中断驱动,状态灯由主循环按时间片翻转,两者互不干扰。如果你要扩展到更多的 LED,只需要增加结构体实例和对应的处理逻辑即可。
5. 联调验证:用逻辑分析仪和串口输出确认波形正确
5.1 用逻辑分析仪看 PA6 的 PWM 波形
程序下载到板子后,不要把示波器探头直接接到 LED 引脚就以为是最终波形,LED 引脚上实际测量到的是经过限流电阻后的信号,电压幅度可能偏低。正确的做法是直接测 MCU 的 PA6 引脚,也就是 PWM 输出端口。逻辑分析仪采样率不需要太高,1MHz 足够看 1kHz 的 PWM 波形。
先确认波形频率是否等于配置值。逻辑分析仪软件上直接测量相邻两个上升沿的间隔,应该是 1ms,对应 1kHz。然后观察占空比是否在周期性变化,如果是一段一段地跳变,说明TIM_SetCompare1的调用频率太低或中断被阻塞。如果 PWM 波形完全没有,优先检查 GPIO 是否配置成了复用推挽输出,以及 TIM3 的时钟是否已经使能。
5.2 在 Keil5 中通过串口打印调试信息
PWM 波形用逻辑分析仪看很直观,但有些场景下你还需要知道中断被调用了多少次、当前占空比是多少,这时候串口打印是最快的调试手段。STM32F103 的 USART1 默认引脚是 PA9 和 PA10,波特率设为 115200,用 USB 转 TTL 模块连接即可。
void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); }在中断里加串口打印要非常小心,因为printf的重定向会占用较多 CPU 时间。我的习惯是在 TIM3 中断里只增加一个计数器,主循环每一秒打印一次计数器值,这样既能看到中断的实时性,又不会干扰 PWM 输出的稳定性。
5.3 常见问题排查:编译失败、烧录失败、灯不亮
编译失败最多的场景是标准外设库的版本和 Keil5 的 AC6 编译器不兼容,比如#include "stm32f10x.h"提示找不到文件。常见解决方式是工程头文件路径里添加Library/CMSIS/CM3/DeviceSupport/ST/STM32F10x和标准外设库的inc目录。如果报的是core_cm3.h相关错误,说明 CMSIS 核心头文件路径没加对。
烧录失败时,No Debug Adapter这类提示要先检查调试器是否被识别,再确认 SWD 线序。如果用了 ST-Link 且目标板有 BOOT0 跳线问题,会导致芯片进入 ISP 模式,SWD 连接也可能失败。用标准外设库时烧录算法选择 STM32F10x Med-density Flash,默认的 HD 版本在高密度芯片上可以通用,但反向不行。
灯不亮但程序能跑,先量引脚电压。如果引脚电压固定在高或低电平,说明 GPIO 模式被配置错了,可能是复用模式没有配对正确的定时器或引脚。如果引脚电压始终为 0,可能是时钟没打开。GPIOB 挂在 APB2 总线上,必须调用RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE)才能操作端口寄存器。
6. 进阶:把呼吸灯从 Demo 变成可配置的功能模块
到这里,基础的状态指示灯和呼吸灯已经能跑通,但距离产品可用还有一步。我会把呼吸灯封装成独立模块,对外提供初始化、设置亮度、切换呼吸周期这几个接口,上层业务代码完全不需要关心 PWM 细节。
typedef enum { BREATH_MODE_OFF, BREATH_MODE_SLOW, BREATH_MODE_FAST, BREATH_MODE_STATIC } BreathMode_t; void Breath_SetMode(BreathMode_t mode) { switch (mode) { case BREATH_MODE_OFF: TIM_SetCompare1(TIM3, 0); breath_step = 0; break; case BREATH_MODE_SLOW: breath_direction = 1; breath_increment = 1; break; case BREATH_MODE_FAST: breath_direction = 1; breath_increment = 4; break; case BREATH_MODE_STATIC: TIM_SetCompare1(TIM3, BREATH_STATIC_DUTY); break; } }慢速模式对应 2 秒单程,快速模式对应 500ms 单程。Fast 和 Slow 之间切换时要注意把breath_step复位到 0,否则切换后亮度的起始位置不确定,视觉上会有一个跳变。静态模式不做渐变,直接把占空比设置到指定值,用于需要常亮照明的场景。
如果只使用 STM32F103 片内资源,这个模块已经完整。想进一步降低主循环负担,可以把状态灯的翻转也搬进 SysTick 中断,比如每 10ms 扫描一次所有 LED 的时间戳,这样主循环里连 tick 判断都不需要。实测这样改动后,CPU 占用几乎为零,后续接传感器、跑 Modbus 协议都不会受到点灯逻辑的干扰。
关于cameraiu1的提示,我理解这条索引想表达的是它可能是一个摄像头或显示模组,但从软件角度看,呼吸灯模块和图像采集模块完全可以独立编译和验证。先把 LED 这部分跑稳,再对接 Camera 模块的数据通道和帧同步信号,调 resolve 的时候就不需要同时排查两个模块的问题。
本文还有配套的精品资源,点击获取