3. 中断处理程序设计:上半部与下半部、中断上下文、中断嵌套、中断屏蔽与使能

好,咱们接着聊中断。上一章我们把中断的硬件流程讲清楚了,这一章要深入软件层面——中断来了,CPU 该怎么处理?

我个人习惯把中断处理程序比作「救火队员」。火警响了,你得先冲进去关掉总闸(上半部),然后再慢慢清理现场、写事故报告(下半部)。如果处理不好,整个系统都可能被烧掉。

3.1 上半部与下半部:为什么非要拆开?

你想想看,中断处理程序是在关中断状态下执行的。这意味着什么?意味着其他中断进不来,CPU 只能干这一件事。如果你在中断处理程序里做大量耗时操作——比如网络协议栈处理、磁盘 I/O——那整个系统就「卡死」了。

所以,Linux 内核把中断处理拆成了两半:

  • 上半部(Top Half):快速、不可延迟。只做最紧急的事,比如清除中断标志、拷贝硬件寄存器数据。执行时关中断。
  • 下半部(Bottom Half):慢速、可延迟。做剩下的工作,比如数据处理、协议解析。执行时开中断。

核心原则:上半部越快越好,下半部越少越好。

我在项目中遇到过一个问题:某款网卡驱动把整个数据包处理都放在上半部,结果网络吞吐量一上来,系统就频繁丢包。后来我把数据拷贝留在上半部,把协议栈处理挪到下半部,问题就解决了。

3.1.1 上半部的实现要点

上半部说白了就是一个快速响应函数。它必须满足:

  • 执行时间在微秒级
  • 不能睡眠(不能调用可能引起阻塞的函数)
  • 不能持有锁(或者只能使用自旋锁)
  • 不能访问用户空间
// 一个典型的上半部处理程序
static irqreturn_t my_interrupt_handler(int irq, void *dev_id) {
    struct my_device *dev = (struct my_device *)dev_id;
    
    // 1. 读取硬件状态寄存器
    u32 status = readl(dev->regs + STATUS_REG);
    
    // 2. 清除中断标志(必须做,否则会一直触发)
    writel(status, dev->regs + CLEAR_REG);
    
    // 3. 把数据从硬件 FIFO 拷贝到内存缓冲区
    memcpy_fromio(dev->buffer, dev->regs + FIFO_DATA, FIFO_SIZE);
    
    // 4. 调度下半部
    tasklet_schedule(&dev->my_tasklet);
    
    return IRQ_HANDLED;
}

嗯,这里要注意:memcpy_fromio 这种操作虽然看起来慢,但它只是从硬件寄存器拷贝到内存,不涉及磁盘或网络,所以放在上半部是可以接受的。

3.1.2 下半部的三种实现方式

下半部的实现方式有好几种,我按使用频率排个序:

方式 特点 适用场景
Tasklet 基于软中断,不可睡眠,同一 tasklet 串行执行 大多数设备驱动
工作队列(Workqueue) 基于内核线程,可睡眠,可延迟 需要睡眠的操作(如 I/O)
软中断(Softirq) 最底层,性能最高,但使用复杂 网络子系统、块设备层

我个人最常用的是 tasklet,因为它简单、安全。但如果你需要做磁盘 I/O 或者获取信号量,那就必须用工作队列了。

// Tasklet 示例
static void my_tasklet_handler(unsigned long data) {
    struct my_device *dev = (struct my_device *)data;
    
    // 这里可以处理数据,但不能睡眠
    process_packet(dev->buffer, FIFO_SIZE);
}

// 工作队列示例
static void my_work_handler(struct work_struct *work) {
    struct my_device *dev = container_of(work, struct my_device, my_work);
    
    // 这里可以睡眠,比如写磁盘
    write_to_disk(dev->buffer, FIFO_SIZE);
}

3.2 中断上下文:一个特殊的运行环境

中断上下文,说白了就是 CPU 在执行中断处理程序时的「状态」。它和进程上下文完全不同:

  • 进程上下文:可以睡眠、可以调度、可以访问用户空间
  • 中断上下文:不能睡眠、不能调度、不能访问用户空间

为什么会这样?因为中断上下文没有自己的「进程控制块」(task_struct)。它借用了被中断进程的栈,但逻辑上独立。如果你在中断上下文里调用了 schedule(),内核会直接崩溃。

