4、按键输入与消抖:机械按键原理、软件消抖(延时与状态机)、售货机选货按键编程

按键,是嵌入式系统和用户交互最朴素的方式。自动售货机前面板那一排排数字键、选择键,说白了就是一个个机械开关。你按下去,它导通;你松手,它断开。听起来简单吧?但这里头有个坑——机械抖动。

4.1 机械按键原理:为什么会有抖动?

机械按键的核心是两个金属簧片。按下时,簧片接触,电路导通。但簧片是金属做的,有弹性。接触瞬间,它会反弹几次才能稳定。这个过程就叫「抖动」。

我刚开始做项目时,觉得按键就是高低电平的事。结果有一次调试,发现按一次键,程序却响应了三四次。查了半天,原来是抖动在捣鬼。

抖动的持续时间一般在 5ms 到 20ms 之间。不同按键、不同按压力度,抖动时间都不一样。你想想看,如果程序在抖动期间读取电平,那读到的就是一连串的「0 1 0 1 0 1...」,完全不可靠。

核心要点: 按键按下和松开的瞬间,电平信号是不稳定的。必须等它稳定下来,才能读取有效状态。

4.2 软件消抖:延时法

最简单的消抖方法,就是「等」。检测到电平变化后,先等一段时间(比如 20ms),再读取一次。如果两次读取结果一致,就认为按键状态稳定了。

代码实现也很直白:

// 延时消抖示例
uint8_t read_key_debounce(uint8_t pin) {
    if (digitalRead(pin) == 0) {          // 检测到按下(低电平)
        delay(20);                        // 等 20ms,跳过抖动期
        if (digitalRead(pin) == 0) {      // 再次确认
            return 1;                     // 确认按下
        }
    }
    return 0;                             // 未按下或抖动误判
}

这个方法简单粗暴,但有个问题——delay(20) 会阻塞程序。在售货机这种需要同时处理多个任务的系统里,阻塞 20ms 可能意味着错过其他按键的响应,或者影响屏幕刷新。

我的建议: 如果项目里只有一两个按键,而且对实时性要求不高,延时法完全够用。但如果你要做售货机这种多按键、多任务的系统,我建议你用状态机法。

4.3 软件消抖:状态机法

状态机消抖,说白了就是「不阻塞,靠状态切换」。它把按键的整个过程拆成几个状态:

  • 状态 0:空闲 —— 按键没被按下,等待中
  • 状态 1:按下抖动 —— 检测到按下,开始计时
  • 状态 2:稳定按下 —— 抖动期结束,确认按下
  • 状态 3:松开抖动 —— 检测到松开,开始计时

每个状态都不阻塞,只做两件事:读当前电平、判断是否超时。这样主循环可以一直跑,按键检测只是其中一小步。

我在一个售货机项目里用过这个方案。当时有 12 个选货按键,如果用延时法,每个按键按下去都要等 20ms,用户体验极差。换成状态机后,所有按键都能实时响应,而且 CPU 占用率几乎没增加。

看代码:

// 状态机消抖示例
typedef enum {
    STATE_IDLE,
    STATE_PRESS_DEBOUNCE,
    STATE_PRESSED,
    STATE_RELEASE_DEBOUNCE
} KeyState;

KeyState key_state = STATE_IDLE;
uint32_t last_time = 0;
const uint32_t DEBOUNCE_MS = 20;

