4. 顶半部(Top Half)与底半部(Bottom Half)设计哲学:为什么需要拆分?

中断处理程序必须快速执行,这是实时性与系统吞吐量的基本要求。然而,许多设备在中断到达后需要完成的工作量往往远超“快速”所能容忍的范围——例如网络驱动需要拷贝数据包、解析协议头、唤醒用户态进程。如果所有工作都在中断上下文中完成,系统将长时间关闭中断,导致更高优先级的中断丢失,或使 CPU 被锁死在中断处理中,用户进程完全得不到调度。

Linux 内核通过将中断处理拆分为 顶半部(Top Half) 和 底半部(Bottom Half) 来解决这一矛盾。顶半部在中断上下文中以原子方式执行最紧急的硬件操作,底半部则在稍后的安全时机完成剩余处理。这种拆分是中断子系统设计的核心哲学,也是驱动开发者必须掌握的基本功。

4.1 拆分的核心动机:中断延迟与上下文开销

中断处理程序执行期间,当前 CPU 上的中断通常被禁用(取决于中断标志位设置)。如果处理时间过长,后续中断将被阻塞,造成中断丢失或严重延迟。下表对比了不拆分与拆分后的关键差异:

对比维度 不拆分(全部在中断上下文) 拆分(顶半部 + 底半部)
中断关闭时长 整个处理过程(可能数毫秒) 仅顶半部(微秒级)
可延迟工作 必须同步完成,无法推迟 推迟到底半部,可被更高优先级中断抢占
上下文切换开销 无额外切换,但阻塞其他中断 底半部可能引入软中断/工作队列切换
实时性影响 高,中断延迟不可控 低,顶半部快速退出,中断延迟可控
适用场景 极简硬件确认(如 ACK 中断线) 数据拷贝、协议处理、唤醒等待队列
关键原则:顶半部只做“必须立即做且不做就会丢数据”的事,其余全部推到底半部。典型的顶半部工作包括:读取硬件状态寄存器、清除中断挂起位、将数据从 FIFO 拷贝到内存缓冲区、调度底半部。

4.2 顶半部与底半部的职责边界

明确划分职责是设计高质量中断驱动的第一步。下表给出了顶半部和底半部的典型职责清单:

职责项 顶半部(Top Half) 底半部(Bottom Half)
硬件寄存器操作 读取状态、清除中断标志、ACK 硬件 一般不直接操作硬件寄存器
数据搬运 从硬件 FIFO 拷贝到内存缓冲区(紧急) 从内存缓冲区拷贝到用户空间或协议栈
唤醒/通知 调度底半部(如触发 softirq/tasklet) 唤醒等待队列、发送信号、回调完成函数
锁的使用 只能使用 spinlock(不可睡眠) 可使用 mutex、sleepable lock(工作队列)
执行上下文 中断上下文(不可睡眠、不可调度) 软中断/进程上下文(可睡眠取决于机制)
执行优先级 最高,不可被其他中断抢占(当前 CPU) 可被顶半部中断,可被更高优先级底半部抢占
常见错误:在顶半部中调用 copy_to_user()、kmalloc(GFP_KERNEL)、mutex_lock() 等可能睡眠的函数。这些操作必须在底半部或进程上下文中完成,否则会导致内核崩溃(BUG: scheduling while atomic)。

4.3 拆分流程:从硬件中断到底半部调度

下图展示了典型的中断处理拆分流程,从硬件中断触发到底半部最终执行完毕:

顶半部与底半部拆分流程 中断处理拆分流程:从硬件中断到底半部执行 硬件中断触发 顶半部 (Top Half) 读状态 → 清标志 → 拷贝数据 → 调度底半部 中断返回 (开中断) 底半部调度 (softirq/tasklet/workqueue) 在安全时机执行剩余工作 底半部执行 (Bottom Half) 协议处理 / 唤醒进程 / 释放内存 处理完成 顶半部执行期间中断关闭(当前CPU),底半部执行期间中断已开启,可被更高优先级中断抢占

4.4 三种底半部机制对比

Linux 内核提供了多种底半部实现机制,驱动开发者需要根据实时性要求、执行开销和睡眠需求选择合适的方式:

机制 执行上下文 是否可睡眠 执行优先级 典型延迟 适用场景
softirq 软中断上下文 否 高(仅次于硬件中断) 微秒级 网络收发、块设备层(高吞吐、低延迟)
tasklet 软中断上下文 否 中(基于 softirq 实现) 微秒级 普通设备驱动、无需睡眠的中等负载
workqueue 进程上下文 是 低(受内核调度影响) 毫秒级 需要睡眠、持有 mutex、大量内存操作
选型建议:对于大多数外设驱动(如 GPIO、I2C、SPI),tasklet 是最平衡的选择——它基于 softirq 实现,但接口更简单,且同一 tasklet 不会在多个 CPU 上并发执行,减少了锁竞争。只有当你需要在内核线程中睡眠或执行重量级操作时,才考虑 workqueue。

4.5 代码示例:顶半部调度 tasklet

以下代码展示了一个典型的中断处理程序如何拆分:顶半部只做硬件确认和最小数据拷贝,然后调度 tasklet 完成后续处理。

/* 驱动结构体 */
struct my_device {
    void __iomem *base;
    struct tasklet_struct my_tasklet;
    struct net_device *ndev;
    spinlock_t lock;
};

