4、软中断与tasklet:软中断机制、tasklet的使用与实现
说到中断处理,很多初学者会有一个误区:觉得中断服务函数写得越短越好,执行越快越好。这话对了一半。中断上下文确实不能干重活,但有些工作又不能不做。那怎么办?Linux 内核给出了一个经典的解决方案——把工作分成两半。
上半部(Top Half)处理紧急的硬件操作,比如清中断标志、读寄存器。下半部(Bottom Half)处理那些不那么紧急的后续工作,比如数据拷贝、协议解析。下半部的实现方式有好几种,今天咱们重点聊软中断和 tasklet。
4.1 软中断机制
软中断,说白了就是内核在某个安全时刻,主动去执行一段预先注册好的函数。它不像硬中断那样由硬件触发,而是由软件自己“安排”的。
我刚开始接触软中断时,总觉得它跟普通函数调用没啥区别。后来踩了坑才明白:软中断运行在中断上下文中,不能睡眠,不能调度,不能访问用户空间。这个限制非常严格。
4.1.1 软中断的类型
Linux 内核预定义了几种软中断类型,每种都有固定的优先级。你想想看,网络数据收发、定时器处理、任务调度,这些核心功能都依赖软中断。
| 软中断编号 | 名称 | 用途 |
|---|---|---|
| 0 | HI_SOFTIRQ | 高优先级 tasklet |
| 1 | TIMER_SOFTIRQ | 定时器 |
| 2 | NET_TX_SOFTIRQ | 网络数据发送 |
| 3 | NET_RX_SOFTIRQ | 网络数据接收 |
| 4 | BLOCK_SOFTIRQ | 块设备 |
| 5 | IRQ_POLL_SOFTIRQ | 中断轮询 |
| 6 | TASKLET_SOFTIRQ | 普通 tasklet |
| 7 | SCHED_SOFTIRQ | 调度器 |
| 8 | HRTIMER_SOFTIRQ | 高精度定时器 |
| 9 | RCU_SOFTIRQ | RCU 锁 |
嗯,这里要注意:软中断的编号越小,优先级越高。HI_SOFTIRQ 排在最前面,因为它要处理高优先级的 tasklet。
4.1.2 软中断的执行时机
软中断什么时候执行?主要有三个时机:
- 硬中断处理结束后,在
irq_exit()中检查是否有待处理的软中断 - 内核线程
ksoftirqd被唤醒时 - 显式调用
local_bh_enable()时
我个人习惯把软中断理解成一个“延迟执行队列”。硬中断来了,把需要做的事登记一下,然后赶紧走人。等系统空闲了,再回头处理这些登记的任务。
核心要点:软中断在同一时刻,同一个 CPU 上只能执行一个实例。但不同 CPU 可以同时执行同一个软中断。这意味着你的软中断处理函数必须是可重入的。
4.2 tasklet 的使用
说实话,直接使用软中断的场景并不多。因为软中断的接口比较底层,而且要求开发者自己处理并发问题。大多数驱动开发者更习惯用 tasklet。
tasklet 是建立在软中断之上的一个封装。它简化了接口,而且保证了同一个 tasklet 不会同时在多个 CPU 上执行。这就省去了很多锁的烦恼。
4.2.1 定义和初始化 tasklet
有两种方式:静态和动态。
// 静态方式
DECLARE_TASKLET(my_tasklet, my_tasklet_handler, data);
// 动态方式
struct tasklet_struct my_tasklet;
tasklet_init(&my_tasklet, my_tasklet_handler, data);
我在项目中遇到过一个问题:用 DECLARE_TASKLET 定义的 tasklet 默认是使能的,而用 tasklet_init 初始化的 tasklet 需要手动调度。这个细节容易忽略。
4.2.2 调度 tasklet
tasklet_schedule(&my_tasklet); // 普通调度
tasklet_hi_schedule(&my_tasklet); // 高优先级调度
tasklet 被调度后,会在合适的时机执行。如果同一个 tasklet 被多次调度,它只会执行一次。这个特性很实用——你想想看,如果网络数据包一个接一个地来,你不需要每个包都触发一次完整的处理流程,合并处理效率更高。
4.2.3 禁用和启用 tasklet
tasklet_disable(&my_tasklet); // 禁用,如果正在执行则等待完成
tasklet_enable(&my_tasklet); // 启用
注意:tasklet_disable() 是同步的。如果 tasklet 正在另一个 CPU 上执行,它会等待执行完毕才返回。这个行为在某些场景下会导致死锁,务必小心。
4.3 tasklet 的实现原理
tasklet 底层其实就两个软中断:TASKLET_SOFTIRQ 和 HI_SOFTIRQ。每个 CPU 维护了两个 tasklet 链表:一个普通优先级,一个高优先级。
当你调用 tasklet_schedule() 时,内核会做这几件事:
- 检查 tasklet 是否已经在链表中(防止重复添加)
- 将 tasklet 添加到当前 CPU 的 tasklet 链表尾部
- 触发
TASKLET_SOFTIRQ软中断
软中断处理函数 tasklet_action() 会遍历链表,逐个执行 tasklet。执行前会检查 tasklet 的状态,确保它没有被禁用,也没有在其他 CPU 上执行。
static void tasklet_action(struct softirq_action *a)
{
struct tasklet_struct *list;
local_irq_disable();
list = __this_cpu_read(tasklet_vec.head);
__this_cpu_write(tasklet_vec.head, NULL);
local_irq_enable();
while (list) {
struct tasklet_struct *t = list;
list = list->next;
if (tasklet_trylock(t)) {
if (!atomic_read(&t->count)) {
t->func(t->data);
tasklet_unlock(t);
continue;
}
tasklet_unlock(t);
}
// 如果 tasklet 被禁用或正在执行,重新放回链表
local_irq_disable();
t->next = __this_cpu_read(tasklet_vec.head);
__this_cpu_write(tasklet_vec.head, t);
__raise_softirq_irqoff(TASKLET_SOFTIRQ);
local_irq_enable();
}
}
这段代码我看了很多遍。每次看都觉得内核开发者考虑得很周全——用 tasklet_trylock 避免死锁,用 atomic_read 检查禁用计数,处理不了的就放回链表重新调度。
经验之谈:我曾经在一个网卡驱动里用 tasklet 处理接收数据。一开始觉得挺简单,后来发现如果数据量太大,tasklet 执行时间过长,会导致系统响应变慢。解决办法是把 tasklet 里的重活拆出来,放到工作队列里执行。tasklet 只做必要的协议解析和缓冲区管理。
4.4 软中断与 tasklet 的对比
| 特性 | 软中断 | tasklet |
|---|---|---|
| 并发性 | 同一类型可在多 CPU 并行 | 同一实例只能在一个 CPU 执行 |
| 可重入性 | 必须可重入 | 不需要(内核保证) |
| 注册方式 | 编译时静态注册 | 运行时动态注册 |
| 使用场景 | 网络、块设备等核心子系统 | 大多数驱动开发者 |
| 优先级 | 固定编号决定 | 普通/高优先级两种 |
说白了,如果你不是在内核核心子系统里干活,用 tasklet 就够了。软中断虽然性能更好,但开发难度也更高。我见过不少新手一上来就想用软中断,结果把自己绕进去了。
4.5 避坑指南
最后分享几个我踩过的坑:
- 不要在 tasklet 里调用可能导致睡眠的函数——比如
kmalloc(GFP_KERNEL)、mutex_lock()。要用GFP_ATOMIC和自旋锁。 - 注意 tasklet 的执行顺序——同一个 tasklet 多次调度只执行一次,但不同 tasklet 之间的执行顺序是不确定的。
- 卸载模块时要确保 tasklet 已经停止——用
tasklet_kill()等待 tasklet 执行完毕,防止野指针。
我曾经在卸载驱动时忘了调用 tasklet_kill(),结果模块卸载后,tasklet 还在执行,访问了已经释放的内存。系统直接 panic。从那以后,我每次写驱动都会在退出路径上仔细检查所有异步任务的清理工作。
嗯,软中断和 tasklet 就聊到这儿。下一节咱们讲工作队列,那又是另一种完全不同的下半部机制了。