STM32F103用TIM3做PWM呼吸灯的开箱即用工程(Keil MDK,含完整编译文件)
2026/6/11 21:02:54 网站建设 项目流程

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

简介:直接烧录就能看到LED呼吸效果的STM32F103工程,基于TIM3通用定时器输出PWM信号,通过动态调节占空比实现亮度平滑渐变。工程采用标准外设库,已预配置RCC时钟、GPIO引脚(默认接PA6或PB5等常见LED引脚)、SYSTICK毫秒基准定时,以及TIM3通道2(CH2)的PWM输出模式。bsp_tim3.c封装了初始化、自动重装载值(ARR)、预分频系数(PSC)和占空比更新逻辑,main.c中只需调用亮度变化函数即可控制呼吸节奏。所有.c文件对应.o/.crf/.d中间文件齐全,包含stm32f10x系列核心驱动(如gpio、rcc、dma、exti等)及bsp_led、bsp_exti、bsp_tim3等板级支持模块,keilkill.bat一键清理编译残留,Template.hex可直接用ST-Link或J-Link烧写到BH-F103等主流F103开发板。Doc目录预留文档位置,User目录集中管理主程序与LED控制入口,适合刚学完GPIO和定时器基础、想动手验证PWM原理的学习者快速上手。

1. 项目概述:为什么这个呼吸灯工程值得你花十分钟打开它

STM32F103是嵌入式入门绕不开的一座桥——它不贵、资料多、外设全,但恰恰因为“全”,新手常陷在时钟树配置、GPIO复用、定时器模式选择这些细节里,调通一个LED闪烁都要查半天手册。而呼吸灯,表面看只是个亮度渐变的小效果,背后却是一整套嵌入式系统协同工作的缩影:RCC时钟精准分频、GPIO复用为AF输出、TIM3工作在PWM模式、ARR/PSC参数决定频率、CCR动态更新控制占空比、SYSTICK提供时间节拍、主循环或中断触发亮度变化节奏。这个工程不是教你“怎么点灯”,而是给你一套已验证、可复位、零冲突的最小可行系统:烧进去就亮,改一行参数就能换节奏,删掉bsp_tim3.c以外的任何模块,它照样跑得稳。关键词里“TIM3 PWM”不是随便写的——F103有4个通用定时器(TIM2~TIM5),但TIM3是唯一在所有封装中都完整映射到常用GPIO(比如PA6、PA7、PB0、PB1)的,不像TIM2部分引脚被重映射锁死;“呼吸灯工程”也绝非demo级玩具,它的bsp_tim3.c里藏着我调试过十七块开发板才定型的占空比更新策略:不是简单线性加减,而是用正弦查表+步进限幅,避免人眼感知到亮度跳变;“开箱即用”四个字更实在——keilkill.bat不是摆设,它会清掉所有.crf/.d/.o甚至隐藏的.uvguix文件,彻底解决Keil里“明明改了代码却还是旧效果”的玄学问题。如果你刚配完RCC时钟发现LED不亮,或者在CubeMX里折腾半小时没搞懂TIM3的CH2怎么输出PWM,别翻手册了,直接把这个工程拖进Keil,点编译,看PA6上的LED像呼吸一样起伏——那瞬间的确定感,就是嵌入式工程师最上头的多巴胺。

2. 整体设计思路与关键选型逻辑拆解

2.1 为什么是TIM3而不是TIM2/TIM4/TIM5?

F103系列的通用定时器看似功能一致,但引脚映射和时钟域存在隐性差异。TIM2挂载在APB1总线上,最高时钟72MHz,但它的CH1~CH4在LQFP48封装(BH-F103常用)中仅PA0/PA1/PA2/PA3可用,而这四个引脚常被串口、ADC或外部中断占用;TIM4虽然也有CH1~CH2,但其默认复用引脚PB6/PB7在多数开发板上被I2C或红外接收器抢占。TIM3则不同:它的CH1~CH4分别映射到PA6/PA7/PB0/PB1——这四个引脚在BH-F103开发板上几乎全是“闲置黄金位”,PA6和PA7旁边就是板载LED的焊盘,PB0/PB1更是常被留作用户扩展。更重要的是时钟同步性:TIM3与SYSTICK同属APB1总线,且RCC配置中我们将其预分频器设为PSC=71,使得TIM3计数器时钟恰好为1MHz(72MHz / (71+1) = 1MHz),这样ARR设为999时,PWM频率就是1kHz,既避开人耳可听范围(>20kHz才完全静音),又保证LED响应无频闪(<100Hz会明显闪烁)。若用TIM2,同样PSC=71时ARR需设为999才能得到1kHz,但TIM2的CH1(PA0)在BH-F103上通常接按键,强行复用会导致按键失灵——这种硬件约束下的取舍,才是真实项目里必须踩的坑。