/* 底半部处理函数 */
static void my_device_tasklet_handler(unsigned long data)
{
    struct my_device *dev = (struct my_device *)data;
    struct sk_buff *skb;

    /* 可以持有 mutex 或调用可能睡眠的函数 */
    skb = dev_alloc_skb(packet_len);
    if (!skb)
        return;

    spin_lock(&dev->lock);
    /* 从内存缓冲区拷贝完整数据包 */
    memcpy(skb_put(skb, packet_len), dev->rx_buffer, packet_len);
    spin_unlock(&dev->lock);

    /* 将数据包送入网络协议栈 */
    netif_rx(skb);
}

/* 顶半部:中断处理程序 */
static irqreturn_t my_device_interrupt(int irq, void *dev_id)
{
    struct my_device *dev = (struct my_device *)dev_id;
    u32 status;

    /* 1. 读取硬件状态寄存器 */
    status = ioread32(dev->base + REG_STATUS);

    /* 2. 检查是否为本设备中断 */
    if (!(status & INT_PENDING))
        return IRQ_NONE;

    /* 3. 清除中断挂起位(硬件必须立即确认) */
    iowrite32(INT_CLEAR, dev->base + REG_STATUS);

    /* 4. 从硬件 FIFO 紧急拷贝到内存缓冲区(最小操作) */
    spin_lock(&dev->lock);
    ioread32_rep(dev->base + REG_FIFO, dev->rx_buffer, FIFO_DEPTH);
    spin_unlock(&dev->lock);

    /* 5. 调度底半部 tasklet */
    tasklet_schedule(&dev->my_tasklet);

    return IRQ_HANDLED;
}

/* 驱动初始化时注册 tasklet */
static int my_device_probe(struct platform_device *pdev)
{
    struct my_device *dev;

    /* ... 分配资源、映射寄存器 ... */

    tasklet_init(&dev->my_tasklet, my_device_tasklet_handler,
                 (unsigned long)dev);

    /* 注册中断处理程序 */
    ret = request_irq(irq, my_device_interrupt, IRQF_SHARED,
                      "my_device", dev);
    /* ... */
}
设计要点:顶半部 my_device_interrupt 中只做了 4 件事:读状态、清标志、FIFO 拷贝、调度 tasklet。所有可能阻塞或耗时的操作(如 dev_alloc_skb、netif_rx)都放到了底半部。注意顶半部中使用了 spin_lock 而非 mutex_lock,因为中断上下文不允许睡眠。

4.6 拆分带来的上下文开销与权衡

拆分并非没有代价。每次底半部调度都会引入额外的上下文切换和调度延迟。下表量化了不同底半部机制的典型开销:

底半部机制 调度延迟(典型值) 额外内存开销 CPU 缓存影响 适用负载
softirq(直接触发) 1-5 µs 极低(静态分配) 低(同一 CPU 执行) 高吞吐、低延迟
tasklet 2-10 µs 低(每个 tasklet 约 40 字节) 低(串行化执行) 中等负载、简单驱动
workqueue(绑定 CPU) 10-100 µs 中(内核线程栈 + 任务结构) 中(可能迁移 CPU) 重量级、可睡眠操作
workqueue(非绑定) 100 µs - 1 ms 中 高(CPU 迁移频繁) 后台清理、日志写入
性能陷阱:不要为了“统一风格”而将所有中断处理都使用 workqueue。对于高频中断(如千兆网卡每秒数万次中断),workqueue 的调度延迟和上下文切换开销会严重降低吞吐量。此时应优先使用 softirq 或 tasklet,仅在必要时才降级到 workqueue。

4.7 设计哲学总结:何时拆分、拆到什么程度

顶半部与底半部的拆分不是非黑即白的决策,而是一个根据硬件特性、实时性要求和系统负载进行权衡的设计过程。以下三条经验法则可以帮助你做出判断:

  • 法则一:顶半部越短越好。 理想情况下,顶半部只做“读状态 → 清中断 → 调度底半部”三件事,耗时控制在 10 µs 以内。如果硬件要求必须在中断上下文中完成数据拷贝,则只拷贝最小必要量(如一个 FIFO 深度),剩余拷贝放在底半部。
  • 法则二:底半部选择匹配实时性。 如果设备对延迟敏感(如音频、工业控制),优先使用 tasklet 或 softirq;如果设备处理逻辑复杂且可以容忍毫秒级延迟(如 USB 存储、传感器数据记录),workqueue 是更安全的选择。
  • 法则三:不要过度拆分。 如果顶半部本身只需要几条指令就能完成所有工作(例如仅确认一个 GPIO 边沿中断),则完全没有必要再调度底半部——底半部的调度开销反而会超过直接处理的开销。此时整个中断处理都可以在顶半部完成。
实践检查清单:在编写中断处理程序时,逐条检查以下问题:
1. 顶半部是否调用了任何可能睡眠的函数?(是 → 移到底半部)
2. 顶半部是否持有了 mutex 或信号量?(是 → 改为 spinlock 或移到底半部)
3. 底半部是否真的需要独立调度?能否直接在顶半部完成?(能 → 去掉底半部)
4. 底半部的机制选择是否匹配设备的实时性要求?(不匹配 → 更换机制)