1. 为什么呼吸灯是STM32入门的分水岭呼吸灯这个实验在STM32学习路线里属于那种看起来简单、做起来卡壳的典型项目。你如果只是让一个LED亮起来GPIO配置推挽输出、拉高拉低就完事了五分钟都用不了。但呼吸灯不一样它要求LED的亮度连续、平滑地变化从暗到亮再到暗循环往复像人在呼吸一样。这就不是简单的开关控制能搞定的了必须用到**PWM脉冲宽度调制**技术。PWM这个东西说白了就是通过快速开关来控制平均功率。你想象一下如果一秒钟内让LED亮500次、灭500次因为人眼的视觉暂留效应你看到的就是一个持续发光的LED。如果亮的时间占比从0%慢慢增加到100%再慢慢降回去你看到的就是一个呼吸灯效果。这个亮的时间占总周期的比例就是占空比。STM32的定时器天生就是干这个的。每个通用定时器都有多个通道每个通道可以独立输出PWM波形。你只需要配置好定时器的时钟源、预分频器、自动重装载值再设置好比较值硬件就会自动在对应的GPIO引脚上输出PWM波。CPU完全不用管该干嘛干嘛去。这就是硬件PWM的优势也是为什么学STM32一定要掌握定时器的原因。这个实验适合谁如果你刚学完GPIO操作知道怎么点亮LED但对定时器、中断、PWM这些概念还比较模糊那这个实验就是你的最佳切入点。它不需要你懂复杂的通信协议也不需要外接一堆模块只需要一块STM32开发板和一个LED大部分开发板板载的就行就能把定时器PWM输出的整个流程跑通。而且这个实验的代码框架后面你学电机调速、舵机控制、背光调节全都是同一套逻辑只是频率和占空比范围不同而已。我见过太多人卡在CubeMX的配置界面上不知道哪个参数该填多少为什么这么填。也见过有人代码写完了LED就是不亮或者亮是亮了但不会呼吸。这些问题其实都有明确的排查路径后面我会一个一个拆开讲。先把整体思路理清楚再动手配置比上来就瞎点要快得多。2. 定时器PWM输出的核心原理拆解2.1 定时器是怎么产生PWM波的STM32的通用定时器比如TIM2、TIM3、TIM4本质上是一个向上/向下计数的计数器。它从0开始每个时钟周期加1加到某个值自动重装载寄存器ARR的值后归零然后重新开始。这个循环的时间就是一个PWM周期。在计数的过程中定时器会不断比较当前计数值CNT和比较寄存器CCR的值。以PWM模式1为例当CNT小于CCR时输出高电平当CNT大于等于CCR时输出低电平。所以CCR的值决定了高电平的时间长度也就是占空比。占空比的计算公式很简单占空比 CCR / (ARR 1) × 100%比如ARR设为999CCR设为500那占空比就是500/1000 50%。如果CCR从0慢慢增加到999占空比就从0%慢慢变到100%LED就从暗慢慢变亮。反过来就是变暗。这就是呼吸灯的核心逻辑。PWM的频率则由定时器时钟和ARR共同决定PWM频率 定时器时钟 / ((PSC 1) × (ARR 1))其中PSC是预分频器的值。假设定时器时钟是72MHzPSC设为71那定时器实际计数频率就是72MHz / 72 1MHz。ARR设为999那PWM频率就是1MHz / 1000 1kHz。1kHz对于LED呼吸灯来说完全够用人眼看不到闪烁。2.2 为什么选通用定时器而不是基本定时器STM32的定时器分三类高级定时器TIM1、TIM8、通用定时器TIM2~TIM5、基本定时器TIM6、TIM7。基本定时器只能用来做定时中断没有PWM输出功能。高级定时器功能最全但配置也最复杂涉及到刹车输入、死区控制这些电机控制才用的东西。通用定时器刚好够用有4个独立通道每个通道都能输出PWM配置也相对简单。对于呼吸灯这个实验随便选一个通用定时器就行。我一般习惯用TIM3因为它的通道1对应PA6通道2对应PA7通道3对应PB0通道4对应PB1这些引脚在大部分开发板上都引出来了接线方便。如果你用的是STM32F103C8T6最小系统板PA6和PA7就在板子边缘插杜邦线很顺手。2.3 HAL库和标准库在PWM配置上的区别江协科技的教程早期用的是标准库后来也出了HAL库版本。标准库配置PWM需要手动写一堆寄存器操作比如设置ARR、PSC、CCMR1、CCER这些。HAL库把这些都封装成了结构体你只需要填好参数调用HAL_TIM_PWM_Init()和HAL_TIM_PWM_Start()就行。但HAL库有个坑很多人不知道。HAL_TIM_PWM_Start()只是启动了PWM输出但如果你用的是高级定时器还需要额外调用__HAL_TIM_MOE_ENABLE()来使能主输出。通用定时器不需要这一步但如果你哪天换成TIM1做PWM忘了这个宏波形就是出不来。这个坑我后面会详细说。另外HAL库修改占空比用的是__HAL_TIM_SET_COMPARE()宏直接写CCR寄存器。标准库是TIM_SetCompare1()。功能一样只是名字不同。用HAL库的好处是代码可移植性强换一个STM32系列底层寄存器不一样了但HAL库的API基本不变。3. CubeMX配置定时器PWM的完整流程3.1 新建工程和时钟配置打开CubeMX新建工程选择你的芯片型号。如果你用的是江协科技推荐的STM32F103C8T6就在搜索框里输入STM32F103C8选中后点Start Project。第一步先配时钟。在Pinout Configuration界面找到System Core里的RCC把High Speed Clock (HSE)设为Crystal/Ceramic Resonator。然后切换到Clock Configuration标签页把HSE的输入频率填成8MHz大部分开发板的晶振频率PLL Source选HSEPLL Mul选9倍频这样系统时钟就是72MHz。APB1 Prescaler设为2所以APB1总线时钟是36MHz但定时器时钟会倍频到72MHz。这个细节很重要后面算PWM频率的时候要用到。注意如果你用的是STM32F103C8T6最小系统板板子上的晶振通常是8MHz。但有些廉价板子用的是12MHz或者没有晶振这时候你需要根据实际情况调整。没有晶振的话只能用内部RC振荡器HSI但精度差一些PWM频率会有偏差。3.2 配置TIM3的PWM通道在左侧列表里找到Timers展开后点击TIM3。在TIM3的配置界面里把Clock Source设为Internal Clock。然后在下方的Channel1里选择PWM Generation CH1。这时候你会看到右侧的引脚图上PA6被自动分配了变成绿色。接下来配置参数。Prescaler填71Counter Period填999Counter Mode选Upauto-reload preload选Enable。这四个参数决定了PWM的频率和分辨率。我来解释一下为什么这么填。系统时钟72MHz经过APB1预分频器后定时器的时钟还是72MHz因为APB1预分频系数不为1时定时器时钟会倍频。Prescaler设为71意味着定时器每72个时钟周期计一次数所以计数频率是72MHz / 72 1MHz。Counter Period设为999意味着计数器从0数到999一共1000个计数所以PWM周期是1000 / 1MHz 1ms频率就是1kHz。1kHz这个频率对LED呼吸灯来说很合适。频率太低比如50Hz人眼会看到闪烁。频率太高比如100kHz虽然人眼看不到闪烁但LED的驱动电路可能响应不过来而且占空比的调节精度会下降。1kHz到10kHz之间是比较舒服的范围。Pulse这个参数是初始的CCR值也就是初始占空比。先填0后面在代码里动态修改。CH Polarity选High意思是高电平有效。如果你接的LED是低电平点亮比如LED阳极接VCC阴极接GPIO那就选Low。这个要根据你的硬件电路来定选错了LED的亮灭逻辑就反了。3.3 生成代码和工程结构配置完TIM3后检查一下Project Manager里的设置。Toolchain/IDE选MDK-ARM如果你用Keil或者STM32CubeIDE。Code Generator里勾选Generate peripheral initialization as a pair of .c/.h files per peripheral这样每个外设的初始化代码会单独放在一个文件里工程结构更清晰。点击GENERATE CODECubeMX会自动生成完整的工程。打开工程后你会看到main.c里已经自动调用了MX_TIM3_Init()在tim.c里生成了TIM3的初始化代码。但PWM输出还没有启动需要你在main函数里手动调用HAL_TIM_PWM_Start()。4. 呼吸灯代码实现与占空比调节4.1 启动PWM输出的正确姿势在main.c的main函数里找到MX_TIM3_Init()调用之后的位置加上这两行HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 0);第一行启动TIM3的通道1输出PWM。第二行把初始占空比设为0也就是LED一开始是灭的。如果你用的是高级定时器TIM1或TIM8还需要在启动后加上__HAL_TIM_MOE_ENABLE(htim1);这个宏是使能主输出高级定时器默认是关闭主输出的不加这一句波形出不来。通用定时器不需要但记住这个区别以后用到高级定时器的时候能省你半小时排查时间。4.2 用循环实现呼吸效果最简单的呼吸灯实现就是在while(1)里写两个循环一个让CCR从0增加到1000一个从1000减小到0。每个值停留一小段时间就能看到渐变效果。while (1) { for (uint16_t i 0; i 1000; i 10) { __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, i); HAL_Delay(10); } for (uint16_t i 1000; i 0; i - 10) { __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, i); HAL_Delay(10); } }这段代码的逻辑很直白每次把CCR增加10延时10ms再增加10直到1000。然后反过来减小。1000除以10等于100步每步10ms所以从暗到亮需要1秒从亮到暗再1秒一个完整的呼吸周期是2秒。这个节奏比较接近人呼吸的频率看起来舒服。但这里有个问题HAL_Delay()是阻塞式的CPU在这10ms里什么也干不了。如果你后面还想加按键检测、串口通信就会受影响。更优雅的做法是用定时器中断来更新CCR值或者用PWM的更新中断。不过对于入门实验来说阻塞式延时最简单直观先把效果做出来再考虑优化。4.3 占空比步进值的取舍上面代码里步进值是10也就是每次增加10。这个值决定了呼吸的平滑程度。步进值越小呼吸越平滑但一个周期的时间越长。步进值越大呼吸越粗糙但周期越短。我试过步进值设为1延时1ms这样从0到1000需要1000步每步1ms总共1秒。效果非常平滑几乎看不出台阶。但CPU几乎全被占用了因为每1ms就要进一次循环。如果步进值设为50延时10ms从0到1000只需要20步200ms就走完了呼吸速度很快看起来有点急促。比较折中的方案是步进值10延时10ms周期2秒。或者步进值5延时5ms周期也是2秒但更平滑。你可以根据实际观感调整没有绝对的标准。实操心得如果你发现LED在最低亮度时还有微弱亮光说明CCR没有真正到0。检查一下是不是在循环里i的初始值设成了1而不是0。另外有些开发板的LED是低电平点亮的那你的CCR值越大反而越暗需要把逻辑反过来。5. 常见问题排查与避坑指南5.1 LED完全不亮怎么办这是最常见的问题排查顺序如下。先确认硬件连接。如果你用的是板载LED查一下原理图看LED接在哪个引脚上。很多STM32开发板的板载LED接的是PC13而不是PA6。如果你配置的是TIM3_CH1PA6但LED在PC13上那当然不亮。这时候要么改接线用杜邦线把PA6接到LED上要么换一个定时器通道把PWM输出映射到PC13对应的定时器通道上。如果硬件没问题用示波器或者逻辑分析仪看一下PA6引脚有没有波形。没有示波器的话可以接一个LED加限流电阻直接看LED亮不亮。如果波形有但LED不亮检查LED的方向和限流电阻。如果波形没有那就是软件配置的问题。软件方面先检查HAL_TIM_PWM_Start()有没有被调用。CubeMX生成的代码只初始化定时器不会自动启动PWM。很多人以为配置完就行了结果忘了启动波形自然出不来。再检查时钟配置如果系统时钟没跑到72MHzPWM频率会不对但一般不会完全不亮。还有一个隐蔽的坑如果你在CubeMX里配置了TIM3的Channel1为PWM Generation但生成的代码里MX_TIM3_Init()中HAL_TIM_PWM_Init()的返回值没有检查初始化失败了你也不知道。可以在初始化后加一句判断if (HAL_TIM_PWM_Init(htim3) ! HAL_OK) { Error_Handler(); }这样初始化失败会进入Error_Handler方便定位。5.2 呼吸效果不平滑或者有闪烁如果LED在呼吸过程中有明显的台阶感说明步进值太大了。把步进值从10改成5或者2同时把延时相应减小保持总周期不变。如果LED有闪烁说明PWM频率太低了。1kHz一般不会闪但如果你把ARR设得很大比如65535PSC又没调好频率可能降到几十Hz人眼就能看到闪烁。检查一下PWM频率的计算72MHz / ((PSC1) × (ARR1))。确保结果在1kHz以上。还有一种情况是LED在最低亮度时突然熄灭不是渐变到灭。这是因为CCR值在很小的时候高电平时间极短LED的驱动电流还没建立起来就结束了。可以把最低CCR值设为一个较小的非零值比如10这样最低亮度还有一点微光过渡更自然。5.3 修改占空比后波形不变这个问题通常出现在用__HAL_TIM_SET_COMPARE()修改CCR后发现波形没有变化。原因可能是你修改的是错误的通道或者定时器的预装载寄存器没有使能。在CubeMX配置里auto-reload preload要选Enable。这个选项对应CR1寄存器的ARPE位使能后ARR和CCR的修改会在下一个更新事件时才生效避免波形出现毛刺。如果你没使能修改CCR会立即生效但可能出现不完整的周期。另外__HAL_TIM_SET_COMPARE()的第一个参数是定时器句柄第二个是通道。通道别写错了TIM_CHANNEL_1对应CCR1TIM_CHANNEL_2对应CCR2写错了改的是另一个通道的比较值当前通道当然不变。5.4 CubeMX下载和安装的常见坑网络热词里有人提到cubemx下载不了这个问题我遇到过几次。CubeMX的安装包比较大在线下载有时候会中断。建议去ST官网直接下载完整的安装包不要用在线安装器。下载的时候注意选择对应操作系统的版本Windows选.exemacOS选.dmgLinux选.deb或者.rpm。安装过程中如果卡在某个进度不动可能是网络问题。可以尝试关闭防火墙或者换一个网络环境。安装路径不要有中文和空格否则后面生成代码的时候可能报错。我一般装在C:\ST\STM32CubeMX下面路径短没空格省心。还有cubemx的例程文件夹在哪里这个问题。CubeMX本身不带例程例程是在STM32CubeF1、STM32CubeF4这些固件包里。你安装CubeMX的时候它会提示你下载对应的固件包。固件包默认放在C:\Users\你的用户名\STM32Cube\Repository下面。里面每个外设都有例程比如TIM_PWM_Output这个例程就是PWM输出的参考代码可以直接拿来对比自己的配置。6. 从呼吸灯延伸到实际项目应用6.1 PWM控制电机速度呼吸灯实验里学到的PWM配置直接就能用来控制直流电机的速度。把LED换成电机驱动模块比如L298N或者TB6612PWM频率调到10kHz到20kHz之间人耳听觉范围以上避免电机发出啸叫占空比从0到100%变化电机就从停止慢慢加速到全速。但电机控制有个呼吸灯没有的问题电机的反电动势和电流冲击。电机启动瞬间电流很大如果PWM频率太低电机可能会抖动或者发热。一般建议PWM频率在15kHz以上同时加一个续流二极管保护驱动电路。另外电机的PWM占空比和转速不是完全线性的低速时扭矩小可能需要加一个最小占空比补偿。6.2 PWM调节LED背光亮度如果你做的是带LCD屏幕的项目屏幕背光的亮度调节也是用PWM。比如ILI9341这款屏幕背光引脚接一个PWM输出通过改变占空比来调节亮度。但这里有个坑网络热词里有人提到pwm背光 干扰 显示这是因为PWM频率如果和LCD的刷新率或者SPI时钟产生耦合屏幕上会出现横纹或者闪烁。解决办法是把PWM频率调高比如20kHz以上同时在背光电路上加一个RC低通滤波器把PWM方波变成平滑的直流电压。这样背光亮度调节更线性也不会干扰显示。RC滤波器的截止频率要低于PWM频率比如PWM是20kHzRC截止频率设2kHz左右电阻选1kΩ电容选0.1μF计算一下1/(2π×1000×0.0000001) ≈ 1.6kHz差不多。6.3 PWM模式1和模式2的区别网络热词里有人搜pwm模式1和模式2的区别这个在呼吸灯实验里其实不太涉及但既然提到了就顺带说一下。PWM模式1是CNT小于CCR时输出有效电平PWM模式2是CNT小于CCR时输出无效电平。两者刚好相反。在CubeMX里配置的时候PWM Generation CH1默认就是PWM模式1。如果你把CH Polarity设为High那有效电平就是高电平CNTCCR时输出高。如果你设为Low有效电平就是低电平CNTCCR时输出低。模式1和模式2的区别在于模式2相当于把模式1的输出反相了。实际用哪个模式取决于你的硬件电路需要高电平有效还是低电平有效。呼吸灯实验里两种模式都能用只是占空比的计算方式要相应调整。6.4 用更新中断实现非阻塞呼吸灯前面说的阻塞式呼吸灯CPU在延时里空转浪费资源。实际项目里更常用的是用定时器的更新中断来更新CCR值。配置一个额外的定时器比如TIM2产生1ms的中断在中断服务函数里更新TIM3的CCR值。这样主循环可以处理其他任务呼吸灯在后台自动运行。void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { static uint16_t ccr 0; static int8_t dir 1; ccr dir * 10; if (ccr 1000) { ccr 1000; dir -1; } if (ccr 0) { ccr 0; dir 1; } __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, ccr); } }这段代码放在stm32f1xx_it.c或者main.c里都行HAL库会自动调用这个回调函数。注意TIM2的中断优先级要和TIM3错开避免中断嵌套问题。这种写法在FreeRTOS任务里也能用把CCR更新放在一个低优先级任务里通过队列或者信号量触发更符合RTOS的设计理念。7. 调试工具和验证方法7.1 用示波器看PWM波形如果你有示波器直接把探头接到PA6引脚上应该能看到一个1kHz的方波。调节CCR值方波的占空比会变化。这是最直观的验证方法。没有示波器的话用逻辑分析仪也行几十块钱的USB逻辑分析仪就能看波形配合上位机软件还能测量频率和占空比。7.2 用万用表测平均电压没有示波器的话用万用表的直流电压档测PA6引脚读数是PWM的平均电压。占空比50%时平均电压是3.3V的一半也就是1.65V左右。占空比变化平均电压也跟着变。这个方法虽然看不到波形细节但能快速判断PWM有没有输出占空比大概是多少。7.3 用LED直接观察最土但也最直接的方法就是接一个LED。如果LED亮度在变化说明PWM在正常工作。如果LED一直最亮或者一直灭说明占空比没有变化检查代码里的CCR更新逻辑。避坑技巧有些开发板的LED串联了电阻和跳线帽跳线帽没插的话LED是不亮的。检查一下跳线帽有没有插好。另外LED的限流电阻不要太小否则电流过大可能烧毁LED或者STM32的引脚。一般220Ω到1kΩ之间比较安全。8. 代码优化与工程管理建议8.1 把PWM配置封装成独立模块CubeMX生成的tim.c和tim.h已经帮你把初始化代码分离出来了但PWM的启动和占空比设置还是散落在main.c里。更好的做法是在tim.c里加两个函数void PWM_Start(void) { HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); } void PWM_SetDuty(uint16_t duty) { if (duty 1000) duty 1000; __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, duty); }然后在main.c里调用这两个函数。这样以后换定时器或者换通道只需要改tim.c里的实现main.c不用动。工程大了以后这种模块化设计能省很多事。8.2 用宏定义管理PWM参数把PWM的频率、分辨率、最大占空比这些参数用宏定义放在头文件里#define PWM_FREQ_HZ 1000 #define PWM_RESOLUTION 1000 #define PWM_MAX_DUTY PWM_RESOLUTION这样如果以后要改PWM频率只需要改宏定义重新计算PSC和ARR的值。计算过程也可以写成宏#define TIMER_CLK_HZ 72000000 #define PWM_PSC (TIMER_CLK_HZ / PWM_FREQ_HZ / PWM_RESOLUTION - 1) #define PWM_ARR (PWM_RESOLUTION - 1)这样CubeMX里的参数就可以直接填这些宏的值保持代码和配置一致。8.3 版本管理和代码备份CubeMX有个坑如果你重新生成代码它会覆盖你手动添加的部分。虽然CubeMX有保留用户代码的机制在/* USER CODE BEGIN */和/* USER CODE END */之间的内容不会被覆盖但如果你把代码写在了这两个标记之外重新生成后就没了。我的习惯是每次用CubeMX重新生成代码之前先提交一次Git或者手动备份一下main.c和tim.c。生成完之后用diff工具对比一下看看有没有丢失代码。另外把CubeMX的.ioc配置文件也纳入版本管理这样换电脑或者重装系统后直接打开.ioc文件就能恢复整个工程的配置。9. 从入门到进阶的学习路径呼吸灯实验做完之后你已经掌握了定时器PWM输出的基本操作。接下来可以往几个方向深入。第一个方向是输入捕获。定时器的输入捕获功能可以测量外部信号的频率和占空比。比如你用一个红外接收头接收遥控器信号或者用超声波模块测距都需要用到输入捕获。网络热词里有人搜stm32定时器捕获测频率这就是输入捕获的典型应用。第二个方向是PWM输入模式。有些传感器输出的是PWM信号比如某些湿度传感器或者转速传感器。用PWM输入模式可以同时测量频率和占空比比普通的输入捕获更方便。第三个方向是互补PWM和死区控制。如果你要驱动无刷电机或者H桥电路需要用到高级定时器的互补输出和死区插入功能。这个比呼吸灯复杂得多但原理是一样的只是多了几个配置项。第四个方向是DMA定时器。如果你要输出任意波形比如正弦波或者音频信号可以用DMA把波形数据表传输到定时器的CCR寄存器定时器自动更新占空比CPU完全不参与。这个技术用在数字信号发生器或者LED灯带控制上很常见。我个人在实际操作中的体会是呼吸灯这个实验虽然简单但它把定时器、PWM、GPIO、时钟树这几个核心概念串起来了。你把这几个概念搞清楚了后面学什么外设都快。很多人学STM32觉得难就是因为每个外设都是孤立的不知道它们之间怎么配合。呼吸灯就是一个很好的粘合剂把之前学的GPIO和现在学的定时器连在一起形成一个完整的知识链条。最后再分享一个小技巧如果你在调试PWM的时候发现波形频率不对先别急着改代码用CubeMX的Clock Configuration界面看一下定时器的时钟源频率。有时候APB1的分频系数设错了定时器时钟不是你以为的72MHz而是36MHz或者18MHz算出来的PWM频率自然不对。这个坑我踩过好几次每次都是盯着代码看半天最后发现是时钟树的问题。