拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于STM32F103的状态指示灯与呼吸灯实现:GPIO与PWM全解析

简介面向嵌入式入门者与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模式1CNT 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 的时候就不需要同时排查两个模块的问题。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门