3. 顶半部与底半部:为什么需要底半部、顶半部的职责、底半部的职责与调度时机

在 Linux 内核的中断处理机制中,将中断处理程序划分为“顶半部”(Top Half)和“底半部”(Bottom Half)是一种核心设计模式。这种划分并非随意为之,而是为了解决中断处理中一个根本性的矛盾:中断处理必须快速完成,但某些处理逻辑却可能耗时较长。

核心矛盾:中断服务程序(ISR)执行时,当前 CPU 的中断是关闭的(或至少当前中断线被屏蔽)。如果 ISR 占用 CPU 时间过长,将导致其他中断无法被及时响应,造成系统延迟甚至数据丢失。因此,ISR 必须“短平快”,而将繁重的工作推迟到更合适的时机执行。

3.1 为什么需要底半部?

如果没有底半部机制,所有中断处理工作都必须在顶半部(ISR)中完成,会带来以下严重问题:

  • 中断延迟增加:长时间关闭中断会阻止其他设备(如网卡、磁盘)的中断请求,导致系统整体吞吐量下降。
  • 实时性降低:高优先级的中断无法抢占正在执行的低优先级 ISR,破坏了系统的实时响应能力。
  • 嵌套复杂性:在 ISR 中处理复杂逻辑(如内存分配、信号量等待)容易导致死锁或栈溢出。

底半部的引入,使得中断处理被拆分为两个阶段:

特性 顶半部 (Top Half) 底半部 (Bottom Half)
执行上下文 中断上下文(不可睡眠) 软中断/任务上下文(可睡眠,取决于具体机制)
中断状态 所有中断(或本线中断)被屏蔽 中断已开启,可被抢占
执行要求 极快、不可阻塞 相对宽松,可处理耗时任务
典型操作 保存硬件状态、清除中断标志、启动 DMA 数据拷贝、协议处理、唤醒等待队列
设计原则:顶半部只做“最少且必要”的工作,将剩余的数据处理、协议解析等逻辑推迟到底半部。这类似于紧急事件处理中的“先止血,后治疗”。

3.2 顶半部的职责

顶半部是直接响应硬件中断的 ISR。它的职责被严格限定在“与硬件交互的最小必要操作”范围内:

  1. 确认中断:读取硬件状态寄存器,确认中断源是否有效。
  2. 清除中断标志:向硬件写入确认位,清除中断挂起状态,使硬件可以产生下一次中断。
  3. 保存关键数据:从硬件 FIFO 或寄存器中读取时间敏感的数据(如网络数据包的第一个字节、按键扫描码),存入内核缓冲区。
  4. 标记底半部:通过调用 tasklet_schedule()、workqueue 或触发软中断,通知内核有底半部工作待处理。
  5. 启动后续硬件操作:如启动下一次 DMA 传输、重新使能中断等。
// 顶半部示例:网卡中断处理程序
static irqreturn_t my_net_interrupt(int irq, void *dev_id) {
    struct net_device *dev = (struct net_device *)dev_id;
    struct my_priv *priv = netdev_priv(dev);

    // 1. 读取中断状态寄存器
    u32 status = readl(priv->ioaddr + INT_STATUS);

    // 2. 清除中断标志
    writel(status, priv->ioaddr + INT_STATUS);

    // 3. 如果是接收中断,将 skb 放入私有队列
    if (status & RX_INT) {
        struct sk_buff *skb = ...; // 从硬件读取数据包
        __skb_queue_tail(&priv->rx_queue, skb);
        // 4. 调度底半部(tasklet)
        tasklet_schedule(&priv->rx_tasklet);
    }

    return IRQ_HANDLED;
}
避坑指南:顶半部中绝对禁止调用可能导致睡眠的函数,如 kmalloc(GFP_KERNEL)、mutex_lock()、copy_to_user()。这些操作必须推迟到底半部。

3.3 底半部的职责与调度时机

底半部负责执行顶半部无法完成的“重活”。根据不同的底半部机制,其调度时机和执行上下文有所不同。

