☰
STM32 PWM调光实战:按键控制LED亮度完整链路
2026/10/15 12:12:02 网站建设 项目流程

简介:这份资源面向STM32初学者与嵌入式开发者,围绕PWM脉冲宽度调制与按键输入控制LED亮度这一典型应用展开。包内共153个文件,以32个c源文件、34个h头文件为核心,涵盖定时器、RCC、GPIO等外设驱动代码,另有32个s汇编启动文件、14个o目标文件、13个crf编译中间文件,以及uvprojx工程文件、hex烧录文件、map映射文件等,压缩包约2.18MB,构成一套可直接编译调试的完整工程。资源重点演示定时器PWM模式配置、预分频与自动重载值设定、通道比较值调节占空比,以及按键中断响应并动态更新亮度等关键环节,读者可据此理解占空比与LED平均电流的关系,掌握从时钟使能到中断处理的完整流程。目前已有13839人学习下载,适合作为智能照明、自动化设备等场景的入门实践参考。

1. PWM调光不是玄学:从按键到LED亮度的完整控制链

很多人第一次在STM32上做PWM调光,代码烧进去发现LED要么全亮要么全灭,按键按下去亮度纹丝不动,于是开始怀疑人生。其实问题不在PWM本身,而在于你没有把「按键扫描→占空比更新→定时器重装载」这条链路串起来。这个标题讲的就是用STM32的定时器输出PWM波,通过按键动态修改比较值,从而改变LED的平均导通时间,实现亮度调节。它适合已经能点亮LED、能跑通GPIO输入输出的嵌入式初学者,也适合想从「点灯」进阶到「调光」的工程师。核心就三件事:定时器配置对、按键消抖稳、占空比算得准。

2. 定时器选型与PWM模式:为什么TIM2比TIM1更适合新手调光

2.1 通用定时器和高级定时器的实际差异

STM32的定时器分好几类,高级定时器如TIM1、TIM8,通用定时器如TIM2到TIM5,基本定时器如TIM6、TIM7。做LED调光,通用定时器完全够用,而且配置比高级定时器少一层刹车和死区设置的坑。高级定时器要输出PWM,必须额外调用TIM_CtrlPWMOutputs(TIM1, ENABLE)打开主输出,否则通道配置全对也没有波形出来。我一般建议新手从TIM2开始,它的通道1到通道4对应PA0到PA3,接线直观,查手册也方便。

选定时器还要看总线频率。以常见的STM32F103为例,TIM2挂在APB1总线上,如果系统时钟72MHz,APB1预分频系数为2,则TIM2的时钟为72MHz。这个频率决定了你后面算预分频和自动重装载值的基准。不同型号的时钟树有差异,动手前先确认自己芯片的参考手册里对应定时器的时钟来源。

2.2 PWM模式1和模式2的区别与选择

PWM模式1和模式2的区别就一句话:模式1下,计数器小于比较值时输出有效电平;模式2下,计数器小于比较值时输出无效电平。听起来绕,实际用的时候记住:如果你想让比较值越大LED越亮,就用PWM模式1,且有效电平设为高。这样占空比 = CCR / (ARR+1),CCR越大,高电平时间越长,LED越亮。

配置代码如下,以标准外设库为例:

// TIM2 PWM初始化,通道1对应PA0 void PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // PA0复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器基础配置 TIM_TimeBaseStructure.TIM_Period = arr; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频系数 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // PWM模式1配置 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; // 初始占空比为0 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }

这段代码里,arr和psc决定了PWM频率。公式是:PWM频率 = 定时器时钟 / ((arr+1) × (psc+1))。假设定时器时钟72MHz,想要1kHz的PWM频率,可以设psc=71,arr=999。这样每个PWM周期是1ms,人眼完全感觉不到闪烁。TIM_Pulse就是CCR值,初始设为0,LED不亮。后面按键每按一次,就修改这个值。

TIM_OCPreload_Enable和TIM_ARRPreload_Enable这两个预装载使能很重要。它们保证你在修改CCR或ARR时,新值在下一个更新事件才生效,避免当前周期出现毛刺。如果不使能,调光时偶尔会看到LED闪一下,这就是血泪经验。

