Skip to content

按键抖动 - 机械结构的硬伤

前面的章节我们实现了一个能工作的轮询式按键驱动,测试一下应该能正常检测按键。但如果你仔细观察输出,可能会发现一个奇怪的现象:按一次按键,有时候会收到多次事件。

这就是按键抖动问题。说实话,这是我最开始调试驱动时踩的第一个大坑,查了好久才明白是怎么回事。

什么是抖动

机械按键的结构决定了它必然会有抖动。按键内部的金属触点在接触瞬间不会立即稳定接触,而是会弹跳几次:

理想波形(你以为的):
    ┌───────────────────
────┘                   └─────

实际波形(真实的):
    ┌─┬───┬─────┬────────
────┘ │   │     │        └──
    └─┘   └─┬─┬─┘
             └─┘
    ←──── 5-50ms ────→

这个波形来自示波器的实际测量。你可以看到,在按下和松开的瞬间,电平会跳变好几次,而不是干净利落的一次跳变。

抖动的物理原因

机械触点的表面不是绝对平整的,有微观的凹凸。当两个触点靠近时,可能先接触几个高点,然后弹开,再接触其他点,如此反复多次才能稳定接触。

这个过程持续时间通常在 5-50ms,具体取决于按键的机械结构和材质。

抖动对轮询方式的影响

我们的轮询代码是这样的:

c
current_state = key_get_state(dev->gpio);
if (current_state != last_state) {
    /* 立即返回 */
    int key_value = !current_state;
    return copy_to_user(...);
}

问题来了:如果一次物理按键产生多次电平跳变,这个 if 就会被触发多次。

比如你按一下按键:

  • t=0ms:检测到下降沿,返回"按下"
  • t=5ms:抖动,电平跳回高,返回"松开"
  • t=8ms:又跳回低,又返回"按下"
  • t=15ms:终于稳定了

结果应用层收到了三次事件:按下→松开→按下。但你明明只按了一次!

真实的测试结果

我在开发板上测试的时候,按一下按键,应用层输出了这样的:

Key PRESSED
Key RELEASED
Key PRESSED
Key RELEASED
Key PRESSED

你说这谁顶得住?一次按键变成五次事件,应用程序根本没法用。

简单的消抖尝试:延时确认

最直观的消抖思路是:检测到变化后,等待一段时间,然后再确认

代码可以这样写:

c
current_state = key_get_state(dev->gpio);
if (current_state != last_state) {
    /* 延时 20ms,跳过抖动期 */
    msleep(20);

    /* 再次读取确认 */
    current_state = key_get_state(dev->gpio);
    if (current_state != last_state) {
        /* 确认是真实的状态变化 */
        int key_value = !current_state;
        return copy_to_user(...);
    }
    /* 否则是抖动,忽略这次变化 */
}

这个思路是对的,但有个问题:在轮询模式下,这个消抖方法效果有限。

轮询消抖的局限性

read() 函数里调用 msleep() 会阻塞整个进程。但问题在于:

  1. 抖动期间可能有多次跳变,第一次跳变触发延时,但延时结束后可能正好赶上下一次跳变
  2. 循环继续跑,又会检测到变化

本质上,轮询是在"时间上密集采样",如果采样频率高于抖动频率,就会检测到多次变化。

更好的思路:状态机

更靠谱的消抖方法是引入状态机,跟踪稳定的按键状态:

c
enum {
    STATE_IDLE,        // 初始状态
    STATE_DEBOUNCING,  // 消抖中
    STATE_STABLE       // 稳定状态
};

/* 在 read 函数里 */
switch (dev->state) {
    case STATE_IDLE:
        if (current_state != last_state) {
            dev->state = STATE_DEBOUNCING;
            dev->debounce_start = jiffies;
        }
        break;

    case STATE_DEBOUNCING:
        if (jiffies - dev->debounce_start > msecs_to_jiffies(50)) {
            /* 消抖时间到了,确认状态 */
            dev->state = STATE_STABLE;
            return /* 返回事件 */;
        }
        break;

    case STATE_STABLE:
        /* 等待按键释放 */
        if (/* 按键释放了 */) {
            dev->state = STATE_IDLE;
        }
        break;
}

这个方法比单纯延时好一点,但实现起来复杂,而且在轮询模式下仍然不够完美。

终极解决方案:中断 + 定时器

真正能可靠消抖的方法是用中断方式,这个我们会在下一章详细讲。基本思路是:

c
/* 按键按下 → 触发中断 */
static irqreturn_t key_irq_handler(int irq, void *dev_id)
{
    /* 不立即处理,而是启动定时器 */
    mod_timer(&dev->debounce_timer, jiffies + msecs_to_jiffies(50));
    return IRQ_HANDLED;
}

/* 定时器到期 → 延时已过,读取状态 */
static void debounce_timer_callback(struct timer_list *t)
{
    int current_state = key_get_state(dev->gpio);
    /* 这时已经跳过抖动期,读取的状态是可靠的 */
    /* ...处理按键事件... */
}

这个方法的优势:

  • 中断只在按键动作时触发,CPU 不空转
  • 定时器延时 50ms,确保读取时抖动已经结束
  • 每次按键只触发一次中断

为什么中断方式消抖效果更好

关键区别在于:中断方式是"事件驱动"的,而轮询方式是"时间驱动"的。

  • 轮询:不停地采样,采样频率高就会把抖动都采出来
  • 中断:只在跳变沿触发一次,然后用定时器跳过抖动期

中断方式处理的是"事件",轮询方式处理的是"电平"。事件驱动天然更适合处理这类离散的输入信号。

抖动时间的选择

不同的按键有不同的抖动时间。常见的范围是 5-50ms,有些劣质按键可能更长。

c
/* 常用的消抖延时 */
#define DEBOUNCE_TIME_MS 20  // 大多数按键足够
#define DEBOUNCE_TIME_MS 50  // 劣质按键用这个

选择消抖时间是个权衡:太短消不掉抖动,太长会让用户感觉反应迟钝。

实际测量方法

如果你不确定你的按键抖动时间是多少,可以用示波器测量:

  1. 示波器探头接 GPIO 引脚
  2. 按下按键
  3. 观察波形,测量从第一次跳变到稳定的时间

没有示波器的话,也可以在代码里打印时间戳, empirically 测出合适的值。

轮询方式的定位

到这里你应该理解了:轮询方式不是一个生产级的解决方案

它的价值在于:

  • 教学演示——理解 GPIO 输入的基本原理
  • 快速验证——确认硬件连接和基本逻辑
  • 学习过渡——先理解轮询,再学中断

但如果你要做一个实际的产品,轮询式的按键驱动不是一个好的选择。

小结

按键抖动是机械结构的固有特性:

  • 抖动持续 5-50ms
  • 轮询方式很难完美处理抖动
  • 简单延时消抖效果有限
  • 终极方案是中断 + 定时器

下一章我们会编译测试这个驱动,看看实际效果。然后你会更深刻地体会到为什么需要中断方式。


上一章: 轮询实现 | 下一章: 编译测试

Built with VitePress