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 拆分流程:从硬件中断到底半部调度
下图展示了典型的中断处理拆分流程,从硬件中断触发到底半部最终执行完毕:
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. 底半部的机制选择是否匹配设备的实时性要求?(不匹配 → 更换机制)
1. 顶半部是否调用了任何可能睡眠的函数?(是 → 移到底半部)
2. 顶半部是否持有了 mutex 或信号量?(是 → 改为 spinlock 或移到底半部)
3. 底半部是否真的需要独立调度?能否直接在顶半部完成?(能 → 去掉底半部)
4. 底半部的机制选择是否匹配设备的实时性要求?(不匹配 → 更换机制)