Linux 中断为什么必须“上半部快进快出”:从 hard IRQ、threaded IRQ 到 softirq 与 workqueue
驱动收到中断后,不是所有工作都能留在处理函数里做。真正关键的问题是:当前处于什么执行上下文,能不能睡眠,谁可能并发访问同一份数据,以及剩余工作应该下沉到哪一种机制。
设备产生 IRQ,CPU 跳进驱动处理函数。初学者最自然的写法,是在中断里读完整帧、申请内存、拿 mutex、访问 I²C,甚至等待硬件下一次状态变化。代码看起来集中,实际却很容易带来系统卡顿、scheduling while atomic、死锁和中断风暴。
Linux 把中断工作拆开,并不是为了增加 API 数量,而是因为不同工作有不同的时效和上下文要求。硬件状态必须立刻确认,数据可以稍后批量处理,会睡眠的操作则必须交给可调度的线程。
一、从设备到驱动,中断先经过哪些层
SoC 外设拉起中断线后,信号先进入中断控制器,例如 Arm GIC。CPU 响应异常,体系结构入口保存现场,读取中断号,再进入 Linux generic IRQ 层。内核用 IRQ number 找到 irq_desc,通过 irqchip 回调完成 mask、ack、eoi 等控制,并调用注册在这条 IRQ 上的 action。
驱动通常在 probe 中完成:
irq = platform_get_irq(pdev, 0);ret = devm_request_threaded_irq(dev, irq, my_irq_top, my_irq_thread, IRQF_ONESHOT, dev_name(dev), data);
设备树里的 interrupts 描述硬件中断来源和触发类型,irqdomain 把固件中的硬件编号映射成 Linux IRQ。驱动不应把 DTS 里的数值直接当作可传给 request_irq() 的 Linux IRQ。
二、top half 到底应该做多少事
传统“上半部”就是硬中断处理函数。它的目标不是完成所有业务,而是以最短路径让硬件和内核回到可继续运行的状态。
典型动作只有几类:
- 清除或暂时屏蔽中断源,防止同一事件持续轰炸 CPU;
- 从小型 FIFO 取出必须立即保存的数据,或记录 DMA 完成状态;
- 返回
IRQ_HANDLED、IRQ_NONE 或 IRQ_WAKE_THREAD。
共享 IRQ 场景下,如果状态寄存器证明事件不属于本设备,应返回 IRQ_NONE。无条件返回 handled 会掩盖错误,也会让内核无法判断中断线是否存在异常。
硬中断上下文不能睡眠,没有可供阻塞等待的普通进程语义。这里不能调用 mutex_lock()、msleep()、可能阻塞的总线 I/O,也不能使用 GFP_KERNEL 做可能睡眠的内存分配。
“函数执行很快”不等于“适合放在 hard IRQ”。只要它可能睡眠,就不属于硬中断上下文。
三、为什么硬中断拖得越长,系统越难预测
一个 CPU 正在处理 hard IRQ 时,当前任务被打断。处理时间过长,会直接扩大高优先级任务的响应延迟,也会推迟该 CPU 上其他待处理工作。
在高频设备上,后果更明显:
- UART 或 SPI FIFO 得不到及时服务,出现 overrun;
- 网络包处理堆积,softirq 被推给
ksoftirqd; - 实时线程明明优先级很高,却仍被不可调度的硬中断占住 CPU;
- 中断处理结束前设备状态又变化,驱动丢失边沿或重复处理。
因此,中断优化的第一目标通常不是让 top half “做得更快一点”,而是减少它必须做的事情。
四、threaded IRQ:需要及时处理,又必须允许睡眠
线程化中断适合大量设备驱动。primary handler 读取状态、屏蔽设备中断,然后返回 IRQ_WAKE_THREAD;内核唤醒该 IRQ 对应的内核线程执行 thread_fn。
thread_fn 在进程上下文运行,可以睡眠,也可以使用 mutex,适合:
IRQF_ONESHOT 常与 threaded IRQ 搭配。在线程函数结束前,中断线保持被屏蔽,避免线程尚未处理完,同一个电平触发中断再次进入。线程结束前应把设备状态清干净,并按硬件要求重新打开中断源,否则会出现线程不断被唤醒或中断永久消失。
如果注册时 primary handler 为 NULL 而提供 thread_fn,内核可安装默认 primary handler 来唤醒线程。但共享中断通常仍需要驱动自己的 top half 判断事件是否属于本设备。
五、softirq:为何适合高吞吐,却仍不能睡眠
softirq 是软件中断上下文,常在硬中断返回路径或 ksoftirqd 中处理。网络接收、定时器等内核核心路径大量使用它。
softirq 能把大量工作从 hard IRQ 移开,并允许多个 CPU 并行处理,但它仍属于原子上下文,不能调用可能睡眠的接口。它还可能在不同 CPU 上同时运行,因此共享数据必须考虑真正的 SMP 并发。
NAPI 是网络驱动对中断与 softirq 的经典配合:
网卡产生 RX IRQ ↓hard IRQ 屏蔽 RX 中断,调度 NAPI ↓NET_RX_SOFTIRQ 批量 poll 描述符 ↓处理到 budget 或队列清空 ↓队列清空后重新开启 RX IRQ
高包速率下,如果每个包都产生一次完整硬中断,CPU 会陷入中断风暴。NAPI 把模式从“每包打断一次”切换为“收到通知后批量轮询”,用可控 budget 限制一次处理量。
一般设备驱动不应为了递延工作随意新增 softirq 类型。softirq 是有限的核心机制,普通可睡眠工作优先考虑 threaded IRQ 或 workqueue。
六、workqueue:把事情交给真正可调度的工作线程
workqueue 的 work item 由 worker 线程执行,属于进程上下文,可以睡眠。它适合不要求紧贴中断完成、但可能较耗时或需要阻塞的任务,例如:
驱动可以使用系统 workqueue,也可以在有并发、优先级或资源隔离要求时创建专用 workqueue。不要为每个小功能随意创建单线程队列;现代 workqueue 依靠共享 worker pool 管理并发,正确设置 work item 与队列属性通常比盲目创建线程更好。
queue_work() 只保证把尚未 pending 的 work 入队。若同一个 work 已经 pending,再次 queue 可能不会产生第二次执行。中断事件不能只用一个布尔状态表示时,应使用计数器、环形队列或硬件描述符索引保存事件,避免多个 IRQ 合并后丢信息。
七、threaded IRQ 和 workqueue 到底怎么选
两者都能睡眠,但语义不同。
threaded IRQ 与具体 IRQ action 直接关联,适合中断处理的主体部分。它的唤醒、屏蔽和 IRQF_ONESHOT 语义都围绕这条中断线组织,处理完成通常意味着设备中断条件已经解除。
workqueue 是通用异步任务机制,适合从 IRQ、timer 或普通进程路径共同提交工作,也适合把恢复、通知等非即时任务继续向后推。
一个常见设计是:
top half:确认事件、屏蔽中断、保存最小状态threaded IRQ:读取设备、搬运一批数据、解除中断条件workqueue:执行耗时恢复、重新枚举或跨子系统通知
并不是层级越多越先进。若 threaded IRQ 已能安全完成全部处理,就没有必要再绕一层 workqueue。选择标准是执行上下文、时延、并发与睡眠需求,而不是 API 的复杂程度。
八、中断里的锁:先问谁可能打断谁
进程上下文与同一数据结构的 hard IRQ 共享数据时,只用普通 spin_lock() 可能死锁:进程拿锁后被该 IRQ 打断,IRQ 又等待同一把锁,而被打断的进程无法继续释放它。
进程侧通常需要 spin_lock_irqsave() 禁止本地中断并保存状态;IRQ 侧再使用相匹配的自旋锁。若只与 softirq 共享,可考虑 spin_lock_bh()。
mutex 只能用于可睡眠进程上下文,因此适合 threaded IRQ 和普通 workqueue,不适合 hard IRQ 与普通 softirq。
锁还不是唯一问题。IRQ 与进程之间用 lockless ring buffer、原子变量或完成量时,也必须理解内存排序。不要用一个普通布尔变量假装完成了跨 CPU 同步。
可开启 lockdep、IRQ flags tracing 等调试配置,让内核检查某把锁是否曾在 hardirq/softirq 上下文使用,以及锁顺序是否形成循环。
九、中断风暴通常不是“中断太多”,而是源没有解除
电平触发中断会在设备条件持续存在时反复报告。如果驱动没有清状态、没有读完 FIFO,或清除顺序与手册要求相反,CPU 刚退出处理函数就会再次进入。
常见错误包括:
- write-one-to-clear 位用读改写,误清或漏清;
- threaded handler 结束前设备再次拉低中断线;
- 共享 IRQ 中所有驱动都返回
IRQ_NONE;
排查时先读:
cat /proc/interruptscat /proc/irq//smp_affinity_listgrep . /sys/kernel/debug/irq/irqs//* 2>/dev/null
观察计数是否高速增长、集中在哪个 CPU、是否出现未处理或屏蔽异常。再在驱动中记录设备原始状态、mask 状态和清除后的状态,而不是只打印“IRQ happened”。
十、怎样测出是谁拖慢了中断
仅看平均 CPU 占用看不出尾延迟。更有效的是观察进入、退出和递延工作的时间线。
可使用 ftrace 的 irq_handler_entry、irq_handler_exit、softirq_raise、softirq_entry、softirq_exit、workqueue_queue_work、workqueue_execute_start 等 tracepoint。perf sched、BPF 工具和实时延迟追踪也能帮助定位。
如果 IRQ handler 本身只有几十微秒,但 workqueue 数百毫秒后才执行,问题可能是 worker 拥塞或调度优先级;如果 softirq 长时间占 CPU,可能是负载超过 budget,ksoftirqd 持续运行;如果硬中断突然出现毫秒级尾部,就应检查循环次数、总线访问和错误重试。
在多核 SoC 上还要看 affinity。把高频网络 IRQ、存储 IRQ 和实时控制线程全绑到同一个 CPU,会制造人为拥塞。调整 affinity 前应理解缓存局部性、NAPI/RPS、NUMA 与驱动队列对应关系,不能只把中断平均分散。
十一、驱动退出与 suspend 时,递延工作必须真正停干净
remove、错误回滚和 suspend 路径常见 use-after-free:设备私有结构已经释放,排队的 threaded IRQ 或 work item 仍在运行。
正确顺序通常包括:先阻止硬件产生新事件,屏蔽设备中断;用 synchronize_irq() 等待正在执行的 handler 结束;取消或 flush 相关 work;停止 DMA;最后再释放内存、时钟和寄存器映射。
disable_irq() 会等待当前 handler 完成,disable_irq_nosync() 不会,两者不能凭名字随意替换。在可能由同一 IRQ handler 依赖的上下文中调用同步版本,还要避免自我等待。
电源恢复后也不能只 enable_irq()。控制器寄存器、设备 mask 和 pending 状态可能因掉电丢失,应先恢复硬件,再清除陈旧状态,最后开放中断。
十二、边沿触发与电平触发,决定了清除顺序
边沿触发记录的是信号变化,电平触发反映的是线路当前仍处于有效状态。驱动若不理解二者差异,即使 top half 很短,也可能丢中断或制造风暴。
电平触发设备通常要求先让设备解除条件,再在中断控制器侧完成处理。若设备状态未清,线路仍保持有效,CPU 会在重新开放后立刻再次进入。优点是事件不容易因为暂时屏蔽而彻底消失,缺点是错误清除路径会持续占用 CPU。
边沿触发不会因为线路保持某电平而持续报告。屏蔽期间若发生新边沿,能否被锁存取决于设备和中断控制器。驱动如果只用一个布尔标志保存事件,连续多个边沿可能被合并;对于计数型事件,应从硬件计数器、FIFO 或环形队列恢复真实数量。
设备树中的触发类型必须与电路一致。把低电平有效写成下降沿,可能在初次变化时看似可用,却在状态保持期间丢失后续语义;把边沿源写成电平,也可能导致无法解除。调试时应同时查看芯片手册、原理图中的反相关系、GPIO/pinctrl 配置以及 /proc/interrupts 计数。
共享中断通常更适合电平语义,因为每个设备都能读取自己的状态判断是否命中。无论何种触发方式,primary handler 都应先确认本设备状态,再决定返回 IRQ_NONE、直接 handled,还是唤醒线程。
十三、一个 GPIO 按键中断,为什么也不能直接做业务
GPIO 按键看似简单,却能完整暴露中断设计问题。机械触点会抖动,一次按下可能产生多个边沿。若 hard IRQ 里直接上报业务动作,用户会看到一次按键触发多次。
合理做法通常是在 top half 只记录事件并屏蔽或节流,随后用定时器、delayed work 或输入子系统提供的去抖能力,在稳定窗口后重新读取引脚。若 GPIO 控制器的 get_value 路径可能睡眠,例如它本身挂在慢速总线上,就必须放到可睡眠上下文,不能在 hard IRQ 中读取。
这类设计还要处理 suspend 唤醒:用 irq_set_irq_wake() 或设备电源管理接口配置唤醒源,并区分“允许该 IRQ 唤醒系统”与“运行期间启用 IRQ”是两套状态。恢复时清掉唤醒期间积累的 pending,再恢复正常去抖和上报流程。
小小按键说明了一条通用原则:中断只证明硬件发生了某个信号事件,不等于业务语义已经确定。确认、去抖、解析和通知应放在各自合适的上下文。
十四、最后记住:递延的不是责任,而是执行上下文
中断设计可以压缩成四个问题:
什么状态必须立即确认? → hard IRQ什么处理紧随 IRQ 且需要睡眠? → threaded IRQ什么高吞吐核心路径不能睡眠? → softirq / NAPI什么工作可异步且可能阻塞? → workqueue
上半部快进快出,不等于随便把函数换个地方调用。每向后下沉一层,都要重新考虑事件是否会合并、数据由谁保存、锁怎样使用、设备何时重新 unmask,以及驱动卸载时如何同步结束。
真正可靠的中断驱动,不只是“中断来了能跑”,而是在高频、并发、错误、掉电和卸载条件下,都能让硬件状态、内核上下文与数据生命周期保持一致。