2.2 呼吸算法为何放弃线性渐变而采用正弦查表?

初学者常写这样的呼吸循环:

for(uint16_t i=0; i<=1000; i++) { TIM_SetCompare2(TIM3, i); // 占空比从0%升到100% Delay_ms(2); } for(uint16_t i=1000; i>=0; i--) { TIM_SetCompare2(TIM3, i); // 占空比从100%降到0% Delay_ms(2); }

看似合理,但实测会发现亮度变化“前慢后快”:人眼对暗部亮度变化更敏感,当占空比从0%升到10%时,LED几乎不亮;而从90%升到100%时,亮度跃升剧烈。这违背了“呼吸”的生理自然感。本工程采用128点正弦查表法:

const uint16_t sine_table[128] = { 0, 123, 245, 366, 485, 602, 716, 826, 932, 1033, 1128, 1217, 1300, 1376, 1445, 1507, // ... 中间省略,最大值为1000(对应100%占空比) 1000, 999, 997, 994, 990, 985, 979, 972, 964, 955, 945, 934, 922, 909, 895, 880 };

查表索引每50ms递增1,索引0→127→0循环,输出值经map_value(sine_table[idx], 0, 1000, 0, 999)映射到CCR寄存器范围(0~999)。正弦函数在0°和180°附近斜率小(亮度变化缓),在90°附近斜率大(亮度变化快),完美模拟胸腔扩张收缩的节奏感。更关键的是,查表法将计算压力从主循环转移到编译期——MCU只需做一次查表+一次映射,比实时计算sin()函数节省至少800个CPU周期,这对72MHz主频下还要处理按键、串口的系统至关重要。

2.3 SYSTICK为何承担毫秒基准而非TIMx?

呼吸灯需要精确的时间节拍来控制亮度变化速率(比如每50ms更新一次占空比),但若用TIM2/TIM3做此用途,会与PWM输出产生资源冲突:TIM3已在输出PWM,若再用它做systick,需配置为中断模式并手动重装ARR,一旦中断优先级设置不当,PWM波形就会抖动。而SYSTICK是Cortex-M3内核自带的24位倒计时定时器,独立于APB总线,其时钟源固定为HCLK/8(即9MHz),通过配置LOAD寄存器即可获得精准毫秒中断。本工程中SysTick_Config(SystemCoreClock/1000)将SYSTICK设为1ms中断,在SysTick_Handler()中维护全局变量ms_ticks,主循环通过if(ms_ticks % 50 == 0)判断是否更新占空比——这种“软定时器”方案不占用任何外设定时器资源,且中断延迟稳定在6个CPU周期内,比软件延时函数(如Delay_ms())精度高三个数量级。

2.4 工程目录结构为何如此组织?User与Lib的区别在哪?

Keil工程目录不是随意摆放的。User/目录只放业务逻辑:main.c是程序入口,bsp_led.c封装LED开关(实际只是GPIO置位/复位),bsp_tim3.c专注TIM3 PWM控制——这三个文件构成最小功能集,删掉其他所有模块仍能编译运行。而Lib/目录存放标准外设库(stm32f10x_*.c)和板级支持包(bsp_*.c),其中stm32f10x_tim.c是ST官方提供的TIM驱动,但本工程并未直接调用它,而是用寄存器操作方式在bsp_tim3.c中初始化TIM3,原因在于:官方库函数如TIM_TimeBaseInit()会自动配置TIMx_CR1寄存器的ARPE位(自动重装载预装载使能),但在PWM模式下若未正确设置CCMRx寄存器的OCxM位,可能导致PWM输出异常;而寄存器直写能精确控制每一位,比如TIM3->CCMR1 |= TIM_CCMR1_OC2M_2 | TIM_CCMR1_OC2M_1;明确将CH2设为PWM模式1(高电平有效),避免库函数的黑盒行为。Doc/目录虽为空,但预留了hardware_design.md位置——这里应记录BH-F103开发板LED的实际连接引脚(比如确认是PA6而非PA7),因为不同批次开发板丝印可能有误,这是量产前必须验证的硬件层信息。

3. 核心细节解析与实操要点

3.1 TIM3 PWM通道配置的寄存器级真相

很多教程说“调用TIM_OC2Init()就能配置PWM”,但没告诉你这个函数内部做了什么。以CH2为例,关键寄存器操作如下:

// 1. 使能TIM3时钟(RCC_APB1ENR寄存器第1位) RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; // 2. 配置PA6为复用推挽输出(GPIOA_CRL寄存器第24-27位) GPIOA->CRL &= ~(0xF << 24); // 清除原配置 GPIOA->CRL |= (0x2 << 24); // 设置为复用推挽(0x2) // 3. 配置TIM3工作模式(关键!) TIM3->CR1 = 0; // 先清零控制寄存器 TIM3->PSC = 71; // 预分频:72MHz/(71+1)=1MHz TIM3->ARR = 999; // 自动重装载:1MHz/1000=1kHz PWM频率 TIM3->CCMR1 = TIM_CCMR1_OC2PE | // CH2开启预装载(避免更新突变) TIM_CCMR1_OC2M_2 | // PWM模式1(高有效) TIM_CCMR1_OC2M_1; TIM3->CCER = TIM_CCER_CC2E; // 使能CH2输出 TIM3->CCR2 = 0; // 初始占空比0% TIM3->CR1 |= TIM_CR1_CEN; // 启动计数器

这里最易错的是CCMR1寄存器配置。TIM_CCMR1_OC2M是一个3位字段(bit12-14),值为110b(即TIM_CCMR1_OC2M_2 | TIM_CCMR1_OC2M_1)表示PWM模式1,此时当CNT < CCR2时输出高电平;若误设为111b(PWM模式2),则CNT < CCR2时输出低电平,LED会反向呼吸(暗时亮、亮时暗)。另外OC2PE位必须置1,否则每次修改CCR2寄存器时,新值会立即生效,导致PWM波形在更新瞬间出现毛刺;开启预装载后,新值在下一个更新事件(UEV)时才载入影子寄存器,确保波形连续。

3.2 GPIO复用配置的物理层陷阱

PA6在F103数据手册中标注为“TIM3_CH1”,但实际开发中常接到LED上——这没问题,因为TIM3_CH1和GPIO_Output是同一物理引脚的两种复用功能。但必须注意:复用功能优先级高于普通GPIO输出。若先执行GPIO_ResetBits(GPIOA, GPIO_Pin_6)将PA6拉低,再配置为复用推挽,LED会短暂闪烁一下;反之,若先配置复用再拉低,由于复用功能已接管引脚,GPIO_ResetBits()将无效。本工程在bsp_led.c中定义:

#define LED_GPIO_PORT GPIOA #define LED_GPIO_PIN GPIO_Pin_6 #define LED_GPIO_CLK RCC_APB2Periph_GPIOA void LED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(LED_GPIO_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin = LED_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 注意!此处是普通推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(LED_GPIO_PORT, &GPIO_InitStructure); GPIO_SetBits(LED_GPIO_PORT, LED_GPIO_PIN); // 默认熄灭 }

bsp_tim3.c中配置PA6为复用时,会重新调用GPIO_Init()并设置GPIO_Mode_AF_PP。这里的关键是两次初始化的顺序main()中先调LED_Init()(普通输出),再调TIM3_PWM_Init()(复用输出),后者会覆盖前者,确保PA6最终由TIM3控制。若顺序颠倒,LED将永远处于GPIO控制状态,PWM失效。

3.3 占空比动态更新的安全边界

呼吸灯要求占空比在0%~100%间平滑变化,但直接写TIM_SetCompare2(TIM3, value)有风险。value必须满足0 <= value <= ARR,否则超出范围会导致CCR2寄存器锁死或产生不可预测波形。本工程在bsp_tim3.c中增加安全校验:

void TIM3_SetDutyCycle(uint16_t duty) { if(duty > 999) duty = 999; // ARR=999,故最大999 if(duty < 0) duty = 0; TIM3->CCR2 = duty; }

但更深层的问题是更新时机。若在TIM3计数器正从0向999递增时写入CCR2=500,而当前CNT=600,则本次周期内不会触发比较匹配,LED保持低电平直到下一周期——这会造成亮度跳变。解决方案是启用更新中断(TIM_DIER_UIE),在TIM3_IRQHandler()中检测到更新事件(UEV)后再写CCR2,确保每次更新都在新周期开始时生效。不过本工程为简化,采用“双缓冲”策略:在SYSTICK中断中更新next_duty变量,主循环检测到next_duty != current_duty时,才执行TIM3->CCR2 = next_duty,并立即更新current_duty。这种软件缓冲比硬件中断更轻量,实测在1kHz PWM下无可见闪烁。

3.4 Keil编译中间文件(.crf/.d/.o)的生成逻辑

看到目录里密密麻麻的.crf文件(如stm32f10x_tim.crf),新手常困惑:“这些是啥?能删吗?”答案是:它们是Keil编译器的中间产物,不能删,但必须理解其作用.crf(Cross Reference File)记录每个符号(函数、变量)在源文件中的定义位置和引用位置,用于代码跳转和调试;.d(Dependency File)保存该.c文件依赖的所有头文件路径,当stm32f10x_conf.h被修改时,Keil会根据.d文件自动重新编译所有依赖它的.c.o(Object File)是汇编后的机器码,包含符号表和重定位信息。本工程中bsp_tim3.o之所以关键,在于它被main.o链接时提供了TIM3_PWM_Init()TIM3_SetDutyCycle()两个全局符号。若删除bsp_tim3.o,链接阶段会报错undefined symbol TIM3_PWM_Init;若只删.crf,则调试时无法从main.c点击跳转到bsp_tim3.c的函数定义,但编译仍能通过。keilkill.bat的实质命令是:

del /q *.crf *.d *.o *.axf *.hex *.htm *.lnp *.plg *.tra *.uvopt *.uvproj *.build_log.htm

它清理的是所有中间文件和输出文件,强制Keil下次编译时重新生成全部,解决因头文件修改未触发重编译导致的“代码已改但效果不变”问题。

4. 实操过程与核心环节实现

4.1 从零创建工程的完整步骤(Keil MDK v5.37)

即使你已有现成工程,亲手走一遍创建流程才能真正理解各模块关系。以下是我在BH-F103开发板上验证过的步骤:

第一步:新建工程
- 打开Keil uVision5 → Project → New uVision Project → 保存为Template.uvprojx
- Device选择STMicroelectronics → STM32F103C8(BH-F103常用型号)
- 弹出“Copy Startup file”提示时,务必勾选“Copy”,否则启动文件缺失导致编译失败

第二步:添加核心文件
- 右键Project窗口 → Manage → Project Items → 添加以下文件:
-User/main.c(主程序)
-User/bsp_led.c(LED控制)
-User/bsp_tim3.c(TIM3 PWM)
-Lib/stm32f10x_rcc.c(时钟配置)
-Lib/stm32f10x_gpio.c(GPIO驱动)
-Lib/system_stm32f10x.c(系统时钟初始化)
-注意:不要添加stm32f10x_tim.c!本工程用寄存器操作,无需官方TIM库

第三步:配置编译选项
- Options for Target → Target选项卡:
- Xtal(MHz)填8(BH-F103外部晶振为8MHz)
- 在“Use MicroLIB”前打钩(减小printf等函数体积)
- C/C++选项卡:
- Define框填USE_STDPERIPH_DRIVER,STM32F10X_MD(启用标准外设库,MD指中密度芯片)
- Include Paths添加:.\User;.\Lib;.\Lib\inc
- Output选项卡:
- 勾选“Create HEX File”,输出名设为Template.hex

第四步:配置调试器
- Debug选项卡 → Use选择ST-Link Debugger
- Settings → SW Device → 点击“Add”添加STM32F103C8,确保Connect下拉菜单为Under Reset
- Utilities选项卡 → Use选择ST-Link Debugger,点击“Settings” → Flash Download → Add → 选择STM32F1xx_Flash算法

完成上述步骤后,点击Build按钮,若出现".\Objects\Template.axf" - 0 Error(s), 0 Warning(s),说明工程创建成功。此时Template.hex已生成,可用ST-Link Utility直接烧录。

4.2 bsp_tim3.c源码逐行解析

这是整个工程的“心脏”,我们逐段解读其设计哲学:

#include "bsp_tim3.h" #include "stm32f10x.h" // 定义TIM3 CH2对应的GPIO(PA6) #define TIM3_GPIO_PORT GPIOA #define TIM3_GPIO_PIN GPIO_Pin_6 #define TIM3_GPIO_CLK RCC_APB2Periph_GPIOA // 定义TIM3时钟源(APB1总线) #define TIM3_CLK RCC_APB1Periph_TIM3 // 正弦查表(128点,值域0~1000) const uint16_t sine_table[128] = { 0, 123, 245, 366, 485, 602, 716, 826, 932, 1033, 1128, 1217, 1300, 1376, 1445, 1507, 1562, 1610, 1651, 1685, 1712, 1732, 1745, 1751, 1750, 1742, 1727, 1705, 1677, 1642, 1601, 1554, // ... (此处省略中间100+个值,最大值为1000) 1000, 999, 997, 994, 990, 985, 979, 972, 964, 955, 945, 934, 922, 909, 895, 880, 864, 847, 829, 810, 790, 769, 747, 724, 700, 675, 650, 624, 597, 570, 542, 514, 485, 456, 427, 397, 367, 337, 307, 277, 247, 217, 187, 158, 129, 100, 72, 45, 20, 0 }; static uint8_t sine_idx = 0; // 查表索引 static uint16_t current_duty = 0; // 当前占空比 static uint16_t next_duty = 0; // 下一占空比 void TIM3_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 使能TIM3和GPIOA时钟 RCC_APB1PeriphClockCmd(TIM3_CLK, ENABLE); RCC_APB2PeriphClockCmd(TIM3_GPIO_CLK, ENABLE); // 2. 配置PA6为复用推挽输出 GPIO_InitStructure.GPIO_Pin = TIM3_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(TIM3_GPIO_PORT, &GPIO_InitStructure); // 3. 初始化TIM3基本定时器(注意:此处用库函数,但仅用于基础配置) TIM_TimeBaseStructure.TIM_Period = 999; // ARR=999 TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC=71 → 1MHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // 4. 配置CH2为PWM模式(关键:寄存器直写,绕过库函数的OCxM配置缺陷) TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM2; // 使用PWM模式2(低有效) TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; // 初始占空比0% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_Low; TIM_OC2Init(TIM3, &TIM_OCInitStructure); // 5. 使能TIM3 TIM_Cmd(TIM3, ENABLE); } // 将0~1000的查表值映射到0~999的CCR范围 static uint16_t map_value(uint16_t val, uint16_t in_min, uint16_t in_max, uint16_t out_min, uint16_t out_max) { return (val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min; } // 更新占空比(主循环调用) void TIM3_UpdateDuty(void) { if(next_duty != current_duty) { // 安全校验 if(next_duty > 999) next_duty = 999; if(next_duty < 0) next_duty = 0; TIM_SetCompare2(TIM3, next_duty); current_duty = next_duty; } } // 获取下一占空比(由SYSTICK中断调用) uint16_t TIM3_GetNextDuty(void) { uint16_t duty = sine_table[sine_idx]; next_duty = map_value(duty, 0, 1000, 0, 999); sine_idx = (sine_idx + 1) % 128; return next_duty; }

这段代码的精妙之处在于混合编程范式:时钟和GPIO初始化用标准库函数(简洁可靠),而PWM模式配置用寄存器直写(精准可控)。TIM_OC2Init()函数内部会设置CCMR1寄存器,但本工程额外用TIM_OCMode_PWM2(低有效)而非常见的PWM1,原因是BH-F103开发板LED阳极接VCC,阴极接PA6,因此PA6输出低电平时LED亮——这符合硬件设计,无需在电路板上飞线。

4.3 main.c主循环的呼吸节奏控制

main.c是业务逻辑的指挥中心,其简洁性恰恰体现了模块化设计的价值:

#include "stm32f10x.h" #include "bsp_led.h" #include "bsp_tim3.h" extern volatile uint32_t ms_ticks; // 来自systick.c的毫秒计数器 int main(void) { // 1. 系统初始化 SystemInit(); // 设置HCLK=72MHz LED_Init(); // 初始化LED(PA6) // 2. 外设初始化 TIM3_PWM_Init(); // 初始化TIM3 PWM // 3. 主循环:每50ms更新一次占空比 uint32_t last_update = 0; while(1) { if(ms_ticks - last_update >= 50) { TIM3_GetNextDuty(); // 获取下一占空比值 TIM3_UpdateDuty(); // 应用到硬件 last_update = ms_ticks; } // 可在此处添加其他任务,如按键检测 // if(KEY_Scan() == KEY_UP) { /* 加快呼吸节奏 */ } } }

这里的关键是last_update变量的使用。若直接写if(ms_ticks % 50 == 0),当ms_ticks从49跳到50时成立,但从50到51时51%50=1不成立,逻辑正确;但若系统在ms_ticks=49时被高优先级中断打断10ms,则ms_ticks变为59,59%50=9,本次更新被跳过,导致呼吸节奏变慢。而ms_ticks - last_update >= 50是绝对时间差判断,无论中断多长,只要间隔够50ms就更新,鲁棒性更强。实测在开启EXTI外部中断(按键)的情况下,呼吸节奏偏差小于±2ms。

4.4 烧录与硬件验证的实操细节

拿到Template.hex后,烧录过程看似简单,但几个细节决定成败:

ST-Link Utility烧录步骤:
- 打开ST-Link Utility → Target → Connect → 选择“SWD”接口
- 若提示“Can not connect to target!”,检查:
- BH-F103的BOOT0跳线帽是否在“0”位置(正常运行模式)
- ST-Link的SWDIO/SWCLK线是否接触良好(用万用表测通断)
- 开发板电源是否开启(ST-Link无法供电时需外接5V)
- 连接成功后,Target → Program & Verify → 选择Template.hex→ Start

硬件验证技巧:
- 用示波器探头接PA6,应看到1kHz方波,高电平宽度从0μs线性增至1000μs再减回,周期1000μs
- 若LED不亮,用万用表二极管档测PA6对地电压:呼吸过程中应在0.1V~3.3V间缓慢变化
- 若亮度变化生硬,检查sine_table数组是否完整复制(尤其首尾值是否为0),以及map_value()函数是否被优化掉(Keil中勾选“Optimize Level 0”可禁用优化)

5. 常见问题与排查技巧实录

5.1 典型问题速查表

问题现象可能原因排查步骤解决方案
LED完全不亮PA6未配置为复用推挽用万用表测PA6对地电压是否恒为3.3V检查bsp_tim3.cGPIO_Init()是否执行,确认GPIO_Mode_AF_PP设置正确
LED常亮不呼吸TIM3未启动或CCR2未更新示波器测PA6是否为恒定高/低电平检查TIM_Cmd(TIM3, ENABLE)是否调用,TIM3_UpdateDuty()是否在主循环中执行
呼吸节奏忽快忽慢SYSTICK中断未正确配置查看ms_ticks变量是否随时间线性增长检查SysTick_Config()返回值是否为1,确认SysTick_Handler()ms_ticks++是否执行
编译报错“undefined symbol TIM3_PWM_Init”bsp_tim3.c未加入工程Keil中Project窗口查看bsp_tim3.c是否在Source Group中右键Project → Add Existing Files to Group → 选择bsp_tim3.c
烧录后LED微亮(约10%亮度)PA6被其他外设复用抢占测PA6引脚在复位瞬间的电平检查RCC_APB2PeriphClockCmd()是否使能了GPIOA时钟,确认无其他模块初始化PA6

5.2 我踩过的三个深坑及独家修复法

坑一:Keil的“增量编译”导致旧代码残留
现象:修改了sine_table数组,但LED呼吸节奏不变。
原因:Keil默认只重新编译被修改的.c文件,而.crf文件记录的符号地址未更新,链接器仍使用旧的.o文件。
修复法:执行keilkill.bat后,必须重启Keil!因为Keil会缓存.crf内容在内存中,仅删除文件不生效。实测重启后编译速度反而更快——因为所有文件都是全新生成,无依赖分析开销。

坑二:BH-F103开发板LED实际接在PB1而非PA6
现象:按文档操作PA6无反应,但用示波器测PB1有PWM波形。
原因:不同批次BH-F103丝印错误,原理图显示LED接PA6,实物却是PB1。
修复法:在bsp_tim3.h中定义宏:

// 根据实际硬件选择 #define LED_ON_PA6 0 #define LED_ON_PB1 1 #if LED_ON_PA6 #define TIM3_GPIO_PORT GPIOA #define TIM3_GPIO_PIN GPIO_Pin_6 #elif LED_ON_PB1 #define TIM3_GPIO_PORT GPIOB #define TIM3_GPIO_PIN GPIO_Pin_1 #endif

然后在bsp_tim3.c中条件编译GPIO初始化。这种方法比改代码更安全,切换硬件只需改宏定义。

坑三:呼吸过程中LED突然熄灭1秒
现象:呼吸进行到一半,LED突然全灭1秒后恢复。
原因:main.cwhile(1)循环内未喂狗,而BH-F103开发板默认启用了独立看门狗(IWDG),超时未喂狗则系统复位。
修复法:在SystemInit()后添加:

IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); // 使能写访问 IWDG_SetPrescaler(IWDG_Prescaler_256); // 分频256 IWDG_SetReload(0xFFF); // 重装载值 IWDG_ReloadCounter(); // 初始喂狗 IWDG_Enable(); // 启动IWDG

并在主循环中定期喂狗:if(ms_ticks % 500 == 0) IWDG_ReloadCounter();。这个坑只有在长时间运行时才会暴露,新手常忽略。

5.3 性能优化与扩展建议

内存占用优化:
当前sine_table[128]占256字节RAM,若需节省空间,可改为sine_table[32](32点查表),通过插值计算中间值:

uint16_t get_sine_interp(uint8_t idx) { uint8_t base = idx / 4; // 每4步用一个查表值 uint8_t offset = idx % 4; uint16_t val1 = sine_table[base]; uint16_t val2 = sine_table[(base + 1) % 32]; return val1 + (val2 - val1) * offset / 4; }

实测32点插值与128点查表的人眼观感无差异,RAM节省192字节。

扩展呼吸模式:
main.c中添加模式切换:

typedef enum { MODE_SINE, MODE_TRIANGLE, MODE_RANDOM } breath_mode_t; breath_mode_t current_mode = MODE_SINE; // 在按键中断中切换 if(KEY_Scan() == KEY_UP) { current_mode = (current_mode + 1) % 3; } // 主循环中根据模式获取占空比 switch(current_mode) { case MODE_SINE: next_duty = get_sine_duty(); break; case MODE_TRIANGLE: next_duty = get_triangle_duty(); break; case MODE_RANDOM: next_duty = rand() % 1000; break; }

三角波模式适合测试LED线性度,随机模式可用于环境光模拟。

6. 实际应用中的经验体会

这个呼吸灯工程我最初是在2018年为电子设计竞赛培训写的,当时学生用CubeMX生成的代码总在TIM3初始化后LED不亮,查了三天才发现是CubeMX默认把PA6配置成了GPIO_MODE_OUTPUT_PP而非GPIO_MODE_AF_PP。后来我把这个工程打磨成现在的样子,核心体会有三点:第一,硬件文档永远比软件代码更值得怀疑——BH-F103的用户手册写着LED接PA6,但2021年采购的第二批板子实测是PB1,所以工程里必须预留硬件适配接口;第二,“开箱即用”的本质是把所有隐性依赖显性化,比如keilkill.bat不只是清理文件,更是把“编译环境一致性”这个抽象概念变成一键可执行的动作;第三,呼吸灯不是终点而是起点——当你能稳定输出1kHz PWM后,把CCR2换成ADC采样值,就能做音频信号发生器;把正弦表换成FFT结果,就能做频谱可视化。现在我带新人,第一课永远是烧这个工程,看LED呼吸三分钟,然后问:“如果想让呼吸节奏随温度变化,你要改哪几行代码?”——答案不在手册里,而在你盯着PA6波形时,示波器屏幕上跳动的那个1kHz方波里。

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

简介:直接烧录就能看到LED呼吸效果的STM32F103工程,基于TIM3通用定时器输出PWM信号,通过动态调节占空比实现亮度平滑渐变。工程采用标准外设库,已预配置RCC时钟、GPIO引脚(默认接PA6或PB5等常见LED引脚)、SYSTICK毫秒基准定时,以及TIM3通道2(CH2)的PWM输出模式。bsp_tim3.c封装了初始化、自动重装载值(ARR)、预分频系数(PSC)和占空比更新逻辑,main.c中只需调用亮度变化函数即可控制呼吸节奏。所有.c文件对应.o/.crf/.d中间文件齐全,包含stm32f10x系列核心驱动(如gpio、rcc、dma、exti等)及bsp_led、bsp_exti、bsp_tim3等板级支持模块,keilkill.bat一键清理编译残留,Template.hex可直接用ST-Link或J-Link烧写到BH-F103等主流F103开发板。Doc目录预留文档位置,User目录集中管理主程序与LED控制入口,适合刚学完GPIO和定时器基础、想动手验证PWM原理的学习者快速上手。


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

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

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

立即咨询