STM32按键输入详解:4种消抖方法对比
按键是最基础的输入方式,但"消抖"这件事坑了无数新手。
这篇讲清楚为什么需要消抖、4种消抖方法的原理和代码,最后附上长按/短判定的实战代码。
结合我做多士炉项目时的按键状态机经验,帮你彻底搞定按键。
一、为什么按键需要消抖?
1.1 机械抖动现象
按键是机械结构,按下和松开的瞬间,金属簧片会弹跳,产生一连串通断:
理想波形:
┌────────────┐
│ │
──┘ └──
↑按下 ↑松开
实际波形(抖动):
┌┐ ┌┐┌┐
│ ││ ││ ┌────────────┐
──┘ └┘ └┘ ┘ └──
↑抖动期间 ↑稳定按下
(几ms内反复通断)
抖动时间通常5-20ms,期间CPU可能采样到多次"按下-松开",导致按一次被识别成按了好几次。
1.2 不消抖的后果
/* 笨办法:直接读引脚 */
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == 0) {
count++; /* 按一次想+1,结果+了3-5次 */
}
我做多士炉时,一开始没消抖,按一下档位跳了3格,用户体验极差。
二、GPIO输入配置
按键接PA0,一端接GPIO,一端接GND(低电平有效)。
2.1 CubeMX配置
PA0 → GPIO_Input
GPIO Pull-up/Pull-down: Pull-up (上拉,松开时为高电平)
User Label: KEY
2.2 两种接法
【上拉接法】(常用) 【下拉接法】
3.3V PA0 ──[10k下拉]── GND
│[内部上拉] │
PA0 按键
│ │
按键 3.3V
│
GND
按下=低电平(0) 按下=高电平(1)
松开=高电平(1) 松开=低电平(0)
推荐上拉接法:按下读0,松开读1,符合"低电平有效"的直觉。
三、4种消抖方法
方法1:延时消抖(最简单,但阻塞)
uint8_t key_scan(void)
{
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == 0) { /* 检测到按下 */
HAL_Delay(20); /* 延时20ms消抖 */
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == 0) { /* 再次确认 */
while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == 0); /* 等松开 */
return 1; /* 返回按键有效 */
}
}
return 0;
}
| 优点 | 缺点 |
|---|---|
| 代码最简单 | HAL_Delay阻塞,主循环卡死 |
| 新手友好 | while等松开期间无法做其他事 |
适用:最简单的demo。实际项目别用,会卡死其他任务。
方法2:定时器扫描法(推荐,非阻塞)
用定时器每10ms扫描一次按键,连续检测到几次相同状态才算稳定。
#define KEY_SCAN_MS 10 /* 扫描周期10ms */
#define KEY_STABLE 2 /* 连续2次相同=稳定(20ms) */
static uint8_t key_state = 1; /* 当前稳定状态 */
static uint8_t key_last = 1; /* 上次读取状态 */
static uint8_t key_count = 0; /* 连续相同计数 */
/* 在10ms定时中断里调用 */
uint8_t key_scan_nonblock(void)
{
uint8_t cur = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
if (cur == key_last) {
if (key_count < KEY_STABLE) key_count++;
if (key_count == KEY_STABLE && cur != key_state) {
key_state = cur; /* 状态变化,确认 */
if (key_state == 0) return 1; /* 按下事件 */
}
} else {
key_count = 0; /* 状态不同,重新计数 */
}
key_last = cur;
return 0;
}
| 优点 | 缺点 |
|---|---|
| 非阻塞,不卡主循环 | 需要定时器配合 |
| 可同时扫描多个按键 | 代码稍复杂 |
适用:实际项目首选。我在多士炉项目里就是用定时器扫描法。
方法3:状态机法(最严谨)
多士炉项目里用的方法,三态状态机:
起始态 ──检测到按下──> 确认态 ──10ms后仍按下──> 释放态
↑ │
└──────────检测到松开──────────────────────────┘
typedef enum {
KEY_STATE_IDLE, /* 起始态 */
KEY_STATE_CONFIRM, /* 确认态 */
KEY_STATE_RELEASE /* 释放态 */
} key_state_t;
static key_state_t state = KEY_STATE_IDLE;
static uint8_t timer_cnt = 0;
#define DEBOUNCE_MS 10 /* 消抖时间 */
uint8_t key_scan_fsm(void)
{
uint8_t cur = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
uint8_t event = 0;
switch (state) {
case KEY_STATE_IDLE:
if (cur == 0) { /* 检测到按下 */
state = KEY_STATE_CONFIRM;
timer_cnt = 0;
}
break;
case KEY_STATE_CONFIRM:
timer_cnt++;
if (timer_cnt >= DEBOUNCE_MS / KEY_SCAN_MS) { /* 10ms后 */
if (cur == 0) { /* 仍按下,确认有效 */
event = 1; /* 触发按键事件 */
state = KEY_STATE_RELEASE;
} else { /* 松开了,是抖动 */
state = KEY_STATE_IDLE;
}
}
break;
case KEY_STATE_RELEASE:
if (cur == 1) { /* 等待松开 */
state = KEY_STATE_IDLE;
}
break;
}
return event;
}
| 优点 | 缺点 |
|---|---|
| 逻辑最严谨 | 代码最长 |
| 容易扩展(长按/双击) | 理解成本高 |
适用:对按键要求高的项目,如需要区分长按短按、双击。多士炉项目就用这个。
方法4:计数法(最省事)
不用状态机,用一个计数器累计"按下次数":
static uint8_t key_filter = 0; /* 滤波计数器 */
#define KEY_THRESHOLD 3 /* 连续3次检测到按下=有效 */
uint8_t key_scan_count(void)
{
/* 每10ms调用一次 */
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == 0) {
if (key_filter < KEY_THRESHOLD) {
key_filter++;
}
if (key_filter == KEY_THRESHOLD) {
return 1; /* 刚达到阈值,触发一次 */
}
} else {
key_filter = 0; /* 松开立即清零 */
}
return 0;
}
| 优点 | 缺点 |
|---|---|
| 代码极简 | 不防"松开抖动" |
| 非阻塞 | 需要定时器调用 |
适用:简单场景,不需要区分长按短按。
四、4种方法对比
| 方法 | 阻塞 | 复杂度 | 扩展性 | 推荐场景 |
|---|---|---|---|---|
| 延时法 | ❌阻塞 | ★ | 差 | demo演示 |
| 定时器扫描 | ✅非阻塞 | ★★★ | 中 | 通用首选 |
| 状态机 | ✅非阻塞 | ★★★★ | 强 | 长按/双击 |
| 计数法 | ✅非阻塞 | ★★ | 中 | 简单场景 |
我的建议:新手先用定时器扫描法,需要长按短按再上状态机。延时法只用来理解原理,别用在项目里。
五、实战:长按与短按区分
多士炉项目里,短按切换档位,长按3秒进入解冻模式。用状态机实现:
typedef enum {
KEY_IDLE,
KEY_PRESSED,
KEY_LONG_TRIGGERED
} key_long_state_t;
static key_long_state_t lstate = KEY_IDLE;
static uint16_t press_time = 0;
#define LONG_PRESS_MS 1500 /* 长按阈值1.5秒 */
#define SCAN_MS 10 /* 扫描周期 */
/* 每10ms调用一次,返回:0=无事件,1=短按,2=长按 */
uint8_t key_scan_long_short(void)
{
uint8_t cur = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
uint8_t event = 0;
switch (lstate) {
case KEY_IDLE:
if (cur == 0) {
lstate = KEY_PRESSED;
press_time = 0;
}
break;
case KEY_PRESSED:
if (cur == 0) {
press_time += SCAN_MS;
if (press_time >= LONG_PRESS_MS) {
event = 2; /* 长按事件 */
lstate = KEY_LONG_TRIGGERED;
}
} else {
/* 松开了,且没达到长按阈值 → 短按 */
if (press_time < LONG_PRESS_MS) {
event = 1; /* 短按事件 */
}
lstate = KEY_IDLE;
}
break;
case KEY_LONG_TRIGGERED:
/* 等松开后回到空闲 */
if (cur == 1) {
lstate = KEY_IDLE;
}
break;
}
return event;
}
使用:
/* 在10ms定时中断里调用 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2) {
uint8_t evt = key_scan_long_short();
if (evt == 1) {
/* 短按:档位+1 */
toast_level++;
} else if (evt == 2) {
/* 长按:进入解冻模式 */
toast_mode = MODE_DEFROST;
}
}
}
关键点:长按在"达到阈值时"立即触发,不等松开;短按在"松开时"才触发(因为要判断按了多久)。
六、矩阵键盘扫描(进阶)
多个按键时,用矩阵键盘省引脚:4×4矩阵只需8个GPIO,能接16个按键。
6.1 原理
Col0 Col1 Col2 Col3
Row0 K1 K2 K3 K4
Row1 K5 K6 K7 K8
Row2 K9 K10 K11 K12
Row3 K13 K14 K15 K16
扫描方法:
- 4条行线输出,4条列线输入(上拉)
- 某一行输出低,其他行输出高
- 读列线,哪根列线为低,说明该行该列的按键被按下
6.2 代码
uint8_t matrix_scan(void)
{
uint8_t row, col;
uint8_t row_pins[4] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3};
uint8_t col_pins[4] = {GPIO_PIN_4, GPIO_PIN_5, GPIO_PIN_6, GPIO_PIN_7};
for (row = 0; row < 4; row++) {
/* 所有行输出高 */
for (uint8_t i = 0; i < 4; i++)
HAL_GPIO_WritePin(GPIOA, row_pins[i], GPIO_PIN_SET);
/* 当前行输出低 */
HAL_GPIO_WritePin(GPIOA, row_pins[row], GPIO_PIN_RESET);
HAL_Delay(1); /* 等电平稳定 */
for (col = 0; col < 4; col++) {
if (HAL_GPIO_ReadPin(GPIOA, col_pins[col]) == 0) {
return row * 4 + col + 1; /* 返回按键编号1-16 */
}
}
}
return 0; /* 无按键 */
}
应用:密码锁、计算器等需要多按键的场景。
七、常见踩坑
坑1:按键偶尔失灵
原因:没消抖或消抖时间太短。
解决:消抖时间设15-20ms(覆盖机械抖动周期5-20ms)。
坑2:按一次触发多次
原因:消抖后没等"松开"就返回,主循环快速扫描多次。
解决:状态机里加"释放态",或计数法用"达到阈值只触发一次"的逻辑。
坑3:长按一直触发
原因:长按判定后,没切换状态,主循环每10ms都判定为长按。
解决:长按触发后进入KEY_LONG_TRIGGERED状态,等松开才回到KEY_IDLE。
坑4:没配上拉,引脚悬空乱跳
原因:GPIO输入模式没配上拉/下拉,引脚悬空,电平不确定。
解决:CubeMX里配Pull-up(按键接GND)或Pull-down(按键接VCC)。
八、总结
| 要点 | 内容 |
|---|---|
| 消抖原因 | 机械抖动5-20ms,不消抖会多次触发 |
| 4种方法 | 延时法/定时器扫描/状态机/计数法 |
| 推荐方法 | 定时器扫描(通用)/ 状态机(复杂需求) |
| 长按短按 | 状态机+计时器,长按立即触发,短按松开触发 |
| 矩阵键盘 | 行列扫描,8个GPIO接16按键 |
一句话总结:按键消抖的核心就是**“连续多次检测确认稳定”**,定时器扫描法是性价比最高的选择。
下一篇预告:《STM32 SPI通信详解:从时序到W25Q64 Flash驱动》
如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。作者:嵌入式阿蔡
更多推荐
所有评论(0)