2.3 频率和分辨率的权衡

PWM频率不能太低,低于200Hz人眼可能察觉到闪烁,尤其是用手机摄像头拍的时候。但频率也不能太高,因为ARR值会变小,分辨率下降。比如72MHz时钟,要输出100kHz的PWM,ARR最大只能到719,占空比只能分720级,调光细腻度就差了。一般LED调光用1kHz到5kHz比较合适,ARR设成999或4999,分辨率足够,也不会闪。

3. 按键扫描与占空比更新:把抖动和连按都管住

3.1 按键硬件连接与上拉电阻的选择

按键一端接GPIO,另一端接地,GPIO配置为上拉输入。这样按键未按下时读到高电平,按下读到低电平。STM32内部有可配置的上拉电阻,一般30k到50k欧姆,对于普通轻触按键够用了。如果板子上按键离MCU较远,或者环境干扰大,建议外部再加一个10k欧姆上拉,内部上拉作为备份。

按键对应的GPIO初始化:

// 按键初始化,PA1为上拉输入 void KEY_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_Init(GPIOA, &GPIO_InitStructure); }

GPIO_Mode_IPU就是上拉输入模式。如果按键接的是VCC而不是GND,那就用下拉输入GPIO_Mode_IPD,逻辑反过来。接线方式决定配置,别搞混。

3.2 软件消抖的两种写法与选择

按键抖动一般持续5ms到20ms,不消抖的话按一次可能被识别成好几次。最简单的消抖是延时法:检测到低电平后延时10ms再读一次,还是低电平就确认按下。缺点是阻塞CPU,但在这个小项目里无所谓。

// 带消抖的按键扫描,返回键值 u8 KEY_Scan(u8 mode) { static u8 key_up = 1; // 按键松开标志 if (mode) key_up = 1; // 连按模式 if (key_up && (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) == 0)) { delay_ms(10); // 消抖延时 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) == 0) { key_up = 0; return 1; // 按键按下 } } else if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) == 1) { key_up = 1; } return 0; }

mode参数控制是否支持连按。传0时,按住不放只算一次;传1时,按住会连续触发。调光场景一般用不支持连按的模式,每按一次亮度加一档,按到最大再回到最小,循环调节。

3.3 占空比步进策略:线性调光和视觉调光的差别

占空比从0到100%线性增加,LED亮度并不是线性变化的。人眼对亮度的感知近似对数关系,低占空比区间变化明显,高占空比区间变化迟钝。如果按固定步进比如每次加10%,你会发现低亮度时跳变很大,高亮度时几乎看不出变化。

常见做法是分两段:0到50%区间步进小一点,比如5%;50%到100%区间步进大一点,比如10%。或者直接用查表法,预先算好一组人眼感觉均匀的CCR值,按键时切换索引。我一般用查表法,省事且效果稳定。

// 人眼视觉均匀的CCR值表,共11档 const u16 ccr_table[11] = {0, 50, 120, 220, 350, 500, 680, 880, 1100, 1350, 1600}; u8 level = 0; // 当前亮度档位 // 按键处理函数 void KEY_Process(void) { if (KEY_Scan(0) == 1) { level++; if (level > 10) level = 0; // 循环 TIM_SetCompare1(TIM2, ccr_table[level]); } }

这个表假设ARR是1600,占空比从0%到100%分11档。数值不是等差的,低档位间隔小,高档位间隔大,补偿人眼的非线性。你可以根据自己的LED和限流电阻实测调整,没有标准答案。

4. 避坑与排查:调光不生效的五个典型原因

4.1 现象:LED常亮,按键无反应

原因通常是GPIO模式配错。PA0如果配成了普通推挽输出而不是复用推挽,定时器波形根本出不来,LED可能因为其他代码被拉高而常亮。解决方法是检查GPIO_Mode_AF_PP是否写对,以及对应引脚是否真的支持该定时器通道。查数据手册的引脚复用表,别凭记忆。

