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 软中断的执行时机
软中断什么时候跑?这个问题很关键。它不像硬中断那样“一触发就执行”。软中断的检查点主要有三个:
- 硬中断退出时:
irq_exit()函数里会检查是否有pending的软中断,有就调用invoke_softirq()。 - ksoftirqd内核线程:当软中断数量太多,或者被硬中断反复打断时,内核会唤醒ksoftirqd来处理。
- 显式调用:某些内核代码路径会主动调用
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里跑完,每一步都清晰可见。
这张图把整个流程分成了两条路径:
- 绿色路径:软中断没超时,直接在
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,其实就是基于软中断的封装,咱们后面再聊。