3. 中断上半部与下半部:为什么需要下半部、上半部与下半部的分工
3.1 中断处理的核心矛盾:快与全
中断服务程序(ISR)直接运行在中断上下文中,默认关闭本地 CPU 中断(或至少关闭当前中断线)。这意味着:
- 响应延迟敏感:ISR 执行时间越长,CPU 响应其他中断的延迟就越大。
- 上下文受限:中断上下文不能睡眠、不能调度、不能使用可能引起阻塞的锁。
- 硬件约束:某些硬件设备要求必须在极短时间内完成 ACK 或数据读取,否则会丢失数据。
然而,很多设备驱动需要完成的工作量远超过“读一个寄存器”或“清一个标志位”。例如网络驱动收到一个数据包后,需要将数据从 DMA 缓冲区拷贝到 sk_buff、解析协议头、统计计数、唤醒等待队列——这些操作如果全部放在 ISR 中,会严重拖慢系统。
核心矛盾:中断处理必须 快(减少关中断时间),但驱动逻辑往往需要 全(完成复杂处理)。上半部与下半部的分工正是为了解决这一矛盾。
3.2 上半部与下半部的分工原则
Linux 将中断处理拆分为两个阶段:
| 特性 | 上半部(Top Half) | 下半部(Bottom Half) |
|---|---|---|
| 执行上下文 | 中断上下文(不可睡眠) | 软中断 / tasklet / 工作队列(部分可睡眠) |
| 中断状态 | 当前中断线关闭(或全部中断关闭) | 中断全部开启 |
| 执行时机 | 硬件中断触发后立即执行 | 上半部返回后,由内核调度执行 |
| 典型工作 | 读硬件状态、清中断标志、拷贝少量数据 | 协议处理、数据拷贝、唤醒进程、复杂计算 |
| 执行时间要求 | 极短(微秒级) | 相对宽松(毫秒级甚至更长) |
| 可睡眠 | 否 | 工作队列可睡眠;tasklet/软中断不可 |
经验法则:如果某个操作可以在不关闭中断的情况下延迟执行,就把它放到下半部。上半部只做“必须立即做”的事。
3.3 上半部与下半部的协作流程
下图展示了从硬件中断触发到上下半部协作完成的完整流程:
3.4 为什么不能全部在上半部完成?
假设一个网络网卡驱动在 ISR 中完成所有工作:
/* 错误示例:所有工作都在上半部完成 */
irqreturn_t bad_net_isr(int irq, void *dev_id) {
struct net_device *dev = dev_id;
// 1. 读硬件状态寄存器
u32 status = readl(dev->regs + STATUS);
// 2. 从 DMA 缓冲区拷贝数据到 sk_buff
struct sk_buff *skb = netdev_alloc_skb(dev, length);
memcpy(skb_put(skb, length), dma_buf, length);
// 3. 解析以太网协议头
eth_type_trans(skb, dev);
// 4. 统计计数
dev->stats.rx_packets++;
// 5. 递交到协议栈
netif_rx(skb);
// 6. 清中断标志
writel(INT_CLEAR, dev->regs + INT_STATUS);
return IRQ_HANDLED;
}
这段代码存在严重问题:
- 关中断时间过长:memcpy 拷贝大包(如 1500 字节)可能耗时数十微秒,期间 CPU 无法响应其他中断。
- 实时性破坏:如果系统中有更高优先级的中断(如定时器、传感器),它们会被延迟响应。
- 嵌套中断风险:即使开启嵌套中断,ISR 中执行复杂逻辑也容易导致栈溢出或锁竞争。
性能警示:实测表明,在 1GHz ARM Cortex-A72 平台上,一次 1500 字节的 memcpy 约耗时 3-5μs。如果网络吞吐量达到 1Gbps,每秒约 81,000 个包,仅拷贝操作就占用 CPU 超过 400ms/s——这还不包括协议解析和统计开销。全部放在上半部将导致系统几乎无法响应其他中断。
3.5 正确的分工:上半部做最少,下半部做最多
将上述网络驱动重构为上下半部分工:
/* 上半部:只做必须立即做的事 */
irqreturn_t good_net_isr(int irq, void *dev_id) {
struct net_device *dev = dev_id;
// 1. 读状态,确认中断来源
u32 status = readl(dev->regs + STATUS);
if (!(status & RX_DONE))
return IRQ_NONE;
// 2. 保存必要信息(如 DMA 地址、长度)
dev->priv->rx_dma_addr = readl(dev->regs + DMA_ADDR);
dev->priv->rx_length = readl(dev->regs + DMA_LEN);
// 3. 调度下半部(tasklet)
tasklet_schedule(&dev->priv->rx_tasklet);
// 4. 清中断标志
writel(INT_CLEAR, dev->regs + INT_STATUS);
return IRQ_HANDLED;
}
/* 下半部:完成剩余所有工作 */
void net_rx_tasklet(unsigned long data) {
struct net_device *dev = (struct net_device *)data;
// 1. 从 DMA 缓冲区拷贝数据
struct sk_buff *skb = netdev_alloc_skb(dev, dev->priv->rx_length);
memcpy(skb_put(skb, dev->priv->rx_length),
dev->priv->rx_dma_addr, dev->priv->rx_length);
// 2. 解析协议头
eth_type_trans(skb, dev);
// 3. 统计计数
dev->stats.rx_packets++;
// 4. 递交到协议栈
netif_rx(skb);
}
重构后的优势:
- 上半部仅执行 3-4 次寄存器读写 + 一次 tasklet 调度,耗时 < 1μs。
- 下半部在中断开启后执行,不阻塞其他中断响应。
- 下半部可以被更高优先级的中断抢占,系统实时性得到保障。
3.6 下半部的三种实现机制对比
Linux 内核提供了三种下半部实现机制,适用于不同场景:
| 特性 | 软中断(Softirq) | Tasklet | 工作队列(Workqueue) |
|---|---|---|---|
| 执行上下文 | 中断上下文 | 中断上下文 | 进程上下文 |
| 可睡眠 | 否 | 否 | 是 |
| 并发性 | 同一类型可多 CPU 并行 | 同一类型串行执行 | 由 worker pool 管理 |
| 使用复杂度 | 高(需静态声明) | 低(动态创建) | 中(需创建工作项) |
| 典型用途 | 网络 RX/TX、定时器 | 驱动下半部通用机制 | 需要睡眠的复杂操作 |
| 调度开销 | 最低 | 低 | 较高(涉及进程切换) |
选择建议:驱动开发中优先使用 tasklet,它简单安全且性能足够;如果下半部需要访问用户空间、使用可睡眠锁或执行文件系统操作,则必须使用工作队列。
3.7 上半部与下半部的数据传递
上下半部之间通过共享数据结构传递信息。常见模式:
- 设备私有结构体:在
struct net_device->priv或自定义结构体中保存上半部采集的数据。 - 原子变量:使用
atomic_t传递简单计数或状态。 - 环形缓冲区:上半部写入,下半部读取,适用于批量数据场景。
- 指针传递:通过 tasklet 或工作项的数据参数传递结构体指针。
/* 典型数据传递模式 */
struct my_device {
void __iomem *regs;
struct tasklet_struct rx_tasklet;
/* 上半部填充,下半部消费 */
u32 rx_dma_addr;
u32 rx_length;
atomic_t rx_count;
};
/* 上半部 */
irqreturn_t my_isr(int irq, void *dev_id) {
struct my_device *mydev = dev_id;
mydev->rx_dma_addr = readl(mydev->regs + DMA_ADDR);
mydev->rx_length = readl(mydev->regs + DMA_LEN);
atomic_inc(&mydev->rx_count);
tasklet_schedule(&mydev->rx_tasklet);
return IRQ_HANDLED;
}
/* 下半部 */
void my_rx_tasklet(unsigned long data) {
struct my_device *mydev = (struct my_device *)data;
/* 使用 mydev->rx_dma_addr 和 mydev->rx_length 处理数据 */
/* 处理完成后清零或更新 */
atomic_dec(&mydev->rx_count);
}
关键要点:上半部与下半部共享的数据必须使用适当的同步机制(如原子操作、spinlock、memory barrier),防止竞争条件。tasklet 在同一 CPU 上不会与自身并发,但可能与其他 CPU 上的 tasklet 或上半部并发。