STM32 零基础可移植教程 05:按键消抖,为什么按一次会触发好几次
上一篇我们已经能读到按键电平了:按住按键,LED 亮;松开按键,LED 灭。
这说明 GPIO 输入、上拉/下拉、按键有效电平都已经跑通。
但你后面很快会遇到一个新问题:
bash
明明只按了一次,程序却像按了好几次
比如你想实现"按一下 LED 翻转一次",结果 LED 可能亮了又灭,或者连续翻转几次。代码看起来没问题,按键也确实只按了一下,但程序就是多触发。
这不是 STM32 特有的问题,也不是 HAL 库的锅。机械按键本来就会抖。
这一篇只解决一个目标:
bash
用软件消抖,让按键按下一次只触发一次 LED 翻转
外部中断、长按、双击、组合键先不讲。先把最基础的"按下一次,识别一次"做稳。
本篇目标
最终现象:
bash
每按下一次按键,LED 翻转一次
按住不放,LED 不会一直翻转
松开后再按,才会再次翻转
本篇用到的外设:
bash
GPIO Input
GPIO Output
SysTick / HAL_GetTick
本篇跑通标准:
-
Keil 编译通过;
-
程序能下载到开发板;
-
按键按下一次,LED 只翻转一次;
-
按住按键不松手,LED 不会连续翻转;
-
能说清楚"按键抖动"和"消抖时间"是什么意思。
准备工作
你需要准备:
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意 STM32 开发板
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
LED
|
沿用第 02 篇 LED 工程
|
|
按键
|
沿用第 04 篇按键输入工程
|
|
原理图
|
确认按键引脚、上拉/下拉、有效电平
|
建议直接从上一篇 04_key_input 复制一份,改名为:
bash
05_key_debounce
这样 LED 和按键的 CubeMX 配置都能保留,我们只升级按键应用层代码。
为什么按键会抖
机械按键不是理想开关。
你手指按下去时,里面的金属触点不是"一瞬间稳定接通",而是会在很短时间内反复接触、弹开、再接触。
在示波器或逻辑分析仪上看,按键电平可能不是这样:
bash
松开 ---------------- 按下
高电平 -------------- 低电平
而是这样:
bash
高 -> 低 -> 高 -> 低 -> 高 -> 低 -> 稳定低
这段乱跳通常只持续几毫秒到几十毫秒。
对人来说,这还是一次按下;对 MCU 来说,它可能已经看到好几个边沿了。
所以我们需要做消抖。

本篇采用哪种消抖方式
按键消抖有很多种写法:
-
简单延时消抖;
-
计数消抖;
-
状态机消抖;
-
定时器周期扫描;
-
外部中断 + 延时确认。
新手最容易想到的是:
bash
if
(按键按下)
{
HAL_Delay(
20
);
if
(按键仍然按下)
{
确认按下;
}
}
这个思路能用,但它有一个问题:HAL_Delay() 会堵住主循环。后面你的工程里如果还有串口、蜂鸣器、传感器、通信任务,主循环被堵住就不舒服了。
所以这一篇我们用一个更适合长期扩展的写法:
bash
每次主循环调用 App_Key_Scan()
函数内部用 HAL_GetTick() 判断电平稳定了多久
稳定超过 20 ms 后,才产生一次按下/松开事件
这种写法不需要在按键函数里长时间阻塞。
这里有一个地方很容易想岔。
很多人第一次理解消抖,会觉得:
bash
检测到按键按下
等它超过 20 ms
然后翻转 LED
这个理解只对了一半。
因为如果代码只是判断"按键已经按下超过 20 ms",那手一直按着时,这个条件会一直成立。主循环每扫描一次,都可能再次翻转 LED。
也就是说,下面这种思路仍然不够:
bash
if
(按键按下超过
20
ms)
{
App_LED_Toggle();
}
它跳过了抖动瞬间,但没有解决"长按时重复触发"的问题。
真正稳定的写法应该是:
bash
检测到电平变化
等待 20 ms
确认这个电平仍然稳定
如果稳定状态从"松开"变成"按下"
才产生一次"按下事件"
重点是最后这句:
bash
状态变了,才产生事件
按键一直按着时,它的稳定状态一直是"按下",但它不是每一轮扫描都在"刚刚按下"。所以 LED 只应该在"松开 -> 按下"这个变化发生后翻转一次。
换句话说,消抖里面其实有两件事:
|
动作
|
解决的问题
|
| --- | --- |
|
等 20 ms 再确认
|
跳过机械触点刚接触时的抖动
|
|
判断状态变化
|
避免按住不放时一直触发
|
所以这篇代码里才会同时出现两个概念:
bash
稳定状态:现在到底是按下还是松开
按键事件:这一次有没有刚刚按下或刚刚松开
如果只看电平,就容易写成长按一直触发。
如果把稳定电平转换成事件,就能做到:
bash
按下一次 -> 产生一次按下事件 -> LED 翻转一次
一直按住 -> 不再产生新事件
松开后再按 -> 再产生下一次按下事件
这就是本篇消抖代码真正想解决的问题。
硬件连接
硬件连接沿用上一篇。
按键常见有两种接法。
第一种:按键一端接 GPIO,一端接 GND。
bash
GPIO ---- 按键 ---- GND
这种情况下通常使用上拉:
bash
松开:读到 1
按下:读到 0
第二种:按键一端接 GPIO,一端接 3.3V。
bash
GPIO ---- 按键 ---- 3.3V
这种情况下通常使用下拉:
bash
松开:读到 0
按下:读到 1
本篇代码默认按键是低电平有效:
bash
#define APP_KEY_PRESSED_LEVEL GPIO_PIN_RESET
如果你的按键是高电平有效,后面改成 GPIO_PIN_SET 即可。

