第 4 章 硬件中断与内核的应答之道
想象这样一个场景。
你正在聚精会神地写代码,或者正准备端起早就凉透的咖啡喝一口。突然,门铃响了。
那一瞬间,你的大脑必须立刻做出反应:把咖啡放下(保存现场),站起来(切换上下文),去开门(处理中断)。不管你当时正在做什么,门铃这种「硬中断」拥有最高优先级——它强迫你立刻做出响应。
操作系统里的硬件中断(Hardware Interrupt)就是这么回事。
但事情比这稍微复杂一点。
如果你的门铃每秒钟响一次,你还能写代码吗?或者更糟,门铃坏了,一直在响,你会不会疯掉?这就是我们在驱动开发中每天都在面对的现实:外设(键盘、鼠标、网卡、磁盘控制器)时刻都在向 CPU 发送信号。CPU 如果每次都亲自下场处理,什么都干不了。
这就引出了本章的核心议题:如何优雅地处理被打断的生活?
早期的操作系统很简单,CPU 听到铃声就跑过去,处理完再回来。但在现代高性能系统中,这种「老实人」策略行不通了。我们需要分层处理:有人负责应答(上半部/Hard IRQ),有人负责干脏活累活(下半部/Softirq/Threaded IRQ)。
本章的任务,就是搞清楚这套机制是怎么搭起来的。
4.1 硬件中断与内核的处理流
先别急着写代码。在动手分配 IRQ 之前,我们需要先把这个「门铃」的布线图搞清楚。
从硬件电信号到内核抽象
这一切始于设备上的一根物理导线。
当设备需要服务时(比如网卡收到了一个包),它会在这根特定的物理线上拉高电压(或者拉低,取决于电平逻辑)。这根线最终连到了主板上的一块芯片——在 x86 上叫 IO-APIC,在 ARM 上通常是 GIC(Generic Interrupt Controller)。这块芯片就像一个公司的总机秘书,它负责汇总所有打进来的电话,然后决定转接给哪条 CPU 线路。
这里有一个抽象层需要跨越。
对于 CPU 来说,它不知道「网卡」或者「键盘」是什么。它只知道「第 24 号中断线」触发了。这个数字,我们称之为 IRQ(Interrupt ReQuest)。
你可以把 IRQ 理解为硬件中断的虚拟身份证号。内核维护了一个从 IRQ 号到具体设备处理函数的映射表。当总机(APIC/GIC)告诉 CPU "嘿,24 号线有活" 时,CPU 会查表,找到对应的处理函数跳过去执行。
通用 IRQ 处理层
但「查表」这个动作其实很麻烦。
不同的中断控制器(PIC, IO-APIC, GIC)操作方式完全不同。如果驱动程序每次都要去写底层寄存器配置,那内核代码就会乱成一锅粥。
为了屏蔽这些硬件差异,Linux 内核发明了 Generic IRQ handling layer(通用 IRQ 处理层)。
这一层的存在非常关键。它就像一个适配器。上层的驱动程序只需要调用标准的 API(比如「给我分配 24 号中断」),底层的通用层负责把这个请求翻译成给中断控制器的具体指令。这一层还得负责处理各种棘手的情况:比如两个设备共用一根线(共享中断 IRQ),或者中断处理函数正在执行时又来了一个同样的中断(中断屏蔽)。
现在,我们建立了一个完整的心理模型:外设拉线 $\rightarrow$ 中断控制器汇总 $\rightarrow$ CPU 捕获 $\rightarrow$ 内核通用层分发 $\rightarrow$ 驱动处理函数执行。
接下来的问题就是:作为驱动作者,我们怎么把自己挂到这条链上?
分配硬件 IRQ
要接收中断,你得先向内核「预订」这个号码。
这不仅仅是注册一个回调函数那么简单。内核在这个过程中要做很多后台工作:检查这个 IRQ 线是不是已经被占用了?是不是允许共享?对应的设备结构体 dev 是什么?中断触发方式是电平触发还是边沿触发?
所有的这些配置,最终都收敛到一个古老的 API 上:request_irq()。
虽然现代代码更推荐用它的「托管版本」(我们后面会讲),但理解 request_irq() 是基础。它的原型长这样:
static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
const char *name, void *dev)
别被参数吓到了,它们都有明确的职责:
irq: 你要申请的硬件中断号。这通常来自设备树或者是platform_get_irq()的返回值。handler: 你的中断处理函数。这是核心,等下细讲。flags: 标志位。这很关键,它定义了中断的行为(比如是快速中断还是共享中断)。name: 传递给/proc/interrupts显示的字符串。起个好名字,调试时你会感谢自己的。dev: 一个指向设备特定数据的指针。如果设置了IRQF_SHARED标志,这个参数就至关重要,因为它用来区分共享同一根 IRQ 线的不同设备。
如果分配成功,返回 0。失败的话,返回一个负的错误码(比如 -EBUSY 表示这根线被人占用了,而且对方不愿意共享)。
实现中断处理程序
现在来看看 handler。
当中断真的发生时,内核会暂停当前的进程,保存上下文,然后跳转到这个函数执行。这意味着你的代码运行在一个非常特殊的环境里:中断上下文。
这里有一件极其重要的事情要印在脑子里:
在中断处理程序里,绝不能睡眠。
你不能调用任何会阻塞的函数,不能申请带 GFP_KERNEL 标志的内存(要用 GFP_ATOMIC),也不能使用互斥锁。你的动作必须快如闪电,处理完最紧急的状态确认,然后立刻退出。
标准的处理函数签名是这样的:
static irqreturn_t my_isr(int irq, void *dev_id)
{
/* 1. 检查设备状态,确认是不是我们的设备发出的中断 */
/* 2. 如果是,清除设备寄存器里的中断 pending 位 */
/* 3. 执行最小限度的处理工作 */
return IRQ_HANDLED; // 或者 IRQ_NONE
}
注意返回值类型 irqreturn_t。
- 返回
IRQ_HANDLED:表示你的设备确实触发了中断,并且你已经处理了。 - 返回
IRQ_NONE:表示这个中断不是你的设备触发的(这在共享中断的场景下很常见)。
使用线程化中断模型
「不能睡眠」这个限制太痛苦了。
有些硬件的中断处理逻辑真的很复杂,可能需要通过慢速的总线(比如 I2C)去读数据,或者需要加锁访问临界区。如果在硬中断里干这些,系统延迟会爆炸。
为了解决这个问题,现代 Linux 内核引入了 Threaded IRQ(线程化中断) 模型。
这是一种非常聪明的妥协。它把中断处理拆成了两个阶段:
- Primary Handler (Hardirq): 就像传统的硬中断,仍然在原子上下文里运行。但它的任务被极度简化了——只做最紧急的事,比如确认硬件状态、屏蔽中断源。然后,它不返回
IRQ_HANDLED,而是唤醒一个内核线程。 - Threaded Handler: 这是一个运行在内核线程上下文里的函数。在这个线程里,你可以睡眠,可以使用互斥锁,可以做所有进程上下文能做的事。
要使用这个模型,你需要用 request_threaded_irq() API:
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
irq_handler_t thread_fn,
unsigned long flags,
const char *name, void *dev)
这里的关键是 thread_fn。
- 如果你提供了
thread_fn,内核会在中断发生时,先执行handler(如果非空),然后强制唤醒thread_fn对应的内核线程。 - 如果你把
handler设为NULL,内核会提供一个默认的handler,它的唯一作用就是唤醒你的thread_fn。
这非常符合现代驱动的需求:把紧急的应答(Hardirq)和繁重的处理逻辑彻底解耦。
启用与禁用 IRQ
有时候,你需要在代码里暂时关掉中断。
也许你在重新配置设备,也许你在处理一个竞态条件。内核提供了几个不同的 API,它们的作用域完全不同,千万别搞混了。
最底层的是 local_irq_disable() 和 local_irq_enable()。
这俩是「核武器」级别的。它们会禁止当前 CPU 核心上的所有中断。除了不可屏蔽中断(NMI),什么都进不来。这通常用于内核核心代码的保护临界区,驱动代码极少需要用到这个。
如果你只是想关掉你当前设备这一条 IRQ 线,应该用 disable_irq() 和 enable_irq()。
disable_irq(n) 会禁止 IRQ 号为 n 的中断在整个系统中传递(如果它是共享的,连累其他设备也一起被禁了)。此外,它还会等待当前正在执行的中断处理程序跑完。
如果你不想等待,可以用 disable_irq_nosync(),它会立即返回,但可能导致正在处理的中断还没结束就被新的逻辑打断,这是很危险的。
查看已分配的 IRQ 线路
我们不是在瞎写代码。系统里发生了什么,是有迹可循的。
你可以随时查看 /proc/interrupts 文件。这是一张实时的「战报表」:
$ cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
0: 46 0 0 0 IO-APIC 2-edge timer
1: 3 0 0 0 IO-APIC 1-edge i8042
24: 10234 5601 4200 8991 PCI-MSI 524288-edge eth0
...
每一行代表一个 IRQ。
- 第一列是 IRQ 号。
- 后面几列是各个 CPU 核心上的中断计数器(每次中断发生,计数+1)。
- 再后面是中断控制器类型和设备名称。
如果你怀疑你的中断丢失了,或者没有被触发,第一件事就是看这个文件。如果计数器一直是 0,说明硬件根本没拉线,或者线接错了。
4.2 理解并使用上半部与下半部
现在我们把视角从微观的 API 拉开,看看策略。
正如我们前面提到的,中断处理程序有两大天敌:
- 它太慢了:处理太慢会丢后续的中断。
- 它太急了:不能睡眠,干不了重活。
为了调和这对矛盾,Linux 社区在几十年的演化中,沉淀出了一套标准的分治策略:Top Halves(上半部) 和 Bottom Halves(下半部)。
这不仅仅是 Linux 的专利,几乎所有现代操作系统都在做类似的事。但在 Linux 里,实现方式极其丰富:Softirq、Tasklet、Workqueue、Threaded IRQ。这里我们重点前两种。
软中断与内核线程
最底层的机制是 Softirq(软中断)。
软中断是在编译内核时静态定义的向量。你不能在驱动模块里动态注册一个新的软中断类型。内核预定义了几个常用的,比如 HI_SOFTIRQ(高优先级)、NET_TX_SOFTIRQ(网络发包)、NET_RX_SOFTIRQ(网络收包)等。
软中断的执行时机非常微妙。它通常会在硬中断处理程序返回之前被检查。如果发现有挂起的软中断,内核会立刻找一个时间点去执行它。
但这里有个坑。
如果系统里网络流量巨大,软中断就会疯狂触发。此时 CPU 忙于处理软中断,用户进程完全得不到调度机会,看起来系统就像死机了一样。这就是传说中的 softirqd 爆炸。
为了解决这个问题,内核创造了 ksoftirqd 这个内核线程。当软中断处理过于频繁时,内核会把这些工作甩锅给 ksoftirqd 线程,让它作为普通的进程去调度,防止饿死用户进程。
你可以通过 /proc/softirqs 看到各个软中断的统计情况:
$ cat /proc/softirqs
CPU0 CPU1
HI: 0 0
TIMER: 2831401 2703456
NET_TX: 123 89
NET_RX: 1234567 9876543
BLOCK: 45000 32000
使用 Tasklet
对于驱动开发者来说,Softirq 太底层了,也太危险(没有锁保护,容易 SMP 竞态)。我们需要一个更友好的接口。
这就是 Tasklet。
Tasklet 本质上是建立在 Softirq 之上的一种动态机制(具体用的是 HI_SOFTIRQ 或 TASKLET_SOFTIRQ)。它允许你在运行时注册,并且保证:同一个 tasklet 永远不会同时在两个 CPU 上运行。
这大大简化了并发控制。
使用 Tasklet 通常分两步:
第一步:定义并初始化 tasklet 结构体。
#include <linux/interrupt.h>
/* 定义 tasklet 处理函数 */
void my_do_tasklet(unsigned long data);
/* 定义 tasklet 结构体并静态初始化 */
DECLARE_TASKLET(my_tasklet, my_do_tasklet, 0);
如果你需要动态分配(比如在 probe 函数里),可以用 tasklet_init:
struct tasklet_struct my_tasklet;
tasklet_init(&my_tasklet, my_do_tasklet, (unsigned long)data);
第二步:调度它。
当你需要把重活扔给下半部时,只需要在中断处理程序里调用:
tasklet_schedule(&my_tasklet);
这就像是在老板(CPU)的耳朵边小声说:「这事儿我现在标记下来了,你稍后有空找那个叫 my_tasklet 的家伙处理。」
内核会在随后的某个时刻(通常是开中断后不久)执行 my_do_tasklet。
这种「异步调度」的感觉,是理解整个 Linux 中断子系统的钥匙。
4.3 几个遗留问题的解答
到这里,关于中断的核心机制已经讲得差不多了。但在实际动手前,还有几个常让人困惑的细节需要澄清。
边沿触发 vs 电平触发
这是硬件层面的一个经典分歧。
- 电平触发:只要中断线保持高电平,中断就会一直被触发。这通常用于总线共享的场景。这意味着处理程序必须在处理完之后把电平拉低(或者操作硬件让设备拉低),否则会死循环进入中断。
- 边沿触发:只有当电平从低变高的那一瞬间(上升沿)触发一次。之后不管你保持高电平多久,它都不会再触发,除非你拉低再拉高。
边沿触发相对简单,不太容易丢中断(如果只是瞬间脉冲),但如果不小心处理中断太快,可能会错过第二次脉冲。电平触发则很稳健,但如果硬件没处理好,就会变成「中断风暴”。
中断上下文指南:什么该做,什么不该做
这里有一份简单的生存法则。
在硬中断上下文里:
- ✅ 可以:修改寄存器、使用
spinlock(不能睡眠的那种)、读写per-cpu变量。 - ❌ 禁止:调用
kmalloc(GFP_KERNEL)、调用mutex_lock()、调用任何可能引起schedule()的函数。
怎么检查?内核提供了一个宏 might_sleep()。如果你在不能睡眠的地方调用了可能睡眠的函数,内核配置了 DEBUG_ATOMIC_SLEEP 的话,系统会直接 panic 并打印堆栈。相信我,这比以后莫名其妙死机要好得多。
中断掩码:默认行为与控制
当你进入一个中断处理程序时,内核为了防止重入,默认会屏蔽当前这条 IRQ 线在所有 CPU 上的中断。
注意这个细节:它在所有 CPU 上屏蔽了这条线。 为什么?因为如果你在 CPU0 上处理这个中断,CPU1 又收到了同一个设备的中断,而且它们共享数据,那你就得加锁。为了简化这种复杂度,内核默认采取了「独占」策略。
但这会影响性能。如果你确定你的处理很快,而且你能通过自旋锁保护好数据,你可以使用 IRQF_DISABLED 标志(这个现在很少用了,因为现在的内核默认行为已经很复杂了),或者在处理函数内部手动调用 irq_set_affinity 来调整。
中断栈:内核维护独立的栈吗?
这是个很深的问题。
在早期的 Linux 里,中断是直接借用当前进程的内核栈的。这意味着如果你的进程栈本身就很深(比如复杂的系统调用嵌套),突然来了个中断,栈可能就溢出了。
现在的内核(绝大多数架构)为每个 CPU 都分配了一个独立的 irq_stack。
当中断发生时,CPU 会切换到这个专门的栈上运行。这大大增加了系统的健壮性,也允许我们在中断里做稍微多一点一点的事情(但也只是多一点而已)。
好了,理论铺垫得差不多了。现在我们手里有了「门铃」(硬件中断)、有了「秘书」(通用 IRQ 层),也有了「办事员」(处理函数)。
在下一节,我们将把这些理论应用到真正的代码中,看看一个现代驱动是如何通过托管资源 API 来优雅地管理这一切的。