
简介面向STM32入门学习者的按键控制LED亮度实战工程基于STM32F103完成六个按键对LED六档亮度的调节。工程围绕GPIO输入检测、定时器PWM输出、中断响应与按键去抖动展开既适合作为单片机课程实验也可用于智能灯光等嵌入式项目的参考设计。压缩包共202个文件以Keil MDK工程为主包含35个C源码、36个头文件、标准外设驱动、启动汇编、编译生成的.o/.crf/.d中间文件以及工程配置文件整体仅6.76MB目录按功能划分结构清晰易读。目前已有19510人学习下载属于高热度STM32例程。读者可从源码中看到定时器初始化、PWM占空比更新、按键中断服务函数的完整写法理解CCR比较值与输出脉宽的关系并直接复用核心代码省去从零搭建外设工程的步骤便于进一步扩展渐变亮度或长按调节功能代码均来自真实可运行工程方便二次开发和移植。1. 从按键到亮度PWM 才是这个工程的题眼拿到“STM32 通过按键控制LED灯亮度”这个项目时别以为它只是点灯。表面上是按键输入、LED输出真正要吃透的核心是 PWM——用定时器产生可调占空比的方波再通过方波的平均功率去控制 LED 亮度。资源包围绕“按键 定时器 PWM”的最小闭环展开正好覆盖 GPIO 输入、定时器输出、占空比调节和机械按键去抖这几个入门必备知识点适合刚学会点灯、想用真实外设做交互的开发者也适合给电机调速、屏幕背光这类需要“占空比控制”的场景打底。我完整拆了一遍之后最深的感受是按键去抖和 PWM 参数设计比想象中费神有些坑不看示波器根本发现不了。2. 原理先行为什么亮度控制要 PWM 而不是直接调电压2.1 PWM 调光的本质占空比决定平均功率LED 亮度跟流过它的平均电流近似线性。直接调电压的方式有两种一是用 DAC 输出模拟电压二是串一个可变电阻分压。这两种方案在 MCU 工程里都相对昂贵或者低效DAC 引脚少、成本高可变电阻阻值受温度影响而且引脚要占 ADC。PWM 则是用一串数字方波去控制 LED 的导通和关断当开关频率足够高时人眼由于视觉暂留效应感知到的就是平均亮度。设 PWM 周期为 T高电平时间为 Ton占空比 D Ton / TLED 的平均电压约等于 Vcc × D。比如 Vcc3.3V、占空比 50% 时LED 上等效平均电压是 1.65V实际通过的是滞回特性的电流所以亮度大致也是线性变化的。关键参数有两个频率和占空比分辨率。频率由定时器的预分频 PSC 和重装载值 ARR 决定PWM 频率 定时器时钟源频率 / ((PSC1) × (ARR1))。以常见主频 72MHz 的 MCU 为例如果希望得到 1kHz PWM可以配置 PSC71这样定时器计数时钟变成 1MHz再设 ARR999计数一个周期正好 1000 个数频率就是 1kHz。占空比分辨率由 ARR 决定ARR999 时 CCR 可以从 0 调到 999分辨率约 0.1%如果想要更亮的渐变细腻度可以把 ARR 拉大但代价是频率会下降要求预分频同步调整。下面是几组常见参数组合按不同需求选择目标频率PSCARR分辨率备注1 kHz719990.1%亮度调节细腻调光常用20 kHz38990.11%频率高适合驱动感性负载10 kHz78990.11%显示器背光常用3906 Hz712550.39%8 位 PWM256 级亮度需要注意这里说的分辨率是占空比的步进。如果你的需求是人眼可分辨的亮度档位256 级已经足够但 CCR 是 16 位寄存器即使 ARR999 也只用到 10 位数据仍然建议用 uint16_t 类型保存占空比避免后续做加减运算时溢出。2.2 按键输入方案选择轮询、外部中断与去抖按键输入是另一个容易翻车的地方。机械按键按下和松开的瞬间触点会弹跳几次每次弹跳持续 5 到 20 毫秒不等如果不处理MCU 可能把一次按下误判成多次。软件去抖最常用的是“延时确认法”检测到电平变化后延时 10 到 20 毫秒再读一次如果还是同一个电平就认为状态稳定了。输入检测方式有两种轮询和外部中断。轮询就是在主循环里不断读 IO 引脚优点是逻辑简单、不需要配置中断缺点是比较耗 CPU因为你需要每隔几毫秒扫一次按键哪怕没有按键事件也在循环。外部中断则是当引脚电平发生变化时触发 EXTI 中断适合低功耗唤醒但需要处理中断标志、中断优先级而且多个按键共用一个外部中断线时还要自己判断按键编号。从工程习惯上来讲我一般会把按键扫描封装成一个独立函数返回“按下事件”而不是“当前电平”。电平是状态事件是变化。两者最大的区别是按住不动时电平一直为低但事件只应该上报一次。这个细节很多入门资料不讲导致很多人的按键按下一次占空比连续跳好几级。按键的上下拉也要提前定好。如果按键一端接 GND另一端接到 MCU 引脚那 MCU 端需要配置内部上拉按住时为低松开为高如果按键一端接 3.3V另一端接 MCU 引脚则配置内部下拉。我给出的工程里用的都是“按键接 GND 内部上拉”的方式这样 MCU 上电默认高电平外部干扰不容易误触发。2.3 定时器通道与参数选择分辨率、频率、引脚复用STM32 这类 MCU 的 PWM 输出不是随便拿一个 GPIO 就能用的必须绑定到定时器的输出比较通道。比如通用定时器 TIM2 的通道 1 默认复用引脚是 PA0通道 2 是 PA1TIM3 通道 1 是 PA6、通道 2 是 PA7。不同型号引脚可能有重映射查手册时一定要看“AF 复用映射表”。选择哪路定时器主要看时钟树。通常 APB1 上的定时器时钟在系统主频较高时会被倍频有的是 72MHz有的是 84MHz。如果发现自己配置的频率和理论计算差一位先确认该定时器挂在哪条总线、对应外设时钟使能有没有打开。常见问题是 HAL 库例程使用 TIM3但用户把引脚接在 TIM2 的复用脚上又没有改初始化结构体里的 Instance结果引脚一直输出不了波形。补充一个容易忽略的参数输出比较极性 OCPolarity。设置为 HIGH 时CCR 计数匹配前输出高电平匹配后输出低电平设置为 LOW 时相反。如果 LED 接法不同同样的 CCR 值对应的亮度可能是相反的。更根本的做法是统一硬件接法LED 正极经过限流电阻接 MCU 引脚负极接地这样高电平点亮极性设为 HIGH 即可。后面避坑章节我会专门展开。3. 落地复现从建工程到按键改变占空比3.1 硬件连接LED、按键、限流电阻与最小系统我用的最小系统板核心是 “Cortex-M3 内核、主频 72MHz” 的常见型号资源包里的代码同时给了寄存器版和标准库版。先看硬件连接表信号引脚方向连接说明LED_PWMPA0输出接限流电阻 220Ω再串 LED 阳极LED 阴极接 GNDKEY_UPPB0输入内部上拉按键另一端接 GNDKEY_DOWNPB1输入内部上拉按键另一端接 GND硬件上有一个很多人不重视的点LED 限流电阻不能省。虽然 PWM 平均电流由占空比决定但峰值电流在 PWM 高电平瞬间是会全部施加在 LED 上的。如果直接接 3.3V 不串电阻LED 可能在低占空比时没事一旦占空比调到 100% 就会过热老化。用 220Ω 或 330Ω 比较稳妥工作电流大约在 10 到 15 毫安。按键的机械弹跳会产生尖峰甚至可能干扰旁边一路 PWM 波形的毛刺。如果板子上按键离 LED 驱动引脚很近建议在按键两端并联一个 100nF 电容同时软件去抖也保留。这样双保险硬件滤波和软件滤波组合实际调试会少很多麻烦。我最早只做了软件去抖按键一按示波器上看到 PA0 波形边缘有明显的毛刺抖动才知道机械开关真的需要多加处理。3.2 定时器与 GPIO 初始化PWM 通道和按键输入配置打开一个空工程后第一步是配置时钟和 GPIO。下面的代码以 HAL 库为例但资源包里的标准外设库版本逻辑一模一样只是函数名不同。重点看注释部分。#include stm32f1xx_hal.h /* 定时器句柄作为全局变量方便后续修改 CCR */ TIM_HandleTypeDef htim2; void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; /* 使能 GPIOA 和 GPIOB 时钟 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); /* PA0 复用推挽输出用于 TIM2_CH1 PWM */ GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); /* PB0 和 PB1 接按键配置为输入内部上拉 */ GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); }PA0 配置成GPIO_MODE_AF_PP的意思是这个引脚不是普通推挽输出而是由片上外设这里是 TIM2控制。这一步非常关键我见过不少新手把 PA0 配成GPIO_MODE_OUTPUT_PP然后发现无论怎么改比较寄存器都没有波形输出就是因为引脚没有切到复用模式。按键输入用GPIO_PULLUP这样按键悬空时读回来是高电平按下时接地读回来是低电平。接下来初始化 TIM2。这里有两个容易混淆的寄存器PSC 控制计数时钟分频ARR 控制计数周期。PSC71 时72MHz 变成 1MHz也就是说计数器每 1 微秒加一ARR999 时从 0 数到 999 正好 1 毫秒对应 1kHz。这种配置下 CCR 的范围是 0 到 999占空比最小 0%最大 99.9%。void PWM_Config(void) { TIM_OC_InitTypeDef sConfigOC {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance TIM2; htim2.Init.Prescaler 71; /* PSC71计数时钟 1MHz */ htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 999; /* ARR999PWM 频率 1kHz */ htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(htim2, TIM_CLOCKSOURCE_INTERNAL); sConfigOC.OCMode TIM_OCMODE_PWM1; /* 向上计数时 CCR 匹配前为高 */ sConfigOC.Pulse 500; /* 初始占空比 50% */ sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim2, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1); }AutoReloadPreload启用后ARR 的值会在发生更新事件时被重新装载避免你在运行中修改 ARR 时出现错位。Pulse就是 CCR初始值 500也就是 50% 占空比。注意HAL_TIM_PWM_Start必须调用否则定时器虽然配置了但不会输出波形。这个函数内部会清零计数并启动时基漏掉它就只剩下一个静态的 0V 输出。3.3 按键扫描函数去抖、状态变化与事件产生按键扫描最怕的是“一次按下触发多次”。我写的扫描函数每次只在“按下沿”返回一次然后必须等按键松开后才会重新响应。核心是利用两个静态变量记录上一次和当前次的状态。#define KEY_PORT GPIOB #define KEY_UP_PIN GPIO_PIN_0 #define KEY_DOWN_PIN GPIO_PIN_1 /* 返回值为 1 表示产生一次按下事件否则返回 0 */ uint8_t Key_Scan(uint16_t pin) { static uint8_t last_state[2] {1, 1}; uint8_t index (pin KEY_UP_PIN) ? 0 : 1; uint8_t current HAL_GPIO_ReadPin(KEY_PORT, pin); /* 检测下降沿按键按下状态从高到低 */ if (last_state[index] 1 current 0) { HAL_Delay(10); /* 软件去抖 */ current HAL_GPIO_ReadPin(KEY_PORT, pin); if (current 0) { last_state[index] 0; /* 确认按下 */ return 1; } } /* 检测上升沿按键松开 */ if (last_state[index] 0 HAL_GPIO_ReadPin(KEY_PORT, pin) 1) { last_state[index] 1; } return 0; }HAL_Delay(10)放在确认前后起到延时滤波的作用。按下瞬间开始延时 10ms再读一次仍然为低说明不是抖动。这里的“10ms”其实是一个经验值机械开关弹跳多数在 5-20ms 之间太短滤不掉太长会让快速连击失效。我一般习惯用 15ms然后把按键松开检测放在主循环下一次扫描这样按住不放不会连续触发。完整的 main 函数里调用扫描函数如果增加按键触发就把占空比往上调 50如果是减小按键就往下调 50。边界值通过直接比较钳制在 0 到 999 之间。int main(void) { HAL_Init(); GPIO_Config(); PWM_Config(); uint16_t duty 500; /* 初始占空比 50% */ while (1) { if (Key_Scan(KEY_UP_PIN)) { duty 50; if (duty 999) duty 999; /* 上限钳位 */ __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, duty); } if (Key_Scan(KEY_DOWN_PIN)) { if (duty 50) duty - 50; else duty 0; /* 下限钳位 */ __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, duty); } } }__HAL_TIM_SET_COMPARE是一个宏直接写比较寄存器 CCR。PWM 硬件会在下一个计数周期自动应用新占空比不需要额外软件触发。这里我用的是步进 50也就是 5% 占空比步进总共 20 档视觉上还算均匀。如果你想要更细腻可以改成 10 或 25但要注意按键按得频繁时变化速度会不会太慢。3.4 验证波形没有示波器也能判断占空比在变化很多初学者没有示波器烧录后只看 LED 亮度变不变很容易把“没变化”误判成“没烧录成功”。一个便宜的验证方法是把 LED 引脚外接一块电压表切换到直流电压档测量 PA0 对地的平均电压。占空比 50% 时读到的电压应该在 1.65V 左右占空比越大电压越接近 3.3V。这个读数本质就是占空比 × 高电平电压所以即使没有示波器也能确认 PWM 真在输出。如果手头有示波器重点看两个地方通道打开后有没有一高一低的方波频率是不是接近 1kHz按下按键后方波的“高电平时间”有没有变宽或变窄。我在第一次调这个工程时占空比从 500 加到 999LED 亮度变化不明显后来用示波器一看原来是 PA0 配置成了普通推挽输出方波完全出不来只有 GPIO 高低电平翻转的假象。4. 避坑与排查五个让新手翻车的地方4.1 硬件连接与极性相关的两个坑现象一LED 在占空比为 0% 时微亮占空比调到 100% 反而熄灭。原因LED 接法用了“共阳”。当 LED 阳极接电源、阴极接 PA0 时PA0 输出低电平才能让 LED 导通。PWM2 模式下CCR 越大低电平时间越短LED 平均电流越小所以占空比和亮度是反的。我最初拿到别人的示例工程发现波形和亮度正好反比例最后查图才发现 LED 板载是共阳接法。解决选一种接法并坚持。如果是共阳接法把初始化里的OCPolarity改为TIM_OCPOLARITY_LOW同时注意逻辑运算按下增加键亮度仍然是增大的因为占空比只是数字大小真正决定亮度的是极性设置。我更建议直接改硬件为高有效即 LED 阳极经电阻接 PA0、阴极接地这样思维负担小。现象二按键按下瞬间 LED 亮度会跳一下尤其是靠近 LED 的按键。原因机械按键没有硬件滤波按键线太长相当于一根天线按下时产生的抖动干扰耦合到了 PWM 输出引脚。另外如果按键直接用内部上拉内部上拉电阻大约 40kΩ抗干扰能力有限。解决在按键两端并一个 100nF 电容同时外部加上 4.7kΩ 上拉电阻到 3.3V。这样电平变化边沿变缓配合软件去抖基本能消除亮度跳动。如果按键离 MCU 很远可以用双绞线并缩短长度或者把 LED PWM 引脚和按键布线拉开距离。4.2 定时器与 PWM 配置中的两个坑现象三PA0 完全没有波形读寄存器又显示 PWM 已经启动。原因引脚复用配置错误。很多入门者把 PA0 配成了GPIO_MODE_OUTPUT_PP导致引脚由 GPIO 模块接管定时器的输出信号被隔离。另一种可能是该型号 MCU 的 TIM2_CH1 复用引脚需要重映射比如在某些封装上 PA0 不是默认复用脚。解决打开数据手册的“Alternate Function Mapping”表确认 PA0 对应的 AF 编号。对于 HAL 库配置模式用GPIO_MODE_AF_PP不需要手动调 AF 编号因为HAL_TIM_PWM_Init会自动把引脚绑定到外设。如果用的是标准外设库记得调用GPIO_PinRemapConfig(GPIO_Remap_TIM2, ENABLE)检查是否需要重映射。现象四占空比调节到 100% 后LED 反而灭掉而且继续按键时亮度异常。原因CCR 值超过了 ARR。比如duty是 uint16_t加到 1100而 ARR 只有 999硬件的 PWM 发生器行为会变得不确定有些内核会输出恒定高电平有些则全低。占空比越界是最隐蔽的翻车点表面看“按键有反应”但亮度逻辑完全乱了。解决每次修改比较寄存器之前做钳制。更稳妥的办法是使用闭环函数void Set_Duty(uint16_t value) { if (value htim2.Init.Period) value htim2.Init.Period; __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, value); }这样所有调用点统一处理边界不会出现多个地方都写一遍 if 导致漏改。我后来重构代码时发现原来主循环里判断上下限的代码写了两份按键扫描函数里也有一份最后导致一处生效一处无效改成一个函数后问题立刻消失。4.3 按键扫描与主循环阻塞的一个坑现象五按键按下去LED 亮度变化正常但主循环里如果还跑了系统调度整个程序会卡顿按下按键要等几百毫秒才有反应。原因Key_Scan里用了HAL_Delay(10)这个函数是阻塞延时。如果按键被按住不放HAL_Delay会持续占用 CPU而 PWM 是硬件输出不受影响但其他软件任务会被堵住。解决把按键扫描改成非阻塞状态机或者用定时器中断每 1ms 扫描一次。考虑到这个项目只做调光阻塞也能用但如果以后要加 OLED 显示、串口打印就必须把去抖延时代替成时间戳判断。一个简单的非阻塞扫描方式是利用 SysTick记录上次扫描时间间隔超过 15ms 再去读引脚这样 CPU 在等待期间可以去处理其他事情。5. 进阶让调节体验更好的两个技巧5.1 亮度记忆断电后保存当前占空比按键调好的亮度一断电就丢了很多场景并不友好。STM32 没有独立 EEPROM 的系列可以把最后一块 Flash 页面当作存储区用。基本思路是检测到占空比变化后先把新值写入一个临时位置然后触发备份到固定地址。掉电重启时上电初始化阶段读出该地址校验范围后赋给 duty。因为 Flash 写入寿命有限不能每次按键都写通常做法是只有停止按键 2 秒后才写一次。用定时器记录“最后一次按键时间”当主循环发现当前时间减上次时间超过 2 秒就把 duty 写入 Flash。我实际做下来按键后快速连续调节不会真正写 Flash只有停下来才触发一次寿命和功耗都更可控。资源包里提供了这套 Flash 模拟 EEPROM 的例程但要注意擦写地址需要按页对齐。5.2 人眼感知更线性的亮度曲线很多人的第一版调光是线性占空比加成 50但调到低亮度区域时会发现从 0% 到 10% 变化特别明显而 50% 到 60% 几乎看不出变化。这是因为人眼对光强的感知不是线性而是近似 Gamma 曲线标准 Gamma 值大约是 2.2。也就是说物理亮度要上升为原来的 4 倍人眼才会觉得亮度变成原来的 2 倍。解决办法是把线性占空比映射到 Gamma 曲线。如果占空比支持 0 到 255 级每一级的比较寄存器值可以预先计算uint16_t gamma_table[256]; for (int i 0; i 256; i) { float normalized (float)i / 255.0f; gamma_table[i] (uint16_t)(pow(normalized, 2.2f) * 999.0f); }按键调节时只改“亮度档位”level真正写入比较寄存器的值查表得到__HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, gamma_table[level]);。这样低亮度区域变化被放慢中高亮度区域变化提速整体看起来均匀很多。我第一次用线性调光时总觉得“暗部太跳、亮部不动”换了 Gamma 表之后立刻顺眼。这个技巧也适用于呼吸灯或者渐变动画。如果你把查找表放到 Flash 数组里而不用每次算pow运行时只要查一次表。资源包里我已经把 256 级表生成好直接取过去就能用。最后留个提醒按键去抖和 PWM 极性是这种项目最容易踩的两座山。那次因为没有确认 LED 是共阳还是共阴我硬是花了两个小时对比波形最后才发现OCPolarity设反了。从那以后我每次拿到新板子都强制用万用表先量一遍 LED 正负极再写初始化代码。希望帮到你少走一次弯路。本文还有配套的精品资源点击获取