CubeMX 配置步骤
1. 保留 LED 输出配置
LED 的配置沿用第 02 篇:
|
配置项
|
推荐值
|
| --- | --- |
|
GPIO mode
|
GPIO_Output
|
|
User Label
|
LED
|
|
初始电平
|
按 LED 有效电平设置为默认灭
|
这一篇用 LED 来显示按键事件。
按键每确认一次"按下事件",LED 翻转一次。

2. 保留按键输入配置
按键配置沿用第 04 篇:
|
配置项
|
推荐值
|
| --- | --- |
|
GPIO mode
|
GPIO_Input
|
|
User Label
|
KEY
|
|
Pull-up/Pull-down
|
按原理图选择
|
如果你的按键一端接 GND,通常选择:
bash
Pull-up
如果你的按键一端接 3.3V,通常选择:
bash
Pull-down

3. 生成 Keil 工程
配置确认后点击:
bash
GENERATE CODE
然后打开 Keil 工程,先编译一次。

Keil 工程生成和编译
打开 Keil 后,先编译:
bash
Build / F7
确认输出里没有错误:
bash
0 Error(s)

这一篇主要修改 app_key.h/.c 和 main.c,CubeMX 配置变化不大。
完整代码
这一篇我们升级上一篇的按键模块。
你需要保留:
bash
Core/Inc/app_led.h
Core/Src/app_led.c
然后把上一篇的 app_key.h/.c 替换成下面这个消抖版本:
bash
Core/Inc/app_key.h
Core/Src/app_key.c
1. 更新 Core/Inc/app_key.h
打开:
bash
Core/Inc/app_key.h
替换为下面代码:
bash
#ifndef APP_KEY_H
#define APP_KEY_H
#include "main.h"
typedef
enum
{
APP_KEY_LEVEL_RELEASED =
0
,
APP_KEY_LEVEL_PRESSED =
1
} App_KeyLevel;
typedef
enum
{
APP_KEY_EVENT_NONE =
0
,
APP_KEY_EVENT_PRESSED,
APP_KEY_EVENT_RELEASED
} App_KeyEvent;
void App_Key_Init(void)
;
App_KeyLevel App_Key_ReadLevel(void)
;
App_KeyEvent App_Key_Scan(void)
;
#endif
这里分成两个概念:
|
名称
|
含义
|
| --- | --- |
| App_KeyLevel |
当前稳定状态:按下还是松开
|
| App_KeyEvent |
这一次扫描有没有新事件:刚按下、刚松开、无事件
|
为什么要分开?
因为"当前按着"不等于"刚刚按下"。
按住不放时,状态一直是按下,但事件只应该触发一次。
2. 更新 Core/Src/app_key.c
打开:
bash
Core/Src/app_key.c
替换为下面代码:
bash
#include "app_key.h"
/* * KEY_GPIO_Port and KEY_Pin are generated by CubeMX in main.h. * Set the key pin User Label to KEY in CubeMX. */
#ifndef KEY_GPIO_Port
#error "KEY_GPIO_Port is not defined. Set the key pin User Label to KEY in CubeMX."
#endif
#ifndef KEY_Pin
#error "KEY_Pin is not defined. Set the key pin User Label to KEY in CubeMX."
#endif
/* * Many key circuits are active-low: * released -> GPIO_PIN_SET * pressed -> GPIO_PIN_RESET * * If your key is active-high, change this macro to GPIO_PIN_SET. */
#ifndef APP_KEY_PRESSED_LEVEL
#define APP_KEY_PRESSED_LEVEL GPIO_PIN_RESET
#endif
#ifndef APP_KEY_DEBOUNCE_MS
#define APP_KEY_DEBOUNCE_MS 20u
#endif
static
App_KeyLevel s_stable_level = APP_KEY_LEVEL_RELEASED;
static
App_KeyLevel s_last_sample_level = APP_KEY_LEVEL_RELEASED;
static
uint32_t
s_last_change_tick =
0u
;
static App_KeyLevel App_Key_ReadRawLevel(void)
{
GPIO_PinState level = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
if
(level == APP_KEY_PRESSED_LEVEL)
{
return
APP_KEY_LEVEL_PRESSED;
}
return
APP_KEY_LEVEL_RELEASED;
}
void App_Key_Init(void)
{
App_KeyLevel level = App_Key_ReadRawLevel();
s_stable_level = level;
s_last_sample_level = level;
s_last_change_tick = HAL_GetTick();
}
App_KeyLevel App_Key_ReadLevel(void)
{
return
s_stable_level;
}
App_KeyEvent App_Key_Scan(void)
{
App_KeyLevel current_sample = App_Key_ReadRawLevel();
uint32_t
now = HAL_GetTick();
if
(current_sample != s_last_sample_level)
{
s_last_sample_level = current_sample;
s_last_change_tick = now;
}
if
((now - s_last_change_tick) >= APP_KEY_DEBOUNCE_MS)
{
if
(s_stable_level != s_last_sample_level)
{
s_stable_level = s_last_sample_level;
if
(s_stable_level == APP_KEY_LEVEL_PRESSED)
{
return
APP_KEY_EVENT_PRESSED;
}
return
APP_KEY_EVENT_RELEASED;
}
}
return
APP_KEY_EVENT_NONE;
}
这段代码不长,但比上一篇多了状态。
核心逻辑是:
-
每次读取一次按键原始电平;
-
如果发现电平变化,就记录变化时间;
-
如果这个电平稳定超过
APP_KEY_DEBOUNCE_MS; -
并且稳定状态真的发生变化;
-
才返回一次按下或松开事件。
默认消抖时间是:
bash
#define APP_KEY_DEBOUNCE_MS 20u
大多数普通按键用 10 ms 到 30 ms 都可以。你可以先用 20 ms。
3. 确认 app_key.c 加入 Keil 工程
如果你是从上一篇工程复制过来的,app_key.c 大概率已经在 Keil 工程里。
但还是建议检查一下:
bash
Application/User/Core
里面应该能看到:
bash
app_key.c
app_led.c
如果没有,就右键 Application/User/Core,选择:
bash
Add Existing Files to Group 'Application/User/Core'
然后添加:
bash
Core/Src/app_key.c

