Skip to content

从轮询到中断 - 为什么要折腾这个

上一节我们实现了轮询方式的按键驱动,说实话,那个实现虽然能跑,但总感觉哪里不对劲。CPU 一直在那里傻傻地循环读取 GPIO 状态,就像一个人每隔几秒就去看一眼门有没有敲,效率太低了。更糟糕的是,这种方式根本没法让 CPU 进入低功耗状态,在嵌入式设备上这可是致命的——电池会被很快耗干。

所以今天我们要来点更专业的:中断方式

轮询的问题在哪里

先让我们回忆一下轮询方式的实现。在 read 函数里,我们有个循环一直在读 GPIO:

c
while (1) {
    int state = gpiod_get_value(gpio);
    if (state != last_state) {
        // 状态变了,报告事件
        break;
    }
    msleep(10);  // 稍微睡一下,避免 CPU 占用太高
}

说实话,这种写法有两个大问题。第一,即使加了 msleep,CPU 占用仍然不低。你想啊,每 10ms 就要醒来一次,读一次 GPIO,做一次比较。如果系统里有很多这样的设备,CPU 就被这些无聊的轮询任务占满了。第二,响应延迟不可控。如果用户按键刚好在两次轮询之间,他得等最长 10ms 才能被检测到。你说 10ms 不长?但对于人机交互来说,这已经能感觉到迟钝了。

真实体验

我之前在一个项目里用了轮询方式读取传感器数据,结果发现系统功耗比预期高了 30%。查了好久才发现,就是这些看似无害的轮询任务在作怪。CPU 根本没法进入深度睡眠,一直在浅睡状态,功耗自然下不来。

中断方式的核心思想

中断方式的核心思想其实很简单:别主动去问,等硬件来通知你

GPIO 可以配置成中断源,当它的电平发生变化时,会触发一个中断信号。CPU 收到中断信号后,暂停当前正在执行的任务,跳转到中断处理函数执行。处理完之后,再回到原来的任务继续执行。

c
// 配置 GPIO 为中断源
int irq = gpiod_to_irq(gpio);
request_irq(irq, key_irq_handler, IRQF_TRIGGER_FALLING, "key", dev);

// 中断处理函数
static irqreturn_t key_irq_handler(int irq, void *dev_id) {
    // 按键状态变了,做点什么
    return IRQ_HANDLED;
}

这就像门铃一样的原理。你不需要每隔几秒去门口看看有没有人,你只需要等门铃响。门铃响了,你再去开门。没响的时候,你可以安心做别的事情,甚至可以睡觉。

上下半部机制

中断处理通常被分为"上半部"和"下半部"。上半部就是中断处理函数本身,必须快速执行,不能睡眠。下半部可以推迟执行,可以睡眠。我们的按键驱动用工作队列来实现下半部,后面会详细讲。

中断方式的优势

和轮询相比,中断方式的优势非常明显:

特性轮询方式中断方式
CPU 占用高(持续轮询)极低(事件驱动)
响应延迟取决于轮询周期微秒级
功耗高(CPU 无法深睡)低(CPU 可深睡)
消抖效果差(在抖动期内可能读到错误状态)好(延时读取跳过抖动期)
代码复杂度

但是等等,你可能会说,中断方式虽然响应快,但按键的机械抖动怎么办?

按键抖动的真相

这是初学者最容易踩的坑。机械按键在按下或松开的瞬间,触点不是立即稳定的,而是会有一段时间的抖动:

理想情况:
按下 ────────┐
              └───────────

实际情况(有抖动):
按下 ────────┐┌┌┐┌┐┌───
              └┘└┘└┘└
              ↑ 抖动期,约 5-20ms

如果在中断触发时立即读取 GPIO,你可能会读到错误的值。更糟糕的是,抖动期间会触发多次中断,你会收到一堆按下/松开事件。

踩坑经历

我第一次写按键驱动的时候,就吃了这个亏。按一下按键,应用程序收到了好几个事件。查了半天才发现是按键抖动导致的。后来加了消抖处理,问题才解决。

我们的解决方案:延时读取