避坑指南:我曾经在中断处理程序里调用了 kmalloc(GFP_KERNEL),结果系统直接死机。因为 GFP_KERNEL 可能触发内存回收,而内存回收需要睡眠。正确的做法是用 GFP_ATOMIC

判断当前是否在中断上下文,可以用这个宏:

if (in_interrupt()) {
    // 当前在中断上下文,只能用 GFP_ATOMIC
    buf = kmalloc(size, GFP_ATOMIC);
} else {
    // 当前在进程上下文,可以用 GFP_KERNEL
    buf = kmalloc(size, GFP_KERNEL);
}

3.3 中断嵌套:能嵌套吗?怎么嵌套?

中断嵌套,就是在一个中断处理程序执行时,另一个更高优先级的中断来了。这时候 CPU 会暂停当前中断,去处理更高优先级的那个。

但这里有个关键点:上半部执行时,中断是关闭的。所以上半部之间不会嵌套。嵌套只发生在:

  • 上半部执行期间,更高优先级的中断被挂起,等上半部完成后才处理
  • 下半部执行期间(开中断),可以被任何中断打断

我记得在 ARM Cortex-A 系列处理器上,中断嵌套是硬件自动支持的。但软件层面,我们通常不建议深度嵌套。为什么呢?因为嵌套会导致栈空间急剧膨胀,而中断栈通常只有 4KB 或 8KB。

我的建议:除非你有非常明确的实时性要求,否则尽量保持中断嵌套深度不超过 2 层。我曾经在一个项目中看到 5 层嵌套,结果栈溢出导致系统随机崩溃,查了整整两天才找到原因。

3.4 中断屏蔽与使能:什么时候关?什么时候开?

中断屏蔽,说白了就是告诉 CPU:「先别理其他中断,我这儿正忙着呢。」

中断屏蔽分两个层级:

  1. CPU 级屏蔽:关掉整个 CPU 的中断响应
  2. 中断控制器级屏蔽:只屏蔽某个特定的中断源

3.4.1 CPU 级屏蔽

在 x86 上,用 cli(关中断)和 sti(开中断)。在 ARM 上,用 cpsid icpsie i。Linux 内核封装了这些操作:

// 关中断
unsigned long flags;
local_irq_save(flags);  // 保存当前中断状态并关中断

// ... 临界区代码 ...

// 开中断
local_irq_restore(flags);  // 恢复之前的中断状态

注意:local_irq_savelocal_irq_restore 必须成对使用。我曾经见过有人用 local_irq_disable 关中断,然后用 local_irq_enable 开中断,结果在嵌套调用时出了问题——因为 local_irq_disable 不保存状态,内层函数把外层的中断状态给覆盖了。

3.4.2 中断控制器级屏蔽

有时候你只想屏蔽某个特定设备的中断,而不是关掉所有中断。这时候要用中断控制器提供的接口:

// 屏蔽某个中断源
disable_irq(irq_num);       // 同步方式,会等待当前中断处理完成
disable_irq_nosync(irq_num); // 异步方式,不等待

// 使能某个中断源
enable_irq(irq_num);

disable_irqdisable_irq_nosync 的区别,我解释一下:

  • disable_irq:会等待正在执行的中断处理程序完成,然后才返回。适合在驱动卸载时使用。
  • disable_irq_nosync:立即返回,不等待。适合在中断处理程序内部使用(避免死锁)。

注意:千万不要在中断处理程序里调用 disable_irq!因为你在中断上下文里等待中断处理完成,而你自己就是那个中断处理程序——这就成了「自己等自己」,直接死锁。

3.5 实战经验总结

讲了这么多,我总结几条实战经验:

  1. 上半部越短越好:只做寄存器操作和数据拷贝,其他都丢给下半部。
  2. 下半部选型要谨慎:不需要睡眠用 tasklet,需要睡眠用工作队列。
  3. 中断屏蔽要精确:能用 disable_irq 就别用 local_irq_save,尽量减少对系统实时性的影响。
  4. 栈空间要留足:中断栈默认 4KB,如果嵌套层数多,建议增大到 8KB 或 16KB。
  5. 调试时多打日志:在中断处理程序入口和出口打印时间戳,可以帮你发现性能瓶颈。

嗯,中断处理程序设计就讲到这里。下一章我们会聊中断亲和性和负载均衡,那是多核系统下的进阶话题。