main.c 调用方式
这一篇的 main.c 比上一篇更清楚:主循环不停扫描按键,一旦检测到"按下事件",就翻转 LED。
1. Includes 区域
找到:
bash
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */
改成:
bash
/* USER CODE BEGIN Includes */
#include "app_led.h"
#include "app_key.h"
/* USER CODE END Includes */
2. 初始化区域
确保 MX_GPIO_Init() 已经在前面执行:
bash
MX_GPIO_Init();
然后在 USER CODE BEGIN 2 里添加:
bash
/* USER CODE BEGIN 2 */
App_LED_Init();
App_Key_Init();
/* USER CODE END 2 */
App_Key_Init() 会读取一次当前按键状态,把它作为初始稳定状态。
3. while 循环区域
找到:
bash
while
(
1
)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
改成:
bash
while
(
1
)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
if
(App_Key_Scan() == APP_KEY_EVENT_PRESSED)
{
App_LED_Toggle();
}
HAL_Delay(
5
);
/* USER CODE END 3 */
}
这里要注意一个细节:
bash
HAL_Delay(
5
);
不是消抖本身,它只是让主循环每隔大约 5 ms 扫描一次按键。
真正的消抖判断在 App_Key_Scan() 里,由 HAL_GetTick() 和 APP_KEY_DEBOUNCE_MS 完成。
不要在这个 while 里写很长的延时,比如:
bash
HAL_Delay(
1000
);
如果主循环 1 秒才扫描一次按键,那短按很容易被错过。
编译、下载和验证
代码加完后,先编译:
bash
Build / F7
如果没有错误,再下载:
bash
Download
下载后观察现象:
bash
按下一次按键 -> LED 翻转一次
按住不放 -> LED 不继续翻转
松开后再按 -> LED 再翻转一次

