按键是最基础的输入方式,但"消抖"这件事坑了无数新手。
这篇讲清楚为什么需要消抖、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

扫描方法:

  1. 4条行线输出,4条列线输入(上拉)
  2. 某一行输出低,其他行输出高
  3. 读列线,哪根列线为低,说明该行该列的按键被按下

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驱动》

如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。

作者:嵌入式阿蔡

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