从硬中断到 softirq:Linux 软中断机制的全景解剖
1. 概览摘要
在 Linux 里,外设一发中断,CPU 上来的第一反应是"硬中断处理程序"(hardirq),但真正大头的工作,往往被拆包转交给一个更"温和"的机制------软中断(softirq)。软中断是 Linux 内核里用来承接"与中断紧密相关、又不适合在硬中断里耗时太久"的那部分工作的一套框架,它是网络协议栈、块设备 IO、定时器等子系统的核心基础设施。
软中断介于"硬中断上下文"和"普通进程上下文"之间:它不在用户态执行,但也不要求像硬中断那样极短的时延。通过软中断、tasklet、工作队列等多级延后机制,Linux 既保证了中断响应延迟,又能把大量逻辑拆到更合适的上下文中执行,从而在高并发场景下保持良好的吞吐。
下面我们会从基本概念、关键数据结构、执行流程、设计取舍、实际示例、调试方法到整体架构,系统性地把 Linux 软中断机制"剖开"来看,尤其会结合网络收包这一典型场景,帮你真正理解内核为什么要这么设计,而不是停留在"知道有 softirq 这个词"的层面。
2. 核心概念详解
2.1 几个关键术语
先把几个常被混在一起的概念捋清:
| 名称 | 所在上下文 | 是否可抢占 | 典型用途 |
|---|---|---|---|
| 硬中断(hardirq) | 中断上下文(最高优先级) | 基本不可被普通代码抢占 | 及时响应外设事件 |
| 软中断(softirq) | 内核上下文(软中断域) | 不可被同 CPU 软中断抢占 | 网络、块 IO 底半部等 |
| tasklet | 构建在 softirq 之上 | 同一 tasklet 不并行 | 驱动自定义延后函数 |
| 工作队列(workqueue) | 进程上下文(内核线程) | 可睡眠 | 需要 sleep 的延后工作 |
可以用一个生活类比:
- 硬中断 = 急诊室电话:必须立刻接,先做最关键的动作(比如"止血"),绝不能在电话上聊 30 分钟。
- 软中断 = 急诊分诊台:把病人分类、安排后续处理,但还是不能慢吞吞。
- 工作队列 = 各个专科门诊:可以慢慢排队、可以检查、等 IO。
2.2 "上下文"到底是什么
在内核里,"上下文"可以理解为"当前这段代码运行时能做什么事"的能力边界:
- 中断上下文:
- 没有当前进程的概念
- 不能睡眠、不能阻塞
- 调用栈有限、时间必须短
- 软中断上下文:
- 仍然不能睡眠
- 但已经不在最顶级的中断里,可批量处理工作
- 进程上下文:
- 有当前 task_struct
- 可以调度和睡眠
软中断就是在"响应时延"和"可做工作量"之间的一个折中点。
2.3 软中断 vs 早期 bottom half / tasklet
历史上 Linux 早期有"bottom half"(BH)概念,后来演化成 softirq + tasklet:
| 机制 | 特点 | 问题/局限 |
|---|---|---|
| 早期 BH | 全局固定少数"底半部"入口 | 可扩展性差,易产生争用 |
| softirq | 静态注册,数量有限,按位图调度 | 编程模型偏底层,不能睡眠 |
| tasklet | 基于 softirq 的高层抽象,每个实例一个函数 | 不能 SMP 并行(同一 tasklet) |
| workqueue | 使用内核线程执行,可以睡眠 | 延迟相对更大,切换开销更高 |
3. 实现机制深度剖析
下面按"数据结构 → 调度流程 → 典型场景"来拆。
3.1 核心数据结构(概念化简版)
注意:下面是"概念化的简化版本",非完整内核源码,避免版权问题同时突出关键字段。
c
// 每种 softirq 的描述
struct softirq_action {
void (*action)(struct softirq_action *); // 处理函数
void *data; // 子系统私有数据(可选)
};
// 每 CPU 状态(简化版)
struct softirq_cpu_state {
unsigned int pending_mask; // 哪些 softirq 挂起(按位)
int in_softirq; // 嵌套计数,防止重入
};
常见软中断类型(内核里是枚举):
HI_SOFTIRQ:高优先级软中断TIMER_SOFTIRQ:定时器NET_TX_SOFTIRQ:网络发送NET_RX_SOFTIRQ:网络接收BLOCK_SOFTIRQ:块设备 IOTASKLET_SOFTIRQ/HI_TASKLET_SOFTIRQ:tasklet
3.2 软中断注册与触发
3.2.1 注册 softirq
内核子系统在初始化时注册自己的处理函数(只在启动阶段做一次):
c
// 简化版注册接口(示意)
void open_softirq(int nr, void (*action)(struct softirq_action *))
{
softirq_vec[nr].action = action;
}
比如网络子系统会把 NET_RX_SOFTIRQ 绑定到自己的收包函数上。
3.2.2 标记"有软中断待处理"
当硬中断里发现"后续要处理"的工作时,只是做两件事:
- 把少量关键数据搬出来(比如把包放进队列)
- 在当前 CPU 的
pending_mask上置位
c
// 在本 CPU 上触发某个 softirq(简化)
void raise_softirq(unsigned int nr)
{
struct softirq_cpu_state *sc = this_cpu_ptr(&softirq_state);
sc->pending_mask |= (1U << nr);
}
工作真正执行的时机,稍后再看。
3.3 软中断执行流程
整个链路"自上而下"画出来大概是这样:
发现 pending softirq
无 pending
外设产生中断
CPU 进入硬中断 handler
处理极少量关键工作
raise_softirq() 挂起软中断
退出硬中断
中断退出路径
或内核线程调度
__do_softirq() 执行
正常返回/调度
3.3.1 __do_softirq 核心逻辑(伪代码)
软中断执行的核心函数可以抽象成:
c
void __do_softirq(void)
{
struct softirq_cpu_state *sc = this_cpu_ptr(&softirq_state);
if (sc->in_softirq)
return; // 防止重入(简化)
sc->in_softirq++;
unsigned int pending = sc->pending_mask;
sc->pending_mask = 0; // 读取并清空
while (pending) {
int nr = __ffs(pending); // 找到最低位的 softirq
pending &= ~(1U << nr); // 清掉这一位
struct softirq_action *sa = &softirq_vec[nr];
sa->action(sa); // 调用处理函数
}
sc->in_softirq--;
}
注意几个点:
- 每 CPU 独立执行 softirq,不需要跨 CPU 的锁来保护
pending_mask。 - 一次执行时会把当前 pending 的全部 softirq 拉出来处理,避免频繁进出软中断上下文。
- 软中断之间的优先级通过 bit 顺序可以粗略体现(高优先放低 bit)。
3.3.2 在哪里"进"软中断?
主要两个入口:
- 中断返回路径 :硬中断处理刚结束、准备回到内核/用户态时,检查是否有 pending softirq,有就直接调用
__do_softirq()。 - ksoftirqd 线程 :每个 CPU 上有一个
ksoftirqd/N内核线程,在需要时被唤醒,专门拉起软中断处理,避免硬中断退出一次批太多 softirq 导致长时间关抢占。
简化版条件:
c
// 中断返回路径中的伪代码
if (local_softirq_pending())
do_softirq();
// 如果软中断执行太久,则唤醒 ksoftirqd
if (time_spent_too_long)
wake_up_process(ksoftirqd);
3.4 典型场景:网络收包
把网络收包这条链路画成流程图,会更直观:
应用进程 网络协议栈 NET_RX softirq CPU 硬中断 网卡 应用进程 网络协议栈 NET_RX softirq CPU 硬中断 网卡 触发中断(收到新包) 硬中断 handler 读取少量寄存器 DMA 缓冲区中摘取 skb 入队 raise_softirq(NET_RX) 退出硬中断,进入 softirq poll 收包队列,处理 L2/L3/L4 把数据放入 socket 缓冲 recv() 从内核拷贝到用户态
其中"真正复杂的事情"------路由查找、TCP 状态机、协议栈处理,基本都在 softirq 上下文完成。
3.5 tasklet:基于 softirq 的高层抽象
tasklet 是建立在 TASKLET_SOFTIRQ / HI_TASKLET_SOFTIRQ 之上的轻量框架:
c
struct my_tasklet {
struct tasklet_struct t;
int value;
};
void my_tasklet_func(unsigned long data)
{
struct my_tasklet *mt = (struct my_tasklet *)data;
// 这里运行在 softirq 上下文,不能睡眠
}
DEFINE_TASKLET(my_tasklet_inst, my_tasklet_func, (unsigned long)&my_tasklet_inst);
调度时只是把该 tasklet 加入 per-CPU 队列,并触发对应软中断:
c
void some_irq_handler(void)
{
// ......处理中断头......
tasklet_schedule(&my_tasklet_inst); // 延后到 softirq 上下文执行
}
特点:
- 同一个 tasklet 在同一时刻只会在一个 CPU 上运行(不并行)。
- 但不同的 tasklet 可以在不同 CPU 上并行。
3.6 NAPI 与 softirq
在高吞吐网络场景下,NAPI(New API)通过"中断+轮询"结合降低中断风暴:
- 初始时,网卡通过硬中断通知有包。
- 硬中断中关闭进一步的 RX 中断,然后
napi_schedule()。 napi_schedule()触发NET_RX_SOFTIRQ。- 在 softirq 中轮询收包队列直到达到预算或无包。
- 若无包,重新打开中断。
简化代码示意:
c
irqreturn_t nic_interrupt(int irq, void *dev_id)
{
struct my_nic *nic = dev_id;
disable_nic_rx_irq(nic); // 关 RX 中断
napi_schedule(&nic->napi); // 触发 NET_RX softirq
return IRQ_HANDLED;
}
int my_napi_poll(struct napi_struct *napi, int budget)
{
int work = 0;
while (work < budget && nic_has_rx_packet()) {
struct sk_buff *skb = nic_recv_skb();
netif_receive_skb(skb); // 交给协议栈
work++;
}
if (work < budget) {
napi_complete(napi); // 本轮结束,打开中断
enable_nic_rx_irq(nic);
}
return work;
}
NAPI 的轮询实际上就是在 NET_RX_SOFTIRQ 中被调度执行的。
3.7 复杂内容扩展:并发与可重入性
softirq 是每 CPU 执行的,因此:
- 同一种 softirq 类型可以在不同 CPU 上并行执行(如多个 CPU 同时处理 NET_RX)。
- 同一 CPU 上,软中断是不可抢占的:执行
__do_softirq()过程中,这个 CPU 不会再进另一次 softirq。
这给并发设计带来几个要求:
- 多 CPU 共享数据结构需要使用锁(自旋锁为主)或 per-CPU 拆分。
- tasklet 额外保证"同一实例不在多个 CPU 并行",因此驱动可以少考虑重入。
4. 设计思想与技术权衡
4.1 为什么要 softirq?
硬中断上下文有两个硬约束:
- 中断关得越久,系统响应性越差;
- 硬中断里不能睡眠、不能调度,能做的事情类型受限。
如果所有逻辑都挤在硬中断里:
- 网络高流量时,中断风暴会把 CPU 100% 占满,用户进程几乎得不到执行机会。
- 某个驱动写得不好,硬中断里搞复杂算法/打印日志,就可能导致系统"抖一抖甚至死一死"。
softirq 的设计目标:
- 把"必须立刻做"的和"稍微可以晚一点做"的部分拆开。
- 让"可以晚一点"的那部分在仍然较高优先级、但可批处理的环境下运行。
- 支撑 SMP:每 CPU 独立 pending 位图和队列,可以并行处理。
4.2 与工作队列的分层关系
可以简单理解为:
- softirq:时间敏感、与中断强相关、不需要睡眠的工作。
- workqueue:可以容忍更多延迟、可能需要睡眠的工作。
常见用法是:
- 在硬中断中只做最小工作 +
raise_softirq()或tasklet_schedule()。 - 在 softirq/tasklet 中完成协议或驱动大部分"计算型"逻辑。
- 如果需要访问用户空间、等待锁、访问慢速设备,再转交给工作队列。
4.3 一些典型权衡
| 方案 | 优点 | 缺点 |
|---|---|---|
| 全部硬中断完成 | 延迟最低,路径最短 | 对系统其他任务极不友好,难以维护 |
| 全部工作队列 | 易编程,可睡眠 | 延迟大,调度成本高,抖动大 |
| 硬中断 + softirq | 响应及时 + 吞吐较好 + 可并行 | 编程模型较复杂,调试难度提升 |
Linux 内核选择了"硬中断 + softirq + workqueue"多级结构,本质上是"把系统当成一个需要兼顾实时性和吞吐的服务系统",用不同层次的队列来平衡延迟和处理能力。
5. 实践示例(最小可运行示例)
这里给出一个简化的内核模块示例:
- 在加载时注册一个定时器,每隔一段时间在 softirq 上下文里打印一次信息。
- 同时用工作队列在进程上下文里再做一次"慢动作"处理。
注意:代码是教学示例,省略了大量健壮性处理,也与实际内核有适当差异避免版权问题。
5.1 示例代码
c
#include <linux/module.h>
#include <linux/timer.h>
#include <linux/workqueue.h>
#include <linux/interrupt.h>
static struct timer_list my_timer;
static struct work_struct my_work;
static void my_work_func(struct work_struct *work)
{
// 进程上下文:可以睡眠(此处不睡眠,只打印)
pr_info("mysoftirq: workqueue context on CPU %d\n", smp_processor_id());
}
static void my_timer_softirq(struct timer_list *t)
{
// 软中断上下文:不能睡眠
pr_info("mysoftirq: timer softirq on CPU %d\n", smp_processor_id());
// 把后续工作交给工作队列
schedule_work(&my_work);
// 重新启动定时器
mod_timer(&my_timer, jiffies + HZ);
}
static int __init mysoftirq_init(void)
{
INIT_WORK(&my_work, my_work_func);
timer_setup(&my_timer, my_timer_softirq, 0);
mod_timer(&my_timer, jiffies + HZ);
pr_info("mysoftirq: module loaded\n");
return 0;
}
static void __exit mysoftirq_exit(void)
{
del_timer_sync(&my_timer);
cancel_work_sync(&my_work);
pr_info("mysoftirq: module unloaded\n");
}
module_init(mysoftirq_init);
module_exit(mysoftirq_exit);
MODULE_LICENSE("GPL");
这个模块展示了:
- 定时器回调是在 softirq 上下文执行;
- 回调中再调度工作队列,在进程上下文执行;
- 你可以用
cat /proc/softirqs看到TIMER软中断计数在增加。
5.2 编译运行命令(示意)
假设放在目录 mysoftirq/:
- 内核树外构建 Makefile 示例:
makefile
obj-m := mysoftirq.o
- 构建与加载(在内核源码树外目录):
bash
make -C /lib/modules/$(uname -r)/build M=$PWD modules
sudo insmod mysoftirq.ko
sudo rmmod mysoftirq
dmesg | tail
5.3 预期输出
dmesg 中可以看到类似日志(内容会因 CPU / 配置略有不同):
- 模块加载信息
- 每秒一次定时器 softirq 打印
- 与之对应的 workqueue 打印
这有助于你感性认识"softirq 和工作队列的上下文差异"。
6. 调试与工具
调软中断相关问题时,常用观测点和工具如下:
| 工具/文件 | 用途 | 示例 |
|---|---|---|
/proc/interrupts |
查看硬中断计数 | cat /proc/interrupts |
/proc/softirqs |
查看软中断计数 | cat /proc/softirqs |
top/htop |
查看 ksoftirqd/N 占用 |
htop 中按 CPU 查看 ksoftirqd 线程 |
perf |
采样热点函数 | perf top -g |
ftrace/trace-cmd |
跟踪 softirq/irq 事件 | trace-cmd record -e irq -e softirq |
示例:只看某 CPU 的 ksoftirqd 行为:
bash
perf record -a -g -e cycles -- sleep 5
perf report # 查看是否 __do_softirq 成为热点
也可以通过 echo 配置 ftrace 跟踪软中断:
bash
echo function > /sys/kernel/debug/tracing/current_tracer
echo __do_softirq > /sys/kernel/debug/tracing/set_ftrace_filter
cat /sys/kernel/debug/tracing/trace_pipe
在高网络负载场景下,ksoftirqd CPU 100%、/proc/softirqs 中 NET_RX 数疯狂增加,是典型的"软中断打满 CPU"症状。
7. 架构总览
从更高的角度看,Linux 的"中断相关子系统"可以概括成这样一个模块图:
硬件设备
硬中断处理程序
软中断子系统
tasklet 层
NAPI 轮询
块设备软中断
工作队列
网络协议栈
文件系统/页缓存
进程上下文
如果只聚焦某一个 CPU,可以这样理解"执行栈":
用户态进程
系统调用/内核线程
工作队列处理函数
软中断处理(__do_softirq)
硬中断 entry
从"上层往下"看,是普通进程触发 IO,进而产生中断;
从"下层往上"看,是硬件中断拉起硬中断、softirq、工作队列,最终把数据送到进程。
8. 全文总结
- Linux 把与中断相关的工作分三层:硬中断(响应)、软中断(批处理)、工作队列/进程上下文(复杂、可睡眠)。
- 软中断以"枚举 + 每 CPU pending 位图 + 函数向量表"的形式实现,使用
__do_softirq()在合适时机批量处理挂起的 softirq。 - 网络、块设备、定时器等子系统大量依赖 softirq 来完成性能敏感部分的逻辑,是高吞吐场景的关键机制。
- tasklet 建立在 softirq 之上,给驱动提供了"不并行执行同一实例"的延后执行抽象;NAPI 则把中断和轮询结合,减少中断风暴。
- 软中断是"每 CPU 并行、单 CPU 内不可抢占"的,这对数据结构的并发设计有重要影响:要么 per-CPU,要么自旋锁。
- 调试软中断问题时,
/proc/softirqs、ksoftirqd行为、perf/ftrace 是最重要的观察入口。 - 从架构角度看,软中断是连接"实时性(硬件中断)"和"复杂功能(协议栈、文件系统)"的桥梁,是 Linux 内核高性能 IO 的核心基础设施之一。
- 真正理解软中断,需要结合具体子系统(尤其是网络和块 IO)去阅读对应代码和 trace 运行路径,单看 API 名字远远不够。