STM32的PWM调光算是嵌入式入门里最经典的一个练手项目但很多人做完之后只是让灯亮了一下并没有真正理解占空比和亮度之间的对应关系也没搞清楚为什么有的频率下灯会闪、有的频率下电机或LED会发出啸叫。我这次把PWM呼吸灯和OLED实时显示放在一起做目的很明确一边用TIM2输出可调占空比的PWM波驱动LED一边用OLED把当前的占空比、亮度百分比、频率这些参数实时刷出来让整个调光过程看得见。这套东西适合刚上手STM32F103、想搞明白定时器PWM输出模式的朋友也适合已经会点灯但没系统梳理过PWM参数的人。下面我按实际调试的顺序把选型理由、寄存器配置逻辑、OLED刷新策略和踩过的坑都摊开讲。1. 为什么选TIM2做PWM输出而不是软件延时1.1 软件延时调光的致命问题刚入门的时候很多人会用GPIO加delay做呼吸灯思路是循环改变延时时间让LED亮灭的时间比例变化。这个方法在只有一个灯、且主循环什么都不干的时候勉强能看但只要你在主循环里加一句OLED刷新或者按键扫描呼吸节奏立刻就乱了。原因很简单软件延时是阻塞的CPU被死死占在延时循环里其他任务全部停摆。PWM的本质是硬件在后台自动翻转引脚电平CPU只需要在初始化时配置好周期和占空比之后该干嘛干嘛。TIM2挂在APB1总线上STM32F103C8T6的APB1时钟默认是36MHz定时器时钟经过倍频后是72MHz。这个72MHz就是PWM的时间基准决定了你能输出多精细的占空比调节。1.2 TIM2的通道分配与引脚对应STM32F103C8T6上TIM2有四个独立通道对应引脚如下通道引脚默认复用功能备注TIM2_CH1PA0PWM输出常被用作按键注意冲突TIM2_CH2PA1PWM输出本次驱动LED用这个TIM2_CH3PA2PWM输出可驱动第二路LEDTIM2_CH4PA3PWM输出可驱动第三路LED我选PA1作为LED驱动引脚原因是PA0在很多核心板上已经接了按键PA2和PA3有时被串口占用。PA1相对干净接一个限流电阻加LED就能用。限流电阻取值建议在220欧到1K欧之间我实测用470欧时LED亮度适中且电流在安全范围内。1.3 频率选择为什么1kHz是个甜点值PWM频率决定了LED每秒被开关多少次。频率太低比如50Hz以下人眼会明显感觉到闪烁频率太高比如几十kHz虽然人眼看不到闪但LED的驱动电路和寄生电容可能导致波形畸变而且占空比的分辨率会下降。我最终选的是1kHz计算过程如下定时器时钟72MHz预分频器PSC设为71则计数时钟 72MHz / (711) 1MHz自动重装载值ARR设为999则PWM周期 (9991) / 1MHz 1msPWM频率 1 / 1ms 1kHz这样每个计数周期是1微秒占空比可以分成1000级调节分辨率足够细腻。如果你想让呼吸更平滑可以把ARR设成1999甚至3999代价是频率降低或者需要调整PSC。注意PSC和ARR都是16位寄存器最大值65535。如果你需要更低频率优先加大PSC而不是ARR因为ARR越大占空比分辨率越高。2. 寄存器级配置PWM输出的完整链路2.1 时钟使能与GPIO复用配置第一步永远是开时钟。TIM2在APB1上GPIOA在APB2上两个都要开RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 使能TIM2时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN; // 使能GPIOA时钟然后配置PA1为复用推挽输出。这里有个容易搞错的地方PWM输出引脚必须配成复用推挽Alternate Function Push-Pull不能配成普通推挽。普通推挽是CPU控制引脚复用推挽才是定时器外设接管引脚。GPIOA-CRL ~(0xF 4); // 清除PA1配置位 GPIOA-CRL | (0xB 4); // 复用推挽输出50MHz0xB的二进制是1011对应MODE11输出50MHz、CNF10复用推挽。这个值我一开始写成0x3普通推挽结果PWM死活出不来排查了半天才发现是复用模式没配对。2.2 时基单元与输出比较单元时基单元决定PWM的周期输出比较单元决定占空比TIM2-PSC 71; // 预分频计数时钟1MHz TIM2-ARR 999; // 自动重装载周期1ms TIM2-CCR2 500; // 通道2比较值初始50%占空比 TIM2-CCMR1 | (6 12); // 通道2配置为PWM模式1 TIM2-CCMR1 | (1 11); // 使能通道2预装载 TIM2-CCER | (1 4); // 使能通道2输出 TIM2-CR1 | (1 7); // 使能自动重装载预装载 TIM2-CR1 | (1 0); // 启动定时器PWM模式1的含义是当计数值小于CCR时输出高电平大于等于CCR时输出低电平。所以CCR越大高电平时间越长LED越亮假设LED是低电平点亮则相反接线时注意。2.3 占空比动态调节的实现呼吸灯的核心就是让CCR值周期性变化。我在主循环里用一个变量记录当前占空比每次加一或减一到边界后反向static int16_t duty 0; static int8_t dir 1; duty dir; if (duty 1000) { duty 1000; dir -1; } if (duty 0) { duty 0; dir 1; } TIM2-CCR2 duty;这段代码看起来简单但有个细节如果你在主循环里不加延时呼吸速度快到人眼根本看不出来。我实测每步之间加1ms延时比较合适整个呼吸周期大约2秒视觉上很舒服。提示CCR2寄存器有预装载功能写入新值后不会立即生效而是等到下一个更新事件才加载。这样可以避免PWM波形出现毛刺。如果你希望立即生效可以手动触发UG位但一般不需要。3. OLED实时显示的设计与刷新策略3.1 OLED选型与I2C接口优势我用的是0.96寸SSD1306驱动的OLED模块分辨率128x64I2C接口。选I2C而不是SPI的原因是接线简单只需要SCL和SDA两根线对于这种显示参数的小项目完全够用。I2C速率我设在400kHz快速模式刷新一屏大概需要十几毫秒不会拖慢主循环。OLED的I2C地址通常是0x78写地址或0x3C7位地址具体看你模块的SA0引脚接法。我手上这块是0x78如果你的点不亮先用I2C扫描程序确认地址。3.2 显示内容的布局设计OLED上我分了四行显示行号显示内容示例第1行标题PWM Breath LED第2行当前占空比Duty: 500/1000第3行亮度百分比Bright: 50.0%第4行PWM频率Freq: 1000Hz亮度百分比就是占空比除以ARR再乘100保留一位小数。频率是根据PSC和ARR实时算出来的这样即使你改了参数显示也会跟着变。3.3 刷新频率与闪烁问题OLED不需要每毫秒都刷。我一开始在主循环里每次都刷OLED结果呼吸灯变得一顿一顿的因为I2C传输占用了太多时间。后来改成每50ms刷一次呼吸立刻顺滑了。具体做法是用一个计数器static uint16_t oled_tick 0; oled_tick; if (oled_tick 50) { oled_tick 0; OLED_ShowNum(0, 2, duty, 4, 16); OLED_ShowFloat(0, 4, duty * 100.0f / 1000.0f, 1, 16); }这样CPU有足够时间处理PWM更新OLED也不会闪。注意SSD1306的显存是分页的每次刷新整屏需要写8页共1024字节。如果你只改几个数字可以用局部刷新但实现起来麻烦。对于这个项目50ms整屏刷一次完全够用。4. 调试过程中踩过的坑与排查思路4.1 LED不亮的第一层排查第一次烧录后LED完全不亮我按以下顺序排查用万用表测PA1电压发现一直是低电平说明PWM没输出。检查时钟使能确认RCC-APB1ENR的TIM2EN位已置1。检查GPIO配置发现CRL寄存器写的是0x3而不是0xB复用模式没开。改成0xB后重新烧录LED亮了但亮度不变。这个排查链路告诉我PWM不输出先看时钟再看GPIO复用最后看定时器配置。顺序不能乱否则容易在错误的方向上浪费时间。4.2 亮度不变化的隐藏原因LED亮了但呼吸效果没有CCR改了也没反应。原因是CCMR1的预装载使能位没开CCR写入后要等更新事件才生效而更新事件又依赖于ARR的预装载。我把CR1的ARPE位和CCMR1的OC2PE位都置1后问题解决。这个坑的教训是STM32的定时器有大量预装载寄存器配置时要么全部使能预装载要么全部关闭混用容易出问题。4.3 OLED显示乱码的两种可能OLED上显示的数字偶尔变成乱码排查后发现两个原因一是I2C速率太高杜邦线太长导致信号完整性变差。把速率从400kHz降到100kHz后稳定了。二是显示浮点数时用了sprintf栈空间不够导致数据被覆盖。后来改成手动拆分整数和小数部分不再用sprintf。如果你也遇到OLED偶发乱码优先检查接线长度和I2C速率其次检查你的格式化函数是否安全。4.4 呼吸节奏不均匀的问题呼吸灯在低亮度区域变化快、高亮度区域变化慢看起来不自然。这是因为人眼对亮度的感知是非线性的近似服从伽马曲线。解决办法是把线性的占空比映射成非线性uint16_t gamma_map(uint16_t linear) { float x linear / 1000.0f; float y x * x; // 近似伽马2.0 return (uint16_t)(y * 1000); }加上这个映射后呼吸的视觉节奏明显均匀了很多。这是我在实际调试中觉得最值得分享的一个技巧很多教程都不会提。5. 从呼吸灯延伸到其他PWM应用场景5.1 舵机控制的参数差异同样的TIM2通道换成舵机控制时参数完全不同。舵机需要50Hz频率对应PSC71、ARR19999。脉宽0.5ms到2.5ms对应0度到180度换算成CCR就是500到2500。你会发现呼吸灯和舵机用的是同一套定时器逻辑只是参数不同。5.2 电机调速的注意事项用PWM驱动直流电机时频率不能太低否则电机会发出啸叫。我一般用10kHz到20kHz超出人耳听觉范围。另外电机是感性负载PWM输出引脚和电机之间要加驱动电路不能直接接。这一点和LED完全不同LED可以直接用引脚驱动电流控制在20mA以内电机绝对不行。5.3 多路PWM同步输出TIM2的四个通道共用同一个时基所以四路PWM的频率天然同步只有占空比可以独立设置。如果你要做RGB三色呼吸灯用CH1、CH2、CH3分别驱动红绿蓝然后给三个CCR设置不同的相位偏移就能做出彩色渐变效果。这个扩展我在后续项目里试过效果很好。6. 完整代码结构与关键配置速查6.1 工程文件组织我的工程分四个文件main.c主循环呼吸逻辑和OLED刷新调度pwm.c / pwm.hTIM2初始化和占空比设置接口oled.c / oled.hSSD1306驱动包含I2C底层和显示函数delay.c / delay.hSysTick延时这种分法让每个模块职责清晰后面换OLED型号或者换定时器都不用大改。6.2 关键配置参数速查表参数值说明定时器时钟72MHzAPB1倍频后PSC71计数时钟1MHzARR999PWM频率1kHzPWM模式模式1计数值CCR时输出高初始CCR0从灭开始呼吸呼吸步进延时1ms周期约2秒OLED刷新间隔50ms平衡流畅度和CPU占用I2C速率100kHz长线时降速保稳定6.3 上电后的预期现象烧录后你应该看到LED从灭逐渐变亮到最亮再逐渐变灭循环往复OLED上四行数据同步变化占空比从0到1000来回跳亮度百分比从0.0%到100.0%频率始终显示1000Hz。如果OLED显示正常但LED不呼吸检查CCR是否真的在变如果LED呼吸但OLED不显示检查I2C地址和接线。我在实际使用中发现这套代码在STM32F103C8T6核心板上跑得非常稳连续运行几个小时没有出现死机或显示异常。如果你用的是其他型号主要改时钟使能和引脚定义PWM和OLED的逻辑基本可以照搬。后续如果想加按键调节呼吸速度只需要在main循环里加一个按键扫描改变步进延时值就行整个架构留了足够的扩展空间。