本节摘要:中断是硬件主动敲门的通道。本节以开发板上的按键为案例,讲清中断注册手续、顶半部与底半部的分工逻辑、三种推迟执行机制的取舍,并把 2.3 节预留的
poll回调补成完整的阻塞式按键驱动。
CPU 正在执行别的代码,硬件事件发生了,怎么让 CPU 知道?轮询是办法之一——驱动不停地查状态寄存器,像反复按电梯按钮;中断是另一种——硬件直接给 CPU 发电信号,CPU 暂停手头工作,跳转到预设的处理入口。前者浪费 CPU 且响应迟钝,后者高效但要付出代价:处理函数运行在一个特殊环境里,规矩森严。本节把这些规矩一条条立起来。
开发板上按键接在 GPIO 引脚,按下时电平翻转。信号先到 GPIO 控制器,控制器配置了"该引脚上升沿触发中断";控制器向中断控制器(如现代 ARM 系统里的通用中断控制器)发出请求;中断控制器仲裁优先级后通知 CPU;CPU 保存现场,按中断号查表,跳转到驱动注册的处理函数。驱动要做的只是两件事:注册处理函数、在函数里正确行事,前面的硬件链路由芯片与内核框架搭好。
注册与释放的手续:
#include <linux/interrupt.h> #include <linux/sched.h> #define BUTTON_IRQ 42 /* 本章先写死,第 4 章由设备树提供 */ static irqreturn_t button_isr(int irq, void *dev_id) { /* 顶半部:只做最要紧的事 */ pr_debug("button: 中断到来\n"); return IRQ_WAKE_THREAD; /* 唤醒配套的线程化底半部 */ } static int button_setup(void) { return request_irq(BUTTON_IRQ, button_isr, IRQF_TRIGGER_RISING, /* 上升沿触发 */ "button0", NULL); /* 名字与共享标识 */ } static void button_teardown(void) { free_irq(BUTTON_IRQ, NULL); /* 与注册配对 */ }
request_irq 的参数各有讲究:触发类型要与电路匹配(按按键接上拉电阻就是下降沿或低电平);名字会出现在中断统计里,排查时靠它认人;最后一个参数在共享中断时是身份标识——一条中断线被多台设备共用时,处理函数靠它分辨"是不是我的设备在叫"。
中断处理函数运行在中断上下文,此时"同级或更低优先级的中断被挡住、调度器无权介入",意味着两条铁律:不可睡眠、必须快。睡眠等于让系统在"不许调度"的地方等待调度,后果是锁死整机;磨蹭则会让后续中断排队超时,实时性崩盘。
但按键消抖要等十几毫秒、网络收包要解析协议头——这些活在中断上下文里干不完。内核的答案是把工作切成两半:顶半部只做"应答硬件、抢下数据、标记事件",然后尽快返回;剩下的重活交给底半部,在环境宽松的时候补做。

内核为"推迟执行"提供了三档机制,取舍维度是能否睡眠与延迟敏感度。
线程化中断是现代首选:注册时同时给出顶半部与一个内核线程,顶半部返回特定值后内核唤醒线程执行重活。它把"两半"放进了一次注册,还能像普通线程一样设优先级、看调度统计。上面的 button_isr 返回 IRQ_WAKE_THREAD 正是为此——配套线程这样写:
static irqreturn_t button_thread_fn(int irq, void *dev_id) { /* 底半部:线程环境,可以睡眠 */ unsigned long events = atomic_xchg(&event_count, 0); if (events) { wake_up_interruptible(&button_waitq); /* 唤醒 poll 的等待者 */ pr_info("button: 事件已交付用户态通路\n"); } return IRQ_HANDLED; } /* 注册时把两半一起登记 */ ret = request_threaded_irq(BUTTON_IRQ, button_isr, button_thread_fn, IRQF_TRIGGER_RISING, "button0", NULL);
工作队列适合"明确的进程上下文语义"场景:把函数挂进队列,由内核工作线程在进程上下文执行,睡眠自由。需要拿互斥锁、做大量内存操作的活,优先考虑它。
软中断与小任务是最古老的机制:小任务在原子上下文执行,不可睡眠但调度延迟极低,适合网络与块层这类吞吐关键路径。普通驱动很少直接用它——默认选线程化中断,需要睡眠且不绑中断语义时选工作队列,基本不会错。
有了等待队列,阻塞式按键驱动闭环成型:进程 read 时无数据就挂起,中断到来时底半部唤醒它。三段关键代码连起来看:
static DECLARE_WAIT_QUEUE_HEAD(button_waitq); static atomic_t event_count = ATOMIC_INIT(0); static ssize_t button_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { /* 等待事件:条件不满足则睡眠,被唤醒后重新判断 */ wait_event_interruptible(button_waitq, atomic_read(&event_count) > 0); atomic_dec(&event_count); if (copy_to_user(buf, "1", 1)) return -EFAULT; return 1; } static __poll_t button_poll(struct file *filp, poll_table *wait) { poll_wait(filp, &button_waitq, wait); /* 把等待队列登记给轮询框架 */ return atomic_read(&event_count) > 0 ? EPOLLIN : 0; }
💡 关键直觉:
wait_event_interruptible的第二个参数是"醒来条件"而不是"睡一下"——被唤醒后会重新求值条件,假唤醒自动过滤。把条件写成"状态而非事件",是这类代码的正确心法。
追问一:中断处理函数里调用日志打印安全吗? 打印本身不睡眠,技术上合法;但打印走的是带锁的输出通道,高频中断里反复打印,锁竞争会吃掉响应时间,日志还会把真正的线索淹没。排查期的克制用法是"事件到来打一行、状态变化打一行";定位到怀疑区间后,改成计数、再用状态文件读数,让中断路径回到"快进快出"的本分。日志是拐杖,不是常态设施。
追问二:共享中断线上,我的处理函数凭什么分辨"这一波不是我的"? 靠设备自己的状态寄存器:处理函数先读中断状态位,未置位就立刻返回"不归我"。这也是注册共享中断时必须提供设备标识的原因——框架没有魔法,分辨全靠约定。反面教材是"拿到中断就当自己的处理",两台设备互相误认、循环唤醒,处理器占用瞬间拉满。第 7 章排错实录里"中断失踪"一案的排查,就从核对这条约定开始。
read 与 poll 才能优雅阻塞。单字节的事件靠中断足够了,可摄像头一帧就是几兆数据。下一节请出 DMA——让外设自己搬数据,CPU 只在搬完时被叫一声。