3.3.1 底半部的职责

  • 数据协议处理:解析网络协议栈、处理文件系统元数据。
  • 数据拷贝:将数据从内核缓冲区拷贝到用户空间缓冲区。
  • 复杂计算:校验和计算、加密/解密操作。
  • 唤醒等待进程:当数据准备好时,唤醒在等待队列上睡眠的进程。
  • 释放内存:清理不再使用的缓冲区或数据结构。

3.3.2 调度时机

底半部不会立即执行,而是在内核认为“安全”的时机被调度。不同机制的调度时机如下:

底半部机制 调度时机 执行上下文
软中断 (Softirq) 中断返回前夕(irq_exit() 中检查) 软中断上下文(不可睡眠)
Tasklet 基于软中断实现,调度时机同软中断 软中断上下文(不可睡眠)
工作队列 (Workqueue) 内核工作线程(kworker)被唤醒后 进程上下文(可睡眠)

下图展示了从硬件中断触发到底半部执行的完整流程:

中断处理流程:顶半部与底半部调度时机 中断处理流程:顶半部与底半部调度时机 硬件中断触发 顶半部 ISR 执行 调度底半部 中断返回 (irq_exit) 检查待处理软中断 执行底半部 (Tasklet/Workqueue) 关键时机点 底半部在中断返回前 被检查并调度执行 此时中断已重新开启

3.4 三种底半部机制的对比

Linux 内核提供了多种底半部实现机制,开发者需要根据场景选择最合适的一种:

特性 软中断 (Softirq) Tasklet 工作队列 (Workqueue)
执行上下文 软中断上下文 软中断上下文 进程上下文
是否可睡眠 否 否 是
并发性 同一软中断可在多 CPU 上并行 同一 tasklet 串行执行 由工作线程池调度,可并行
使用复杂度 高(需静态声明) 低(动态创建) 中(需创建工作项)
典型用途 网络 RX/TX、块设备层 驱动中的常规底半部 需要睡眠的复杂操作
选择建议:对于大多数设备驱动程序,Tasklet 是首选的底半部机制,因为它简单且安全。如果底半部需要执行可能睡眠的操作(如访问文件系统、获取信号量),则必须使用工作队列。软中断通常留给对性能要求极高的核心子系统使用。

3.5 底半部调度时机详解

底半部的调度并非随机发生,而是由内核在特定的“安全点”进行检查和触发:

  • 中断返回路径:每次 ISR 执行完毕,CPU 准备返回被中断的进程时,内核会调用 irq_exit()。该函数会检查当前 CPU 上是否有待处理的软中断,如果有,则调用 do_softirq() 执行它们。这是底半部最主要的调度时机。
  • ksoftirqd 内核线程:当软中断的执行时间过长(超过 2 毫秒)或连续执行次数过多(超过 10 次),内核会将剩余软中断交给专用的 ksoftirqd 内核线程处理,避免软中断长时间占用 CPU 导致用户进程饥饿。
  • 显式调用:内核代码可以在任意位置调用 local_bh_enable() 或 do_softirq() 来主动触发底半部处理。
// 中断返回路径中的关键函数 (kernel/softirq.c 简化)
void irq_exit(void) {
    // ... 其他清理工作 ...
    if (!in_interrupt() && local_softirq_pending()) {
        // 如果不在中断上下文且有待处理软中断,则执行
        invoke_softirq();
    }
    // ... 返回被中断的上下文 ...
}

static inline void invoke_softirq(void) {
    if (ksoftirqd_running())
        return; // 如果 ksoftirqd 已经在运行,则不再重复触发

    if (!force_irqthreads) {
        // 直接在当前上下文执行软中断
        do_softirq();
    } else {
        // 强制线程化时,唤醒 ksoftirqd
        wakeup_softirqd();
    }
}
性能陷阱:底半部虽然运行在中断开启的环境下,但它仍然会抢占当前进程。如果底半部执行时间过长(例如超过 10 毫秒),会导致用户进程的调度延迟显著增加。对于需要长时间运行的任务,应使用工作队列并考虑设置合理的优先级。

通过将中断处理拆分为顶半部和底半部,Linux 内核在“响应速度”和“处理能力”之间取得了精妙的平衡。理解这一设计哲学,是编写高质量内核驱动的基础。