如果你用 Keil 下载后程序没有自动跑,但按复位键后能跑,先按复位键验证外设现象。这个问题不影响本篇按键消抖逻辑。
移植到其他板子的修改点
这篇的移植点主要有 6 个。
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
按键引脚
|
不同板子的按键接到不同 GPIO
|
CubeMX Pinout 页面
|
|
User Label
|
代码依赖 KEY_Pin 和 KEY_GPIO_Port
|
CubeMX GPIO 页面,标签设为 KEY
|
|
上拉/下拉
|
按键接 GND 还是 3.3V 不同
|
CubeMX GPIO Pull-up/Pull-down
|
|
按下有效电平
|
有些按下读 0,有些按下读 1
| APP_KEY_PRESSED_LEVEL |
|
消抖时间
|
不同按键抖动时间不同
| APP_KEY_DEBOUNCE_MS |
|
主循环扫描周期
|
扫描太慢会错过短按
| while (1)
里的 HAL_Delay(5)
|
推荐移植顺序:
-
看原理图,确认按键接到哪个 MCU 引脚;
-
确认按键按下时是高电平还是低电平;
-
CubeMX 配置
GPIO_Input; -
User Label 填
KEY; -
按原理图选择 Pull-up 或 Pull-down;
-
修改
APP_KEY_PRESSED_LEVEL; -
先用 20 ms 消抖;
-
如果误触发,再适当加到 30 ms 或 50 ms。
常见问题排查
1. 按一下还是触发好几次
优先检查:
|
优先检查
|
具体方法
|
| --- | --- |
|
是否使用了 App_Key_Scan()
|
不要继续用上一篇的 App_Key_Read() 直接判断
|
|
是否只处理 APP_KEY_EVENT_PRESSED
|
按住状态不是事件,事件只应触发一次
|
|
消抖时间是否太短
|
把 APP_KEY_DEBOUNCE_MS 从 20 改到 30 或 50
|
|
主循环是否有其它长延时
|
避免 HAL_Delay(1000) 这类长阻塞
|
2. 按下没反应
优先检查:
-
按键引脚是否选错;
-
Pull-up/Pull-down 是否选反;
-
APP_KEY_PRESSED_LEVEL是否和硬件一致; -
app_key.c是否加入 Keil 工程; -
App_Key_Init()是否放在MX_GPIO_Init()后面; -
App_Key_Scan()是否在while (1)里反复调用。
3. 逻辑反了:松开时触发
大概率是有效电平写反。
打开 app_key.c,找到:
bash
#define APP_KEY_PRESSED_LEVEL GPIO_PIN_RESET
如果你的按键按下读到高电平,改成:
bash
#define APP_KEY_PRESSED_LEVEL GPIO_PIN_SET
4. 长按时 LED 还是一直翻转
检查你的主循环是不是写成了这样:
bash
if
(App_Key_ReadLevel() == APP_KEY_LEVEL_PRESSED)
{
App_LED_Toggle();
}
这种写法判断的是"当前是否按着",不是"刚刚按下事件"。
本篇应该使用:
bash
if
(App_Key_Scan() == APP_KEY_EVENT_PRESSED)
{
App_LED_Toggle();
}
5. 短按容易丢
如果你按得比较快,程序没反应,优先看主循环里有没有长时间阻塞。
比如:
bash
HAL_Delay(
1000
);
这会让主循环 1 秒才回来一次,短按很容易错过。
先改成:
bash
HAL_Delay(
5
);
后面工程复杂了,可以用定时器周期扫描或 RTOS 任务来处理按键。
6. undefined symbol App_Key_Scan
通常是 Keil 里编译的还是旧文件,或者 app_key.c 没有加入工程。
检查:
-
Core/Src/app_key.c是否已经替换成本文新版; -
Keil 工程树里是否有
app_key.c; -
是否重新编译了整个工程。
本篇小结
这一篇我们把按键从"读电平"升级到了"识别事件"。
你现在至少应该知道:
-
机械按键会抖,按一次可能产生多个电平跳变;
-
消抖不是玄学,本质是等待电平稳定一小段时间;
-
App_Key_ReadLevel()表示当前稳定状态; -
App_Key_Scan()表示这次扫描是否产生新事件; -
按下一次只翻转一次 LED,应该处理
APP_KEY_EVENT_PRESSED; -
主循环不能被长时间
HAL_Delay()堵住,否则会影响按键扫描; -
移植时重点改按键引脚、上拉/下拉、有效电平和消抖时间。
下一篇我们可以继续讲:
STM32 外部中断按键:不用一直在 while 里盯着按键。
不过在进入外部中断之前,建议你先把这一篇跑通。因为后面即使用中断,机械按键抖动这个问题也不会消失,只是触发方式变了。