上一篇我们用输入子系统 + 定时器轮询实现了一个 GPIO 按键驱动。evtest 能看到按键事件了,但你知道它的代价吗?——每 20ms 一次定时器中断,CPU 每秒要醒 50 次去读一个可能根本没按的 GPIO。 一款手机如果有 10 个这样的轮询驱动,光"空转"就能吃掉不少电量。今天,我们用 Linux 中断框架 把按键驱动改成中断驱动——按键不按,CPU 完全不理;按键一按,硬件通知 CPU,驱动立刻响应。
unsetunset一、轮询 vs 中断:一张图说明白unsetunset

中断的本质:由硬件主动通知 CPU,而不是 CPU 反复去问硬件。
在 Linux 驱动开发中,理解中断处理是绕不开的核心技能。下文从最简单的 request_irq 开始,逐步展开顶半部/底半部的设计原理,最后改写出中断版按键驱动。
unsetunset二、基础:gpio_to_irq + request_irqunsetunset
2.1 从 GPIO 编号获取中断号
#include<linux/interrupt.h>#include<linux/gpio.h>int irq;/* 第一步:GPIO 编号 → IRQ 编号 */irq = gpio_to_irq(gpio);if (irq < 0) { pr_err("gpio_to_irq failed\n");return irq;}
每个 GPIO 控制器内部都连接着一根中断线。gpio_to_irq 做的事情就是查表——GPIO0_C0 对应的硬件中断号是多少。这个号是芯片原厂的 GPIO 驱动(如 pinctrl-rockchip.c)在初始化时注册的,你不需要知道它具体是几。
2.2 注册中断处理函数
#include<linux/interrupt.h>irqreturn_tmy_irq_handler(int irq, void *dev_id){ pr_info("key pressed! irq=%d\n", irq);return IRQ_HANDLED; /* 告诉内核:中断已处理 */}/* 在 probe 中注册 */ret = request_irq(irq, /* 中断号 */ my_irq_handler, /* 中断处理函数 */ IRQF_TRIGGER_FALLING, /* 触发方式:下降沿 */"gpio_key", /* 名称(出现在 /proc/interrupts) */ dev); /* 传给处理函数的 dev_id */if (ret) { pr_err("request_irq failed, ret=%d\n", ret);return ret;}
四个关键参数:
| | |
|---|
irq | | gpio_to_irq(gpio) |
handler | | my_irq_handler |
flags | | IRQF_TRIGGER_FALLING |
dev_id | | |
2.3 触发方式的四种选择
IRQF_TRIGGER_RISING → 上升沿触发(低电平 → 高电平的瞬间)IRQF_TRIGGER_FALLING → 下降沿触发(高电平 → 低电平的瞬间)IRQF_TRIGGER_HIGH → 高电平触发(持续高电平期间反复触发)IRQF_TRIGGER_LOW → 低电平触发(持续低电平期间反复触发)
按键用边沿触发(RISING 或 FALLING),不要用电平触发。如果用电平触发,按键按着不放时中断会反复触发,CPU 被中断轰炸。
如果你的 GPIO 是上升沿有效(按下 = 高电平),用 IRQF_TRIGGER_RISING;如果是下降沿有效,用 IRQF_TRIGGER_FALLING。双边沿触发(IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING)可以同时捕获按下和释放。
unsetunset三、中断处理函数的铁律:不能休眠unsetunset
3.1 中断上下文是什么?
当 my_irq_handler 被调用时,代码运行在中断上下文(interrupt context)中。和进程上下文最大的区别:
| | |
|---|
| | |
能休眠(msleep/mutex_lock/copy_from_user) | | |
| | |
| | |
中断上下文不能休眠的原因很简单: 你在中断里休眠了,谁来唤醒你?中断没有关联进程,调度器没有办法把 CPU 给到“这个中断”继续执行。如果在中断上下文调了可能休眠的函数(如 mutex_lock、copy_from_user),启用了 CONFIG_DEBUG_ATOMIC_SLEEP 的内核会直接打印 WARNING 甚至触发 kernel panic。
3.2 中断处理函数里能干什么、不能干什么
irqreturn_tmy_irq_handler(int irq, void *dev_id){structgpio_key_dev *dev = dev_id;/* ✅ 能干的:操作寄存器、读 GPIO、设置标志位 */int level = gpio_get_value(dev->gpio);/* ✅ 能干的:操作自旋锁(spin_lock,但不能用 mutex) */ spin_lock(&dev->lock);/* ❌ 不能干的:调用可能休眠的函数 */// mutex_lock(&dev->mutex); // ❌ mutex 会休眠// copy_from_user(buf, ...); // ❌ 可能引发缺页异常// msleep(10); // ❌ 明显休眠// kmalloc(GFP_KERNEL); // ❌ GFP_KERNEL 可能休眠/* ❌ 不能干的:耗时的复杂计算 */// 100ms 的浮点运算 ← 这会让所有其他中断排队等着,系统卡成狗 spin_unlock(&dev->lock);return IRQ_HANDLED;}
核心原则:中断处理函数要越快越好。 打个比方,中断处理函数就像餐厅接待员——你的工作是把客人领进门(记录中断发生),而不是进去亲自给客人做菜(处理业务逻辑)。复杂的事情交给"底半部"去做。
unsetunset四、顶半部与底半部:中断的"前台"和"后台"unsetunset
正是因为中断上下文不能做耗时操作,Linux 把中断处理分成了两半:

Linux 提供了三种实现底半部的机制:
| | | | |
|---|
| workqueue | | | | |
| tasklet | | | | |
| threaded irq | | | 按键驱动首选 | |
本文重点讲两种:request_threaded_irq(首选方案)和 workqueue(备选方案)。 tasklet 正在被内核社区逐步标记为过时——因为它在软中断上下文运行,不能休眠,而且一个 tasklet 只能在一个 CPU 上执行,不如 workqueue 灵活。
unsetunset五、方案一(推荐):request_threaded_irqunsetunset
request_threaded_irq 是 request_irq 的升级版:顶半部(hardirq handler)运行在中断上下文,底半部(threaded handler)运行在独立的内核线程中。
#include<linux/interrupt.h>/* * 顶半部:运行在中断上下文 * 返回值 IRQ_WAKE_THREAD 告诉内核"去叫底半部起来干活" */staticirqreturn_tgpio_key_hardirq(int irq, void *dev_id){/* * 顶半部只做一件事:告诉内核"有按键事件要处理"。 * 实际的上报工作交给底半部。 */return IRQ_WAKE_THREAD;}/* * 底半部:运行在内核线程中,可以休眠 */staticirqreturn_tgpio_key_threaded(int irq, void *dev_id){structgpio_key_dev *dev = dev_id;int level; level = gpio_get_value(dev->gpio);/* * 在底半部里上报输入事件是完全安全的—— * 这里运行在进程上下文,input_event 内部可能做的 * 内存分配、锁操作都不会触发原子上下文警告。 */ input_report_key(dev->input, KEY_1, level); input_sync(dev->input);return IRQ_HANDLED;}/* ============ probe 中注册 ============ */ret = request_threaded_irq(irq, gpio_key_hardirq, /* 顶半部 */ gpio_key_threaded, /* 底半部 */ IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,"gpio_key", dev);if (ret) { pr_err("request_threaded_irq failed\n");return ret;}
常见用法:顶半部直接返回 IRQ_HANDLED
如果你的顶半部什么额外工作都不需要做(按键驱动就是这种情况),可以直接让顶半部返回 IRQ_HANDLED。但更推荐返回 IRQ_WAKE_THREAD 来显式表达"我有底半部要做"。
更简单的写法——顶半部留空:
/* * 如果你不需要单独在中断上下文中做任何事,顶半部可以直接返回 * IRQ_WAKE_THREAD。内核看到这个返回值会自动调用你的底半部。 * * 甚至可以直接用 NULL 作为 hardirq handler——内核会把 * 默认的 primary handler 设置为只返回 IRQ_WAKE_THREAD。 */ret = request_threaded_irq(irq,NULL, /* 顶半部留空 */ gpio_key_threaded, IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,"gpio_key", dev);
这种方式下,内核会自动处理硬件中断应答和底半部调度——你只需要写底半部。
unsetunset六、方案二(了解即可):workqueueunsetunset
在 request_threaded_irq 出现之前(Linux 2.6.30 以前),workqueue 是底半部的事实标准:
#include<linux/workqueue.h>staticvoidgpio_key_work(struct work_struct *work){structgpio_key_dev *dev =container_of(work, structgpio_key_dev, work);int level; level = gpio_get_value(dev->gpio); input_report_key(dev->input, KEY_1, level); input_sync(dev->input);}staticirqreturn_tgpio_key_irq(int irq, void *dev_id){structgpio_key_dev *dev = dev_id;/* * 中断上下文不能休眠,但可以调度 workqueue。 * schedule_work 把 work 放到系统工作队列,稍后由内核线程执行。 */ schedule_work(&dev->work);return IRQ_HANDLED;}/* probe 中初始化 */INIT_WORK(&dev->work, gpio_key_work);ret = request_irq(irq, gpio_key_irq, IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING,"gpio_key", dev);
既然有了 request_threaded_irq,为什么还要知道 workqueue?
因为不是所有中断都用 threaded irq。有些驱动需要在多个地方调度同一个 work(比如定时器 + 中断都触发同一个 work),这时 workqueue 更灵活。但在按键驱动这种"一个中断 → 一个处理函数"的简单场景中,request_threaded_irq 是最优解。
unsetunset七、完整代码:中断版按键驱动unsetunset
把上一篇的轮询版改成中断版,改动集中在 probe 和 remove:
#include<linux/module.h>#include<linux/platform_device.h>#include<linux/of_gpio.h>#include<linux/gpio.h>#include<linux/interrupt.h>#include<linux/input.h>structgpio_key_dev {int gpio;int irq;structinput_dev *input;};/* ============ 底半部:上报按键事件 ============ */staticirqreturn_tgpio_key_threaded(int irq, void *dev_id){structgpio_key_dev *dev = dev_id;int level;/* * 读 GPIO 当前电平,上报按键事件。 * 注意:这里用的是双边沿触发,电平为 1 = 按下,0 = 释放。 * 如果你的硬件是低电平有效,把 level 取反即可。 */ level = gpio_get_value(dev->gpio); input_report_key(dev->input, KEY_1, level); input_sync(dev->input);return IRQ_HANDLED;}/* ============ probe ============ */staticintgpio_key_probe(struct platform_device *pdev){structgpio_key_dev *dev;unsignedlong irq_flags;int ret; dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);if (!dev)return -ENOMEM;/* ---- 1. 获取 GPIO ---- */ dev->gpio = of_get_named_gpio(pdev->dev.of_node, "key-gpios", 0);if (dev->gpio < 0) { pr_err("gpio_key: failed to get key-gpios\n");return dev->gpio; } ret = gpio_request(dev->gpio, "key");if (ret)return ret; gpio_direction_input(dev->gpio);/* ---- 2. GPIO → IRQ ---- */ dev->irq = gpio_to_irq(dev->gpio);if (dev->irq < 0) { pr_err("gpio_key: gpio_to_irq failed\n"); ret = dev->irq;goto fail_gpio; }/* ---- 3. 分配并初始化 input_dev ---- */ dev->input = devm_input_allocate_device(&pdev->dev);if (!dev->input) { ret = -ENOMEM;goto fail_gpio; } dev->input->name = "gpio_key"; dev->input->phys = "gpio-keys/button0"; dev->input->id.bustype = BUS_HOST; input_set_capability(dev->input, EV_KEY, KEY_1); ret = input_register_device(dev->input);if (ret)goto fail_gpio;/* ---- 4. 注册中断(顶半部留空 + 底半部干活)---- *//* * 设备树里如果指定了中断触发方式,gpio_to_irq 会自动填好。 * 这里显式用双边沿触发,同时捕获按下和释放。 */ irq_flags = IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING; ret = request_threaded_irq(dev->irq, NULL, gpio_key_threaded, irq_flags, "gpio_key", dev);if (ret) { pr_err("gpio_key: request_threaded_irq failed, ret=%d\n", ret);goto fail_input; } platform_set_drvdata(pdev, dev); pr_info("gpio_key: probed on irq=%d, /dev/input/eventX ready\n", dev->irq);return 0;fail_input: input_unregister_device(dev->input);fail_gpio: gpio_free(dev->gpio);return ret;}/* ============ remove ============ */staticintgpio_key_remove(struct platform_device *pdev){structgpio_key_dev *dev = platform_get_drvdata(pdev); free_irq(dev->irq, dev); /* 注销中断 */ input_unregister_device(dev->input); /* 注销输入设备 */ gpio_free(dev->gpio);return 0;}static const structof_device_idgpio_key_of_match[] = { { .compatible = "qian,gpio-key" }, { }};MODULE_DEVICE_TABLE(of, gpio_key_of_match);static structplatform_drivergpio_key_driver = { .driver = { .name = "gpio-key", .of_match_table = gpio_key_of_match, }, .probe = gpio_key_probe, .remove = gpio_key_remove,};module_platform_driver(gpio_key_driver);MODULE_LICENSE("GPL");MODULE_AUTHOR("qian");MODULE_DESCRIPTION("Interrupt-driven GPIO key via input subsystem");
对比轮询版:改了什么?
| | |
|---|
| timer_setup | request_threaded_irq |
| | 硬件中断触发 + 底半部里 gpio_get_value |
| | 按键不按 = CPU 零开销 |
| | 微秒级 |
unsetunset八、按键去抖:中断版的老问题unsetunset
按键按下时,机械触点会"弹跳"——在几毫秒内反复通断:
解决方案有两个:
方案一:硬件去抖 + 软件延迟(简单)
/* * gpiod_set_debounce 告诉 GPIO 控制器"电平稳定 X us 后才算有效"。 * 底层由 pinctrl 驱动实现(Rockchip 平台 A->5.10 已支持)。 * 注意:不是所有 GPIO 控制器都支持硬件去抖,不支持时返回 -ENOTSUPP。 */gpiod_set_debounce(gpio_to_desc(dev->gpio), 5000); /* 5ms 去抖 */
简单省事,但依赖硬件支持。如果 GPIO 控制器不支持硬件去抖,需要方案二。
方案二:在底半部加延迟(软件去抖)
staticirqreturn_tgpio_key_threaded(int irq, void *dev_id){structgpio_key_dev *dev = dev_id;int level;/* * 底半部运行在进程上下文,msleep 是允许的。 * 等 10ms 再读 GPIO——如果按键真的按下了,电平应该已经稳定。 */ msleep(10); level = gpio_get_value(dev->gpio); input_report_key(dev->input, KEY_1, level); input_sync(dev->input);return IRQ_HANDLED;}
注意:这个 msleep 放在底半部里是合法的——因为 request_threaded_irq 的底半部运行在内核线程中(进程上下文)。如果放在顶半部里(中断上下文),msleep 会直接触发调度器断言失败。
unsetunset九、查看中断统计unsetunset
加载驱动后,可以通过 /proc/interrupts 确认中断是否正常注册和触发:
cat /proc/interrupts | grep gpio_key# 输出示例(每次按键按下,计数器 +1):# CPU0 CPU1 CPU2 CPU3# 123: 5 0 0 0 gpio_key# ^ ^# irq号 触发次数
每次按下按键,右下角的计数器 +1。如果你的按键"按一下计数器加了 20 几"——说明按键抖动严重,需要加去抖。
unsetunset十、内核 gpio-keys 驱动的中断处理unsetunset
顺便看一下 gpio_keys.c 是怎么用 request_threaded_irq 的(简化版):
gpio_keys_probe() └── 每个子节点 ├── devm_gpiod_get() // 获取 GPIO 描述符 ├── gpiod_to_irq() // GPIO → IRQ ├── request_threaded_irq() │ hardirq = gpio_keys_gpio_isr // 顶半部:记录按键状态 │ threaded = gpio_keys_gpio_report_event // 底半部:上报 + 去抖 + 长按 └── input_register_device()
和我们写的驱动几乎一样。 区别是 gpio-keys 的顶半部做了更复杂的时间戳记录和去抖逻辑,底半部支持自动重复和唤醒源管理。但架构一模一样——request_threaded_irq 顶半部 + 底半部。
unsetunset十一、Linux 4.19 / 5.10 / 6.1 版本差异unsetunset
中断子系统的核心 API 在三个版本中高度一致,但底半部机制有一些演进:
| | | |
|---|
request_irq | | | |
request_threaded_irq | | | |
request_threaded_irq 顶半部为 NULL | | | |
gpiod_set_debounce | | | |
gpio_to_irq | | | |
| ✅ DECLARE_TASKLET(_name, _func) | | ✅ DECLARE_TASKLET(_name, _callback) 新接口,tasklet_setup 替代 tasklet_init |
本文代码在 4.19 / 5.10 / 6.1 上均可编译运行。 唯一需要注意的是:gpiod_set_debounce 在 4.19 的某些 GPIO 控制器(特别是通过 I2C/SPI 扩展的 GPIO)上可能返回 -ENOTSUPP,此时需要改用软件去抖方案(底半部 msleep 或定时器去抖)。Rockchip 主控的片上 GPIO 在 4.19 已支持硬件去抖。
关于 tasklet:5.10 时期社区曾计划用 threaded IRQ 完全替代 tasklet,但到了 6.1,tasklet 不但没有移除,反而完成了一次接口现代化重构——DECLARE_TASKLET 改为接受 callback(struct tasklet_struct *) 风格的回调,并用 tasklet_setup() 取代了老式 tasklet_init()(后者保留 DECLARE_TASKLET_OLD 兼容旧代码)。因此 tasklet 在 6.1 依然是一个可用的底半部选项。
unsetunset本文你学到了什么unsetunset
- 中断 vs 轮询 —— 中断是硬件主动通知,轮询是 CPU 反复询问;中断省 CPU、响应快
gpio_to_irq + request_irq —— GPIO 编号转中断号、注册中断处理函数- 中断上下文的限制 —— 不能休眠、不能调度、不能
copy_from_user、不能用 mutex - 顶半部 / 底半部分离 —— 顶半部快(微秒级),底半部跑复杂逻辑(可以休眠)
request_threaded_irq(推荐方案) —— 顶半部留空(或返回 IRQ_WAKE_THREAD),底半部运行在内核线程- workqueue(了解即可) —— 更灵活但更繁琐,
schedule_work 调度 - 按键去抖 —— 硬件去抖(
gpiod_set_debounce)+ 软件去抖(msleep) /proc/interrupts —— 检查中断是否触发、触发次数
unsetunset接下来unsetunset
中断让按键驱动高效了,但也引入了一个新问题——并发。底半部的内核线程和用户态的 read/write/ioctl 可能同时访问同一个设备结构体,如果不用锁保护,就会出现"读到一半被改了"的竞态条件。
下一篇,我们引入 Linux 并发与同步机制:spin_lock、mutex、atomic_t,以及一个"不上锁就出 bug"的真实按键驱动案例。
关注「钱途无量嵌入式」,专注 Linux 驱动与 BSP 开发,每周硬核输出。