3、上半部与下半部:顶半部(硬中断)与底半部(软中断)的设计哲学
3.1 为什么需要拆成两半?
先问大家一个问题:中断来了,CPU 到底该干多少活?
我刚开始做驱动时,犯过一个典型的错误。当时写一个网卡驱动,在中断处理函数里直接做了数据拷贝、协议解析、还有 skb 的投递。结果呢?网卡一跑起来,系统就卡得不行。鼠标都动不了。
后来我才明白——中断上下文里不能待太久。为什么呢?
- 中断是关的(或者至少是部分关的)
- 其他中断进不来
- 进程调度也停了
- 你占着 CPU 不放,系统就死了
所以 Linux 内核的设计者想了个办法:把中断处理拆成两半。
核心思想:上半部(硬中断)只做最紧急的事,比如清中断标志、读硬件寄存器。下半部(软中断)做剩下的耗时工作,比如数据拷贝、协议处理。
3.2 上半部:硬中断处理
上半部,也叫硬中断处理。它直接响应硬件的中断信号。
我个人的习惯是:上半部里只做三件事。
- 保存硬件状态(读寄存器)
- 清除中断标志(防止重复触发)
- 触发下半部(调度软中断或 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
软中断的执行时机有两个:
- 硬中断处理结束后
- 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 如何选择下半部机制?
我总结了一个简单的选择原则:
- 性能第一,且你熟悉内核 → 用软中断
- 普通驱动,简单可靠 → 用 tasklet
- 需要睡眠,不要求实时 → 用工作队列
说白了,90% 的驱动用 tasklet 就够了。别为了炫技去用软中断,除非你真的需要那点性能提升。
3.8 避坑指南
最后分享几个我踩过的坑:
- 上半部别做耗时操作:我曾经在上半部里做了个循环等待,结果其他设备的中断全被堵死了。
- 下半部注意数据保护:下半部可能被其他中断打断,该用 spinlock 的地方别省。
- 别在 tasklet 里睡眠:tasklet 本质上还是软中断上下文,不能调用睡眠函数。
- 工作队列别用默认的:默认的工作队列是系统共享的,建议创建自己的工作队列,避免互相影响。
嗯,关于上半部和下半部的设计哲学,今天就聊到这里。记住一句话:中断处理就像接电话,接起来说「稍等」就挂,别在电话里把事全办了。