void key_scan(void) {
    uint8_t level = digitalRead(KEY_PIN);
    uint32_t now = millis();

    switch (key_state) {
        case STATE_IDLE:
            if (level == 0) {                     // 检测到按下
                key_state = STATE_PRESS_DEBOUNCE;
                last_time = now;
            }
            break;

        case STATE_PRESS_DEBOUNCE:
            if (now - last_time >= DEBOUNCE_MS) { // 抖动期结束
                if (digitalRead(KEY_PIN) == 0) {  // 再次确认
                    key_state = STATE_PRESSED;
                    // 触发按键事件
                    key_event(KEY_PRESSED);
                } else {
                    key_state = STATE_IDLE;       // 抖动误判,回到空闲
                }
            }
            break;

        case STATE_PRESSED:
            if (level == 1) {                     // 检测到松开
                key_state = STATE_RELEASE_DEBOUNCE;
                last_time = now;
            }
            break;

        case STATE_RELEASE_DEBOUNCE:
            if (now - last_time >= DEBOUNCE_MS) {
                if (digitalRead(KEY_PIN) == 1) {
                    key_state = STATE_IDLE;
                    key_event(KEY_RELEASED);
                } else {
                    key_state = STATE_PRESSED;    // 松开抖动误判
                }
            }
            break;
    }
}
注意: 状态机里的 millis() 是非阻塞计时函数。千万别用 delay(),否则状态机就白写了。

4.4 售货机选货按键编程实战

好了,理论讲完,咱们来点实际的。自动售货机的选货按键,一般是一个 4x4 的矩阵键盘。为什么用矩阵?因为节省 IO 口。16 个按键如果用独立 IO,需要 16 个引脚。矩阵键盘只需要 8 个(4 行 + 4 列)。

扫描原理很简单:逐行拉低,逐列读取。比如先拉低第 1 行,然后读 4 列的电平。如果某列是低电平,说明第 1 行、该列的按键被按下了。

我当年做第一台售货机时,矩阵扫描和消抖是分开写的。后来发现,把消抖逻辑直接嵌入扫描过程,代码更简洁。每次扫描时,只记录当前按键的「原始状态」,然后在主循环里用状态机处理消抖。

核心代码框架:

// 矩阵键盘扫描 + 状态机消抖
#define ROWS 4
#define COLS 4

uint8_t key_map[ROWS][COLS] = {
    {1, 2, 3, 10},   // 10 表示「确认」
    {4, 5, 6, 11},   // 11 表示「取消」
    {7, 8, 9, 12},   // 12 表示「退币」
    {13, 0, 14, 15}  // 0 表示「取消」, 13-15 预留
};

void matrix_scan(void) {
    for (uint8_t row = 0; row < ROWS; row++) {
        set_row_low(row);                    // 拉低当前行
        delay_us(10);                        // 等待电平稳定
        for (uint8_t col = 0; col < COLS; col++) {
            uint8_t level = read_col(col);
            // 将 (row, col) 和 level 传入状态机
            key_state_machine(row, col, level);
        }
        set_row_high(row);                   // 恢复高电平
    }
}
避坑指南: 我曾经在矩阵扫描时忘了加 delay_us(10),结果行拉低后列电平还没稳定,读到的全是错误数据。后来养成习惯,每次拉低行后,等 10 微秒再读列。这个细节,很多新手会忽略。

4.5 按键编程的常见坑

常见问题 原因 解决办法
按一次键,响应多次 消抖时间不够,或没做消抖 增加消抖时间到 20ms 以上
按键响应迟钝 消抖时间过长,或扫描周期太长 缩短消抖时间,或优化扫描频率
矩阵键盘串键 二极管未加,或扫描时序不对 每行加二极管防反灌,调整扫描顺序
长按无法识别 状态机未处理长按状态 增加长按计时状态,超时触发长按事件

嗯,这里要注意一点:售货机的选货按键,通常需要区分「短按」和「长按」。短按是选货,长按可能是查看商品详情。这个逻辑可以在状态机里加一个计时分支,按下超过 1 秒就触发长按事件。

我个人习惯把按键事件封装成回调函数。这样主程序只需要注册一个事件处理函数,按键模块自动调用。代码耦合度低,后期维护也方便。

好了,按键消抖和选货编程就讲到这里。下一章咱们聊聊售货机的显示模块——怎么用 LCD 和数码管显示价格和库存。到时候你会发现,按键和显示是密不可分的。