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_SOFTIRQHI_SOFTIRQ。每个 CPU 维护了两个 tasklet 链表:一个普通优先级,一个高优先级。

当你调用 tasklet_schedule() 时,内核会做这几件事:

  1. 检查 tasklet 是否已经在链表中(防止重复添加)
  2. 将 tasklet 添加到当前 CPU 的 tasklet 链表尾部
  3. 触发 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 就聊到这儿。下一节咱们讲工作队列,那又是另一种完全不同的下半部机制了。