4.2 现象:LED微亮但调不了,或者亮度跳变

先看CCR值有没有超过ARR。如果CCR大于ARR,输出会一直有效或一直无效,取决于模式。用TIM_SetCompare1设值前,确保CCR不超过ARR。另外检查预装载使能是否打开,没打开的话修改CCR会立即生效,可能在周期中间产生窄脉冲,看起来就是跳变。

4.3 现象:按键按一次亮度变好几档

这是消抖没做好,或者按键扫描周期太短。如果主循环里没有其他延时,按键扫描可能每微秒执行一次,抖动期间被多次判定为按下。解决办法是确保消抖延时足够,或者在主循环里加一个1ms到5ms的节拍,按键扫描按节拍执行。

4.4 现象:调光时LED闪烁或听到啸叫

PWM频率设太低了。如果频率低于200Hz,人眼能察觉闪烁;如果低于20kHz但高于听觉范围下限,可能听到电感或陶瓷电容的啸叫。把PWM频率提到1kHz以上,一般就没了。但注意频率提高后ARR变小,分辨率下降,需要重新平衡。

4.5 现象:换一块板子就不工作了

不同STM32型号的定时器时钟源不同。比如F0系列和F1系列的时钟树差异很大,F4系列APB1和APB2的定时器时钟倍频规则也不一样。移植代码时,先确认目标芯片的定时器时钟频率,重新算psc和arr。不要直接复制粘贴,否则频率可能差好几倍。

5. 进阶技巧:用DMA和查表做呼吸灯与多通道同步

5.1 用DMA自动更新CCR实现呼吸灯

呼吸灯的本质是CCR值按正弦或三角波规律连续变化。如果靠主循环改CCR,占空比更新时刻和PWM周期不同步,会有细微抖动。更好的做法是用DMA把预先算好的CCR数组搬运到TIM2->CCR1寄存器,每个PWM周期自动更新一次,完全不占CPU。

配置思路是:开启TIM2的更新DMA请求,DMA源地址指向CCR数组,目标地址指向&TIM2->CCR1,传输长度设为数组长度,循环模式。这样定时器每个更新事件触发一次DMA传输,CCR值自动切换,呼吸效果丝滑。

// DMA配置片段,以标准库为例 DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); // TIM2_UP对应DMA1通道5 DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&TIM2->CCR1; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)ccr_table; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 11; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel5, &DMA_InitStructure); TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE);

这段代码里,DMA_Mode_Circular让DMA搬完一轮后自动回到开头,形成循环。DMA_MemoryInc_Enable让源地址每次递增,依次读取数组元素。目标地址不变,始终写CCR1。这样CCR值按数组顺序循环,LED就呈现呼吸效果。数组内容可以按正弦规律生成,呼吸更自然。

5.2 多通道同步调光的注意事项

如果要同时控制RGB三色LED,用TIM2的通道1、2、3分别输出PWM。三个通道共用同一个ARR,所以PWM频率一致,但CCR各自独立。修改亮度时分别调三个CCR即可。注意三个通道的预装载使能都要打开,否则修改时可能不同步,颜色会闪。

另外,如果三个LED的限流电阻不同,同样的CCR值亮度不一样。需要分别做校准表,或者用软件做gamma校正。我一般先让三个通道输出相同占空比,用手机摄像头看颜色是否偏,再微调CCR偏移量。

5.3 一个验证调光线性度的小方法

拿一个光敏电阻或手机亮度传感器,固定距离对着LED,记录不同CCR值下的传感器读数。如果读数随CCR线性变化,说明调光线性度好;如果低CCR区间读数跳变大,说明需要调整查表值。这个方法不需要专业设备,手机的光传感器App就能凑合用。我试过几次,比肉眼判断靠谱得多。

调光这件事,说到底就是不断逼近人眼感觉舒服的曲线。代码写对只是第一步,参数调到位才是功夫。我自己的习惯是先在纸上画一条目标亮度曲线,再反推CCR表,烧进去看效果,不行就改表。反复几次,总能找到合适的。希望帮到你。

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

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

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

立即咨询