4. 底半部机制之软中断:软中断的注册与触发、软中断的执行时机、ksoftirqd内核线程

中断处理的上半部(Top Half)要求快进快出,这个咱们上一章聊过了。但很多时候,驱动在中断里要做的事远不止“清标志位、读数据”这么简单。网络收发包、块设备读写,这些活儿要是全塞在上半部,系统就别干别的了。

所以内核搞出了“底半部”(Bottom Half)机制。说白了,就是把中断里不急的那部分活儿,挪到后面慢慢干。底半部有好几种:软中断、tasklet、工作队列。这一章咱们先啃最底层、最核心的——软中断。

核心认知:软中断是Linux内核中优先级最高的“可延迟函数”。它运行在中断上下文中,但可以被硬中断抢占。理解软中断,是理解整个中断子系统下半部的钥匙。

4.1 软中断的注册与触发

软中断在系统里是静态分配的。什么意思?你不能像写驱动那样随便 request_irq() 来注册一个软中断。软中断的编号(索引)是在内核编译时就定死的。目前内核定义了10种软中断,常用的就那么几个:

软中断编号 宏定义 用途 优先级
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锁 最低

看到没?网络收发、块设备、定时器这些核心子系统,都直接拿软中断干活。咱们驱动开发一般不会直接注册软中断——那是内核核心模块的事。但理解它的机制,对排查问题极有帮助。

4.1.1 注册一个软中断处理函数

如果你真的需要自己搞一个软中断(比如你在写一个高性能网络驱动),API长这样:

#include <linux/interrupt.h>

/* 注册软中断 */
void open_softirq(int nr, void (*action)(struct softirq_action *));

/* 触发软中断 */
void raise_softirq(unsigned int nr);
void raise_softirq_irqoff(unsigned int nr);  /* 关中断版本 */

举个例子,网络收包中断里怎么触发软中断的:

/* 驱动中断上半部 */
static irqreturn_t my_net_rx_irq(int irq, void *dev_id)
{
    struct net_device *dev = dev_id;

    /* 1. 从硬件读数据到skb */
    /* 2. 把skb挂到CPU的softnet_data队列 */

    /* 3. 触发NET_RX_SOFTIRQ软中断 */
    __raise_softirq_irqoff(NET_RX_SOFTIRQ);

    return IRQ_HANDLED;
}

这里有个细节:__raise_softirq_irqoff() 要求调用时已经关了本地中断。为什么?因为设置软中断标志位 irq_stat[cpu].__softirq_pending 是个原子操作,但配合后续的 wakeup_softirqd() 需要关中断保护。我在项目中见过有人用错了版本,导致软中断丢失,网络吞吐直接掉了一半。

我的习惯:在中断上半部里,永远用 __raise_softirq_irqoff()。因为上半部执行时本地中断本来就是关的,没必要多调用一次 local_irq_save()。

4.2 软中断的执行时机

软中断什么时候跑?这个问题很关键。它不像硬中断那样“一触发就执行”。软中断的检查点主要有三个:

  1. 硬中断退出时:irq_exit() 函数里会检查是否有pending的软中断,有就调用 invoke_softirq()。
  2. ksoftirqd内核线程:当软中断数量太多,或者被硬中断反复打断时,内核会唤醒ksoftirqd来处理。
  3. 显式调用:某些内核代码路径会主动调用 local_bh_enable() 或 do_softirq()。

咱们重点看第一个路径,这是最频繁的执行场景:

/* kernel/softirq.c - 简化版 */
void irq_exit(void)
{
    /* ... 其他清理 ... */

    if (!in_interrupt() && local_softirq_pending())
        invoke_softirq();
}

static inline void invoke_softirq(void)
{
    if (!force_irqthreads) {
        /* 直接在当前上下文执行软中断 */
        do_softirq();
    } else {
        /* 强制线程化,唤醒ksoftirqd */
        wakeup_softirqd();
    }
}

这里有个 force_irqthreads 参数。如果你在内核启动参数里加了 threadirqs,所有软中断都会被扔到ksoftirqd线程里执行。调试的时候我偶尔会用这个参数,方便看软中断的调用栈。

我曾经踩过的坑:软中断执行时,本地CPU的硬中断是开启的!这意味着你的软中断处理函数可以被新的硬中断打断。如果硬中断又触发了同一个软中断,就会形成嵌套。内核通过 __softirq_pending 位图来合并请求,不会重复执行,但处理时间会被拉长。

4.3 ksoftirqd内核线程

软中断虽然优先级高,但不能让它一直霸占CPU。内核设计了一个保护机制:如果软中断连续执行超过一定时间或次数,就把剩下的活儿交给ksoftirqd线程。

每个CPU都有一个ksoftirqd线程,名字叫 ksoftirqd/n(n是CPU编号)。你可以用 ps aux | grep ksoftirqd 看到它们:

root      3  0.0  0.0      0     0 ?        S    10:15   0:00 [ksoftirqd/0]
root     13  0.0  0.0      0     0 ?        S    10:15   0:00 [ksoftirqd/1]
root     18  0.0  0.0      0     0 ?        S    10:15   0:00 [ksoftirqd/2]
root     23  0.0  0.0      0     0 ?        S    10:15   0:00 [ksoftirqd/3]

ksoftirqd的优先级是 SCHED_FIFO 实时线程,优先级为1(最低的实时优先级)。这意味着它比普通进程优先级高,但比硬中断和真正的实时任务低。

它的核心逻辑在 run_ksoftirqd() 函数里:

static int run_ksoftirqd(void *__bind_cpu)
{
    set_current_state(TASK_INTERRUPTIBLE);

    while (!kthread_should_stop()) {
        /* 没有pending软中断就继续睡 */
        if (!local_softirq_pending())
            schedule();

        /* 有活儿干了,开始处理 */
        __set_current_state(TASK_RUNNING);

        while (local_softirq_pending()) {
            /* 关中断,取pending位图 */
            /* 执行软中断 */
            do_softirq();

            /* 如果被硬中断打断太多次,让出CPU */
            cond_resched();
        }

        set_current_state(TASK_INTERRUPTIBLE);
    }
    __set_current_state(TASK_RUNNING);
    return 0;
}

你想想看,为什么需要ksoftirqd?直接在上半部退出时把软中断全干完不行吗?

原因很简单:防止软中断饿死用户态进程。如果网络流量特别大,软中断一个接一个,用户态的shell、GUI就得不到CPU时间片了。ksoftirqd作为一个可被调度的线程,至少会被调度器分时处理。

关键指标:你可以通过 /proc/softirqs 查看每个CPU上各种软中断的执行次数。如果某个软中断数值异常高,或者ksoftirqd的CPU占用率居高不下,说明你的驱动或网络栈可能有问题。

$ cat /proc/softirqs
                    CPU0       CPU1       CPU2       CPU3
          HI:          5          2          0          1
       TIMER:     152345     148921     151234     149876
      NET_TX:      12340      11987      12456      12001
      NET_RX:      89234      87654      90123      86543
       BLOCK:       1234       1100       1198       1087
    IRQ_POLL:          0          0          0          0
     TASKLET:        321        298        310        287
       SCHED:      23456      22890      23123      22567
     HRTIMER:         12         10         11          9
         RCU:      56789      55432      56123      54890

我在调优一个千兆网卡驱动时,就靠这个文件定位了问题。当时发现 NET_RX 在CPU0上特别高,其他CPU几乎没活儿。这就是典型的“中断亲和性”没设置好,导致软中断都挤在一个核上。

4.4 软中断的执行流程全景

说了这么多,咱们画张图把整个流程串起来。从硬件中断触发,到软中断最终在ksoftirqd里跑完,每一步都清晰可见。

软中断执行流程全景图 硬件中断触发 中断上半部 (Top Half) raise_softirq() irq_exit() 检查pending 超时/超次? do_softirq() 直接执行 wakeup_softirqd() ksoftirqd 线程处理 否 是 中断上下文 硬中断开启 进程上下文 可被调度

这张图把整个流程分成了两条路径:

  • 绿色路径:软中断没超时,直接在 irq_exit() 里执行 do_softirq()。这是最快路径,延迟最低。
  • 红色路径:软中断执行太久或次数太多,内核把剩下的活儿交给ksoftirqd。这是保护路径,防止软中断饿死其他任务。

调试技巧:如果你怀疑软中断有问题,可以用 echo t > /proc/sysrq-trigger 查看所有任务状态。如果看到大量 ksoftirqd 处于 R(运行)状态,说明软中断负载很高。

4.5 避坑指南与经验总结

最后,把我这些年跟软中断打交道踩过的坑,列成清单给你参考:

问题场景 现象 根因 解决方案
软中断里睡眠 内核崩溃或死锁 软中断在中断上下文,不能调用 schedule() 用工作队列或线程化中断
软中断执行太久 ksoftirqd CPU 100%,用户态卡顿 软中断里做了大量计算或循环 把耗时操作拆到工作队列
软中断丢失 网络丢包,设备无响应 误用了 raise_softirq() 而非关中断版本 中断上半部用 __raise_softirq_irqoff()
软中断不均衡 某个CPU软中断特别高 中断亲和性没设置,或RPS未开启 设置 /proc/irq/N/smp_affinity

我曾经在一个视频采集卡驱动里,把图像缩放算法放在了软中断里。结果一跑4K视频,ksoftirqd直接把CPU干到100%,系统跟死了一样。后来把缩放挪到工作队列,问题解决。嗯,软中断不是万能的,它只适合“快进快出”的活儿。

总结一下这一章的核心:软中断是Linux中断底半部的最底层机制,它在上半部退出时被检查执行,超时后由ksoftirqd接管。理解它的注册、触发和执行时机,是写出高性能驱动的必修课。下一层——tasklet,其实就是基于软中断的封装,咱们后面再聊。