跳到主要内容

第 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(线程化中断) 模型。

这是一种非常聪明的妥协。它把中断处理拆成了两个阶段:

  1. Primary Handler (Hardirq): 就像传统的硬中断,仍然在原子上下文里运行。但它的任务被极度简化了——只做最紧急的事,比如确认硬件状态、屏蔽中断源。然后,它不返回 IRQ_HANDLED,而是唤醒一个内核线程。
  2. 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 拉开,看看策略。

正如我们前面提到的,中断处理程序有两大天敌:

  1. 它太慢了:处理太慢会丢后续的中断。
  2. 它太急了:不能睡眠,干不了重活。

为了调和这对矛盾,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_SOFTIRQTASKLET_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 来优雅地管理这一切的。