3、上半部与下半部:顶半部(硬中断)与底半部(软中断)的设计哲学

3.1 为什么需要拆成两半?

先问大家一个问题:中断来了,CPU 到底该干多少活?

我刚开始做驱动时,犯过一个典型的错误。当时写一个网卡驱动,在中断处理函数里直接做了数据拷贝、协议解析、还有 skb 的投递。结果呢?网卡一跑起来,系统就卡得不行。鼠标都动不了。

后来我才明白——中断上下文里不能待太久。为什么呢?

  • 中断是关的(或者至少是部分关的)
  • 其他中断进不来
  • 进程调度也停了
  • 你占着 CPU 不放,系统就死了

所以 Linux 内核的设计者想了个办法:把中断处理拆成两半

核心思想:上半部(硬中断)只做最紧急的事,比如清中断标志、读硬件寄存器。下半部(软中断)做剩下的耗时工作,比如数据拷贝、协议处理。

3.2 上半部:硬中断处理

上半部,也叫硬中断处理。它直接响应硬件的中断信号。

我个人的习惯是:上半部里只做三件事

  1. 保存硬件状态(读寄存器)
  2. 清除中断标志(防止重复触发)
  3. 触发下半部(调度软中断或 tasklet)

举个例子,一个按键中断的驱动:

static irqreturn_t key_irq_handler(int irq, void *dev_id)
{
    // 1. 读硬件状态
    int key_val = readl(KEY_STATUS_REG);

    // 2. 清中断
    writel(KEY_IRQ_CLEAR, KEY_IRQ_REG);

    // 3. 调度下半部
    tasklet_schedule(&key_tasklet);

    return IRQ_HANDLED;
}

你看,代码很短。为什么?因为上半部不能睡,不能等,不能做复杂操作。说白了,它就是「接个活,然后赶紧走人」。

注意:上半部里不能调用可能导致睡眠的函数,比如 kmalloc(GFP_KERNEL)、mutex_lock()、copy_from_user()。我曾经在项目里用过一次 GFP_KERNEL,结果系统直接报「Scheduling while atomic」的 BUG。嗯,血的教训。

3.3 下半部:软中断机制

下半部才是真正干活的地方。Linux 提供了三种下半部机制:

机制 特点 使用场景
软中断 (Softirq) 性能最高,但使用复杂 网络、块设备等高性能场景
Tasklet 基于软中断,使用简单 大多数驱动场景
工作队列 (Workqueue) 运行在进程上下文,可睡眠 需要睡眠的耗时操作

你想想看,为什么要有这么多选择?因为不同的场景,对延迟和资源的要求不一样。

3.4 软中断:最底层的下半部

软中断是内核预定义的,你不能随便注册自己的软中断。内核里常用的软中断有:

  • NET_TX_SOFTIRQ:网络发送
  • NET_RX_SOFTIRQ:网络接收
  • TASKLET_SOFTIRQ:tasklet 的底层实现
  • HI_SOFTIRQ:高优先级 tasklet

软中断的执行时机有两个:

  1. 硬中断处理结束后
  2. ksoftirqd 内核线程中

我记得有一次调试网络性能,发现软中断在 ksoftirqd 里跑得太慢,导致网络吞吐上不去。后来调整了 net.core.netdev_budget 参数,才把问题解决。

小技巧:可以用 /proc/softirqs 查看各个软中断的执行次数。如果某个软中断计数增长异常,说明你的驱动可能有问题。

3.5 Tasklet:驱动开发者的首选

说实话,大部分驱动场景用 tasklet 就够了。它基于软中断实现,但用起来简单得多。

// 定义 tasklet 处理函数
void key_tasklet_handler(unsigned long data)
{
    // 这里可以做耗时操作
    printk("Key pressed, value: %ld\n", data);
}

// 初始化 tasklet
DECLARE_TASKLET(key_tasklet, key_tasklet_handler, 0);

// 在上半部中调度
tasklet_schedule(&key_tasklet);

Tasklet 有个特点:同一个 tasklet 不会在多个 CPU 上同时执行。这简化了并发处理,但也限制了性能。

我个人的建议是:如果不需要高性能,优先用 tasklet。代码简单,不容易出错。

3.6 工作队列:可以睡觉的下半部

有些操作必须睡眠,比如:

  • 从用户空间拷贝数据(copy_from_user)
  • 获取互斥锁(mutex_lock)
  • 分配大块内存(kmalloc 大块)

这时候就得用工作队列。它运行在进程上下文,可以睡眠。

// 定义工作队列处理函数
void my_work_handler(struct work_struct *work)
{
    // 这里可以睡眠
    msleep(100);
    printk("Work done\n");
}

// 初始化工作
DECLARE_WORK(my_work, my_work_handler);

// 在上半部中调度
schedule_work(&my_work);

注意:工作队列的延迟比 tasklet 大。因为它要等内核线程调度。如果你的驱动对实时性要求高,别用工作队列。

3.7 如何选择下半部机制?

我总结了一个简单的选择原则:

  1. 性能第一,且你熟悉内核 → 用软中断
  2. 普通驱动,简单可靠 → 用 tasklet
  3. 需要睡眠,不要求实时 → 用工作队列

说白了,90% 的驱动用 tasklet 就够了。别为了炫技去用软中断,除非你真的需要那点性能提升。

3.8 避坑指南

最后分享几个我踩过的坑:

  • 上半部别做耗时操作:我曾经在上半部里做了个循环等待,结果其他设备的中断全被堵死了。
  • 下半部注意数据保护:下半部可能被其他中断打断,该用 spinlock 的地方别省。
  • 别在 tasklet 里睡眠:tasklet 本质上还是软中断上下文,不能调用睡眠函数。
  • 工作队列别用默认的:默认的工作队列是系统共享的,建议创建自己的工作队列,避免互相影响。

嗯,关于上半部和下半部的设计哲学,今天就聊到这里。记住一句话:中断处理就像接电话,接起来说「稍等」就挂,别在电话里把事全办了