消抖的核心思想其实很巧妙:不急着读,等抖动结束了再读

具体来说,当中断触发时,我们不立即读取 GPIO 状态,而是启动一个延时机制(工作队列),等 20ms 后再去读。这 20ms 足够让大部分机械按键稳定下来。

c
// 中断处理函数(上半部)
static irqreturn_t key_irq_handler(int irq, void *dev_id) {
    schedule_work(&dev->work);  // 调度工作队列,不立即处理
    return IRQ_HANDLED;
}

// 工作队列处理函数(下半部)
static void key_work_handler(struct work_struct *work) {
    msleep(20);  // 等待抖动结束
    int state = gpiod_get_value(gpio);  // 读取稳定的状态
    // 报告事件...
}

这个方案的妙处在于,它利用了工作队列的机制。中断处理函数快速返回(只是调度一个工作),真正的处理在工作队列里进行,可以睡眠,可以延时。20ms 后,抖动早就结束了,读到的就是稳定的按键状态。

驱动结构预览

在深入各个机制之前,我们先看看整个驱动的结构:

c
struct key_debounce_dev {
    /* 字符设备相关 */
    dev_t devid;
    struct cdev cdev;
    struct class* class;
    struct device* device;

    /* 硬件相关 */
    struct gpio_desc* gpio;
    int irq;

    /* 工作队列 */
    struct work_struct work;

    /* 同步机制 */
    spinlock_t lock;
    wait_queue_head_t waitq;

    /* 状态跟踪 */
    int last_gpio_state;
    bool event_ready;
    int key_value;

    /* 统计信息 */
    atomic_t irq_count;
    atomic_t event_count;
    atomic_t debounce_skipped;
};

这个结构体包含了驱动需要的所有信息。字符设备相关的内容我们在之前的教程里已经讲过了。硬件相关的是 GPIO 描述符和中断号。工作队列用于实现延时处理。同步机制包括自旋锁和等待队列。状态跟踪用于记录按键状态。统计信息用于验证消抖效果。

完整的工作流程

让我们走一遍完整的工作流程,从用户空间到硬件再回到用户空间:

1. 用户空间调用 read()

2. read() 发现没有新事件,调用 wait_event_interruptible() 睡眠

3. 用户按下按键

4. GPIO 电平变化,触发中断

5. 中断处理函数执行(上半部)
   - 递增 irq_count 计数器
   - 调度工作队列
   - 快速返回

6. 20ms 后,工作队列处理函数执行(下半部)
   - 读取 GPIO 状态
   - 和上一次状态比较
   - 如果状态变化,更新 event_ready
   - 调用 wake_up_interruptible() 唤醒 read()

7. read() 被唤醒,返回数据给用户空间

整个流程里,CPU 只在两个地方真正干活:中断处理函数(几微秒)和工作队列处理函数(几毫秒)。其他时间,CPU 可以做别的事情,或者进入睡眠。这就是中断方式的魅力所在。

本教程的结构

本教程会一步步讲解这个中断消抖驱动的实现:

  1. 02_interrupt_subsystem.md - 中断子系统:深入了解 Linux 中断机制
  2. 03_work_queue.md - 工作队列机制:为什么中断里不能睡眠,如何推迟执行
  3. 04_debounce_algorithm.md - 消抖算法实现:延时读取到底怎么写
  4. 05_synchronization.md - 同步机制:自旋锁、等待队列、原子变量
  5. 06_output_analysis.md - 输出分析:如何验证消抖效果
  6. 07_build_and_test.md - 编译和测试:实际运行驱动

本章小结

中断方式相比轮询方式,CPU 占用低、响应快、功耗低,是生产环境按键驱动的标准做法。但中断方式也有它的复杂性,需要理解中断系统、工作队列、同步机制等概念。

接下来的章节,我们会深入这些机制。说实话,这些内容一开始可能有点抽象,但一旦理解了,你会发现内核设计的精妙之处。中断上下文和进程上下文的分离、上半部和下半部的协作、各种同步机制的配合,都是经过多年演进的成果。

准备好深入了解 Linux 内核的中断机制了吗?我们下一章见。


相关文档

Built with VitePress