4、按键输入与消抖:机械按键原理、硬件消抖与软件消抖、状态机实现

按键,是嵌入式系统和用户交互最朴素的方式。剃须刀的开关、档位切换、锁定解锁,都离不开它。但机械按键有个“老毛病”——抖动。今天我们就来聊聊怎么治这个毛病。

4.1 机械按键原理:按下那一刻发生了什么?

机械按键,说白了就是两个金属触点。按下时接通,松开时断开。听起来很简单对吧?

但实际不是这样的。你想想看,金属片碰撞的时候,会像乒乓球一样弹跳几下。这个过程叫“抖动”。

抖动的时间一般在 5ms 到 20ms 之间。具体多长,取决于按键的材质和做工。我见过最差的按键,抖了将近 30ms 才稳定下来。

抖动期间,电平会反复跳变。如果你直接读取这个信号,一次按下可能被识别成十几次。剃须刀就会乱切换档位,用户体验极差。

关键点:按键按下和松开的瞬间,都会产生抖动。需要做消抖处理。

4.2 硬件消抖:用电路解决问题

硬件消抖,就是用电阻电容搭一个低通滤波器。把高频的抖动信号滤掉,只留下稳定的电平变化。

最简单的做法:按键串联一个电阻(比如 10kΩ),并联一个电容(比如 0.1μF)。RC 时间常数 τ = R × C,算出来大约是 1ms。这个值可以覆盖大部分按键的抖动时间。

我个人习惯用 10kΩ + 0.1μF 的组合。成本低,效果也够用。但要注意,电容不能太大,否则按键响应会变慢。你按下去要等半天才反应,用户会骂娘的。

小技巧:如果用的是施密特触发器输入引脚,可以省掉电容。施密特触发器自带迟滞特性,能天然抑制抖动。

硬件消抖的优点是:不占 CPU 资源。缺点是:增加了 BOM 成本和 PCB 面积。对于剃须刀这种成本敏感的产品,很多厂商会选择软件消抖。

4.3 软件消抖:用代码解决问题

软件消抖,就是通过延时或采样来判断按键是否真的被按下了。

最原始的方法:检测到电平变化后,延时 20ms,再读一次。如果电平一致,就认为是有效按下。

但这种方法有个问题:延时期间 CPU 被阻塞了。剃须刀的主控还要做电机控制、电池检测、防水检测,哪有空等你 20ms?

所以我推荐用“定时采样法”。

具体做法:

  • 每隔 5ms 读一次按键状态
  • 连续读到 4 次(即 20ms)相同的状态,才认为按键有效
  • 否则认为是抖动,丢弃

这样 CPU 不用傻等,每 5ms 花几微秒读一下引脚就行。效率高得多。

我曾经在一个项目中用过这个方案。按键数量有 8 个,全部用定时采样法处理。主控是 STM32F030,跑 48MHz,一点压力都没有。

4.4 状态机实现:优雅地处理按键

如果你只是做简单的消抖,用上面的方法就够了。但如果你要处理长按、短按、双击、连发,那就得上状态机了。

状态机,说白了就是给按键定义一个“生命周期”。

我常用的按键状态机有 4 个状态:

状态 含义 触发条件
IDLE 空闲,按键未按下 初始状态,或按键松开后
PRESS_DETECT 检测到按下,正在消抖 检测到低电平(假设低有效)
PRESS_CONFIRM 确认按下,等待松开 消抖完成,电平稳定为低
RELEASE_DETECT 检测到松开,正在消抖 检测到高电平

代码实现大概是这样的:

typedef enum {
    KEY_IDLE,
    KEY_PRESS_DETECT,
    KEY_PRESS_CONFIRM,
    KEY_RELEASE_DETECT
} KeyState_t;

KeyState_t keyState = KEY_IDLE;
uint8_t debounceCnt = 0;

void Key_Scan(void) {
    uint8_t level = GPIO_ReadPin(KEY_PIN);
    
    switch(keyState) {
        case KEY_IDLE:
            if(level == 0) {  // 检测到按下
                keyState = KEY_PRESS_DETECT;
                debounceCnt = 0;
            }
            break;
            
        case KEY_PRESS_DETECT:
            if(level == 0) {
                debounceCnt++;
                if(debounceCnt >= 4) {  // 连续4次低电平,确认按下
                    keyState = KEY_PRESS_CONFIRM;
                    Key_Event(KEY_PRESS);  // 触发按下事件
                }
            } else {
                keyState = KEY_IDLE;  // 抖动,回到空闲
            }
            break;
            
        case KEY_PRESS_CONFIRM:
            if(level == 1) {  // 检测到松开
                keyState = KEY_RELEASE_DETECT;
                debounceCnt = 0;
            }
            break;
            
        case KEY_RELEASE_DETECT:
            if(level == 1) {
                debounceCnt++;
                if(debounceCnt >= 4) {  // 连续4次高电平,确认松开
                    keyState = KEY_IDLE;
                    Key_Event(KEY_RELEASE);  // 触发松开事件
                }
            } else {
                keyState = KEY_PRESS_CONFIRM;  // 抖动,回到按下状态
            }
            break;
    }
}

注意:这个函数需要在定时器中断或主循环中周期调用。建议调用间隔 5ms,这样消抖总时间就是 20ms。

有了状态机,你可以轻松扩展出长按检测。比如在 PRESS_CONFIRM 状态里加一个计时器,超过 1 秒就触发长按事件。

我曾经在一个剃须刀项目里,用这个状态机实现了:短按开关机、长按 2 秒进入旅行锁模式、双击切换档位。用户反馈说操作很顺手,没有误触。

4.5 避坑指南

做按键处理这么多年,踩过的坑不少。分享几个给你:

  • 不要用 delay() 做消抖。 我曾经在一个项目里用了 delay(20),结果按键响应正常了,但电机控制出现了卡顿。因为 delay 阻塞了主循环。
  • 注意按键的电气特性。 有些按键在潮湿环境下会漏电。剃须刀是防水产品,按键周围可能有水汽。建议在按键引脚加一个上拉电阻,并开启内部上拉。
  • 消抖时间不是越长越好。 50ms 的消抖虽然更稳定,但用户会觉得按键“迟钝”。20ms 是业界公认的黄金值。
  • 考虑低功耗场景。 剃须刀电池容量有限。如果按键扫描太频繁,会浪费电量。我一般用 10ms 的扫描间隔,配合休眠唤醒机制。

嗯,按键消抖看起来是个小问题,但处理不好,整个产品的体验都会打折扣。希望今天的分享对你有帮助。