深入理解 Linux 内核中断下半部机制:从硬中断到 Softirq、Tasklet 与 Workqueue 的实战演进
中断是现代操作系统与硬件交互的基石。本文将深入剖析 Linux 内核中断处理的核心架构,从硬件中断触发到中断上半部(Top Half)与下半部(Bottom Half)的分离机制,系统讲解 Softirq、Tasklet、Workqueue 以及 threaded irq 的设计哲学、实现原理与工程实践。
一、中断处理的核心挑战
当网卡收到数据包、磁盘完成 DMA 传输、定时器到期时,硬件会向 CPU 发送中断信号。CPU 随即暂停当前执行流程,跳转到中断处理程序(ISR)。然而,在实际工程场景中,中断处理面临三个核心矛盾:
- 实时性与工作量的矛盾:硬件要求中断被快速响应,但实际数据处理(如网络协议栈处理)可能非常耗时
- 原子性与睡眠的矛盾:中断上下文(atomic context)不可阻塞/睡眠,但某些 I/O 操作必须等待
- 并发与同步的矛盾:高频中断在多核系统下需要保证数据一致性
Linux 内核的解决方案是将中断处理拆分为两阶段:
- 上半部(Top Half / HardIRQ):在中断上下文中执行,做最关键、最精简的工作(如读取硬件寄存器状态、 ACK 中断),不可被打断
- 下半部(Bottom Half):在更宽松的上下文中执行耗时操作,允许多个设备共享处理时间
二、上半部机制:HardIRQ 处理
2.1 中断注册与处理流程
在内核模块中注册中断处理函数的标准接口是 request_irq():
#include <linux/interrupt.h>
/**
* 注册中断处理函数
* @irq: 中断号
* @handler: 中断处理函数指针
* @flags: 中断标志(IRQF_SHARED 等)
* @name: 设备名称(/proc/interrupts 中可见)
* @dev_id: 共享中断时的设备标识符
*/
int request_irq(unsigned int irq,
irq_handler_t handler,
unsigned long flags,
const char *name,
void *dev_id);
// 中断处理函数原型
typedef irqreturn_t (*irq_handler_t)(int irq, void *dev_id);
// 返回值定义
#define IRQ_NONE (0) // 不是本设备的中断
#define IRQ_HANDLED (1) // 中断已处理
#define IRQ_WAKE_THREAD (2) // 唤醒处理线程(用于 threaded irq)
2.2 上半部的设计约束
上半部运行在中断上下文中,这意味着:
- 不能调用可能睡眠的函数:如
kmalloc(GFP_KERNEL)、mutex_lock()、copy_to_user()等 - 不能调度或让出 CPU:
schedule()、cond_resched()等均不可调用 - 执行时间必须极短:否则会导致中断屏蔽时间过长,影响系统实时性
- 受中断嵌套影响:单核时代中断会被全局屏蔽,现代内核支持嵌套中断但仍然有严格限制
三、下半部机制演进史
Linux 内核历经多次下半部机制重构,最终演化出四大机制,各自针对不同场景:
| 机制 | 上下文 | 是否可睡眠 | 是否可并行 | 适用场景 |
|---|---|---|---|---|
| BH (Bottom Half) | 中断上下文 | 否 | 全局串行 | 已废弃(2.4及更早) |
| Tasklet | 软中断上下文 | 否 | 同类型串行,不同类型可并行 | 高性能网络设备、块设备 |
| Softirq | 软中断上下文 | 否 | 全并行(同类型也可在多核并发) | 网络/块设备/定时器等核心子系统 |
| Workqueue | 进程上下文(内核线程) | 是 | 是 | 需要睡眠、阻塞或长时间运行的任务 |
| Threaded IRQ | 进程上下文(内核线程) | 是 | 每个 irq 一个线程 | 将整个中断处理移出硬中断上下文 |
| Timer | 软中断上下文 | 否 | 同类型串行 | 延时执行、超时处理 |
四、Softirq:最底层的下半部机制
4.1 核心设计
Softirq 是 Linux 内核中最基础且不对外暴露的下半部机制。它是一个编译时静态注册的数组,定义在 include/linux/interrupt.h:
enum {
HI_SOFTIRQ = 0, // 高优先级 tasklet(已过时)
TIMER_SOFTIRQ, // 定时器
NET_TX_SOFTIRQ, // 网络发送
NET_RX_SOFTIRQ, // 网络接收
BLOCK_SOFTIRQ, // 块设备
IRQ_POLL_SOFTIRQ, // IRQ poll
TASKLET_SOFTIRQ, // 普通 tasklet
SCHED_SOFTIRQ, // 调度器
HRTIMER_SOFTIRQ, // 高精度定时器
RCU_SOFTIRQ, // RCU 回调
NR_SOFTIRQS // 总数(当前为 10)
};
关键点:
- 静态注册:新的 softirq 不能由模块动态添加,只有内核编译时确定
- 全并行:同一个 softirq handler 可以在多个 CPU 上同时运行
- 严格实时:在处理硬中断返回时、在
ksoftirqd内核线程中、在显式调用do_softirq()的代码路径中都会被触发执行
4.2 触发与执行流程
Softirq 的执行有三个入口:
- 硬中断退出时:
irq_exit()调用invoke_softirq() - ksoftirqd 内核线程:每个 CPU 上的
ksoftirqd/N线程在软中断过多时避免占用过多的硬中断时间 - 显式检查点:
local_bh_enable()、网络子系统等地方显式调用
// 触发一个 softirq(设置对应 CPU 的待处理位图)
static inline void raise_softirq(unsigned int nr)
{
unsigned long flags;
local_irq_save(flags);
softirq_pending |= softirq_mask(nr);
local_irq_restore(flags);
}
// do_softirq 的核心逻辑(简化版)
asmlinkage void do_softirq(void)
{
__u32 pending;
unsigned long flags;
if (in_softirq())
return;
local_irq_save(flags);
pending = local_softirq_pending();
if (pending)
__do_softirq();
local_irq_restore(flags);
}
4.3 实践中的使用
Softirq 主要在关键内核子系统内部使用。以网络收包为例:
- 网卡的 HardIRQ handler 调用
napi_schedule(),其中内部调用__raise_softirq_irqoff(NET_RX_SOFTIRQ) NET_RX_SOFTIRQ注册的 handler 是net_rx_action(),它通过 NAPI 轮询网卡获取数据包- 这种设计允许在单次 softirq 执行中批量处理多个数据包(NAPI 轮询预算为
net.core.netdev_budget,默认 300)
五、Tasklet:基于 Softirq 的封装
5.1 设计理念
Tasklet 是驱动开发者最常用的下半部机制,它基于 Softirq 构建但做出了两个关键限制:
- 同类型 tasklet 不会在多个 CPU 上并行执行:避免了复杂的同步问题
- 同一时刻只有一个相同类型的 tasklet 可以执行:天然防止了竞态条件
5.2 使用方法
#include <linux/interrupt.h>
// 1. 声明 tasklet
void my_tasklet_handler(unsigned long data);
// 方式 A:静态声明
DECLARE_TASKLET(my_tasklet, my_tasklet_handler, (unsigned long)dev);
// 方式 B:动态初始化
struct tasklet_struct *tl = kmalloc(sizeof(*tl), GFP_KERNEL);
tasklet_init(tl, my_tasklet_handler, (unsigned long)dev);
// 2. 在中上半部调度 tasklet(标记为待执行)
// 注意:多次调用 tasklet_schedule() 不会导致 handler 被多次执行
tasklet_schedule(&my_tasklet);
// 3. 使 tasklet 在指定 CPU 上执行(用于 NUMA 优化或缓存亲和性)
tasklet_hi_schedule(&my_tasklet); // 高优先级(HI_SOFTIRQ 已过时,等价于 TASKLET_SOFTIRQ)
// 4. 在模块退出时 kill tasklet(确保不再执行)
tasklet_kill(&my_tasklet);
5.3 执行流程
当调用 tasklet_schedule() 时:
- 检查 tasklet 是否已被调度(
TASKLET_STATE_SCHED标志位) - 如果未调度,将 tasklet 链接到当前 CPU 的
tasklet_vec链表 - 调用
raise_softirq_irqoff(TASKLET_SOFTIRQ)触发软中断 - 在
TASKLET_SOFTIRQ的 handlertasklet_action()中,遍历并执行链表中的所有 tasklet
5.4 代码实现剖析
// tasklet_action 核心逻辑(简化版)
static void tasklet_action(struct softirq_action *a)
{
struct tasklet_struct *list;
local_irq_disable();
list = __get_cpu_var(tasklet_vec).list;
__get_cpu_var(tasklet_vec).list = NULL;
local_irq_enable();
while (list) {
struct tasklet_struct *t = list;
list = list->next;
// 关键:执行前检查并设置 SCHED 状态(防止并行执行)
if (tasklet_trylock(t)) {
if (!atomic_read(&t->count)) { // 确保启用
if (!test_and_clear_bit(TASKLET_STATE_SCHED, &t->state))
BUG();
t->func(t->data);
tasklet_unlock(t);
continue;
}
tasklet_unlock(t);
}
// 如果执行失败,重新加入队列并触发 softirq
local_irq_disable();
t->next = __get_cpu_var(tasklet_vec).list;
__get_cpu_var(tasklet_vec).list = t;
__raise_softirq_irqoff(TASKLET_SOFTIRQ);
local_irq_enable();
}
}
六、Workqueue:唯一可睡眠的下半部机制
6.1 核心设计
Workqueue 将下半部处理移动到内核线程中执行,这意味着:
- 可以使用
GFP_KERNEL标志分配内存 - 可以获取 mutex、读写锁等可能睡眠的锁
- 可以执行阻塞式 I/O 操作(如 copy_to_user、文件操作)
- 可以调用 schedule_timeout() 主动让出 CPU
- 可以被内核调度器管理,支持 CPU 亲和性、优先级调整
6.2 工作队列类型
内核提供三种预定义的工作队列:
| 队列 | 类型 | 特点 |
|---|---|---|
| system_wq | 普通 | 通用的共享工作队列 |
| system_highpri_wq | 高优先级 | 使用高优先级内核线程(nice -20) |
| system_unbound_wq | 非绑定 | 不绑定到特定 CPU,可在任意 CPU 上执行 |
| system_long_wq | 长时间运行 | 允许 worker 长时间占用 CPU |
| system_power_efficient_wq | 节能 | 节能模式下选择空闲 CPU |
6.3 使用方法
#include <linux/workqueue.h>
// ========== A. 使用系统预定义工作队列 ==========
// 定义 work 和处理函数
static void my_work_handler(struct work_struct *work);
static DECLARE_WORK(my_work, my_work_handler);
// 在 module_init 中使用(线程安全地调度)
bool schedule_work(struct work_struct *work); // 在 system_wq 上调度
bool schedule_work_on(int cpu, struct work_struct *work); // 在指定 CPU 上执行
// ========== B. 创建自定义工作队列 ==========
struct workqueue_struct *my_wq;
// 创建(Linux 4.4+ API)
my_wq = alloc_workqueue("my_wq", // 名称
WQ_MEM_RECLAIM | WQ_HIGHPRI, | // 标志
max_active); // 最大并发数
// 调度
struct work_struct my_work;
INIT_WORK(&my_work, my_work_handler);
queue_work(my_wq, &my_work); // 队列到自定义 wq
queue_work_on(cpu, my_wq, &my_work); // 在指定 CPU 队列
queue_delayed_work(my_wq, &my_work, msecs_to_jiffies(100)); // 延时执行
// 清理
flush_workqueue(my_wq); // 等待所有工作完成
destroy_workqueue(my_wq); // 销毁队列
6.4 自定义工作队列的典型场景
/**
* 自定义工作队列标志:
*
* WQ_UNBOUND - 解除 CPU 绑定,使用全局 worker pool
* WQ_HIGHPRI - 使用高优先级 worker 线程
* WQ_MEM_RECLAIM- 标记为内存回收相关(防止递归回收死锁)
* WQ_FREEZABLE - 系统挂起时冻结
* WQ_CPU_INTENSIVE - CPU 密集型工作(不计入并发限制)
*/
// 高性能 I/O 驱动常用配置
struct workqueue_struct *io_wq = alloc_workqueue(
"my_driver_io",
WQ_HIGHPRI | WQ_MEM_RECLAIM,
8 // 最大并发数
);
// 需要延时执行的硬件操作
INIT_DELAYED_WORK(&hw_poll_work, hw_poll_handler);
queue_delayed_work(io_wq, &hw_poll_work, usecs_to_jiffies(10));
七、Threaded IRQ:现代推荐方式
7.1 设计理念
Threaded IRQ 是 Linux 3.0+ 引入的现代中断处理方式,它将整个中断处理逻辑(包括上半部和下半部)都放在线程上下文中执行。
这种方式的驱动力来自 PREEMPT_RT(实时内核)项目,旨在减少内核中不可抢占的区段(即中断上下文和自旋锁持有区),使内核具有更强的实时性。
7.2 使用方法
#include <linux/interrupt.h>
// 线程化的中断处理函数
static irqreturn_t my_irq_thread(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
// 这里是在内核线程上下文中!可以睡眠、可以 mutex_lock
mutex_lock(&dev->lock);
// 执行耗时操作
process_data(dev);
copy_to_user(...); // 可以!
mutex_unlock(&dev->lock);
return IRQ_HANDLED;
}
// 注册 threaded IRQ(只需提供线程处理函数)
int ret = request_threaded_irq(irq,
NULL, // 硬中断处理(NULL 表示使用默认 handler)
my_irq_thread, // 线程处理函数
IRQF_ONESHOT, // 标志
"my_driver",
dev);
// 默认的硬中断 handler 仅做 ACK 中断并返回 IRQ_WAKE_THREAD 来唤醒线程
7.3 IRQF_ONESHOT 标志
使用 request_threaded_irq() 且不提供硬中断 handler 时,必须使用 IRQF_ONESHOT 标志。
- 有线中断:中断线在线程处理函数退出前一直被屏蔽,防止中断丢失
- 无共享中断:绝对不要使用,因为中断线会一直屏蔽直到线程处理完成
- 避免中断丢失:线程处理过程中来的中断被屏蔽,线程退出后会重新触发一次
7.4 实际驱动中的 Threaded IRQ
static irqreturn_t gpio_key_irq_handler(int irq, void *dev_id)
{
// 可选:极简的硬件 ACK
struct gpio_key *key = dev_id;
// 仅做最简单的 ACK,返回 IRQ_WAKE_THREAD 唤醒线程处理
return IRQ_WAKE_THREAD;
}
static irqreturn_t gpio_key_irq_thread(int irq, void *dev_id)
{
struct gpio_key *key = dev_id;
int state;
// 在上下文中可以睡眠等待按键去抖(debounce)
msleep(20); // 等待硬件稳定
state = gpiod_get_value(key->gpiod);
input_report_key(key->input, key->code, !state);
input_sync(key->input);
return IRQ_HANDLED;
}
八、性能调优与最佳实践
8.1 /proc/interrupts 分析
查看系统中断分布的最佳工具:
$ cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
0: 22 0 0 0 IR-IO-APIC 2-edge timer
1: 2 0 0 0 IR-IO-APIC 1-edge i8042
14: 75 0 0 0 IR-IO-APIC 14-edge ata_piix
16: 12098 8320 4120 8980 IR-PCI-MSI 524288-edge eth0
NMI: 0 0 0 0 Non-maskable interrupts
LOC: 2549234 2548231 2428900 2368791 Local timer interrupts
关键观察:
- 如果所有中断都集中在 CPU0,说明没有启用中断负载均衡
- MSI-X 中断可以分配到多个 CPU,是现代高性能网卡的标准
- 通过
/proc/irq/{IRQ}/smp_affinity手动配置中断亲和性
8.2 IRQ Affinity 与中断负载均衡
# 将 IRQ 16 绑定到 CPU 2(掩码方式)
echo 4 > /proc/irq/16/smp_affinity # 4 = 100b = CPU2
# 将 IRQ 16 绑定到 CPU 0+1
echo 3 > /proc/irq/16/smp_affinity # 3 = 011b = CPU0+CPU1
# 高精度场景:使用 irqbalance 服务的相反做法——手动绑定
# 停止 irqbalance,手动分配中断到非关键 CPU
systemctl stop irqbalance
# 将网卡中断绑定到 CPU 2+3(与应用程序分离)
echo 12 > /proc/irq/16/smp_affinity # 12 = 1100b = CPU2+3
echo 12 > /proc/irq/17/smp_affinity # 多队列网卡的每个队列分别绑定
8.3 Softirq 调优
# 查看 softirq 统计
cat /proc/softirqs
CPU0 CPU1 CPU2 CPU3
HI: 1234 234 456 789
TIMER: 234567 123456 234567 123456
NET_TX: 1234 567 890 123
NET_RX: 345678 123456 234567 345678 # NET_RX 极高可能需要调优
BLOCK: 4567 1234 5678 1234
TASKLET: 1234 567 890 123
# NET_RX 过高时的调优方法:
# 1. 增加 netdev_budget(每次软中断轮询的最大数据包数)
sysctl -w net.core.netdev_budget=600
# 2. 启用 RPS/RFS(Receive Packet Steering / Receive Flow Steering)
# 将数据包分发到多个 CPU 处理
echo "f" > /sys/class/net/eth0/queues/rx-0/rps_cpus # 分配到 CPU 0-3
# 3. 启用 NAPI 合并中断 coalescing
ethtool -C eth0 rx-usecs 100 tx-usecs 100
8.4 ksoftirqd CPU 占用过高的排查
当 ksoftirqd 进程的 CPU 占用持续偏高时,说明软中断过于硬中断处理不够及时,触发了 ksoftirqd 兜底处理。排查方向:
- NIC (网卡) 中断过载:
sar -n DEV 1查看 PPS(每秒数据包量),10G 网卡万兆线速可达 14.88Mpps - BLOCK 中断过载:高 IOPS 场景下,SSD/NVMe 每次完成队列(CQ)都会产生中断
- 优化手段:
- 启用中断合并(Interrupt Coalescing):
ethtool -C eth0 rx-usecs 100 - 使用 NAPI 批量收包替代每次硬中断处理一个包
- 使用 MSI-X + RSS 多队列网卡,每个队列绑定不同 CPU
- 启用中断合并(Interrupt Coalescing):
8.5 RCU 与软中断的交互
RCU (Read-Copy-Update) 的回调执行也在 RCU_SOFTIRQ 中。在大规模读多写少的场景下,RCU_SOFTIRQ 可能成为瓶颈:
// 监控 RCU 软中断
$ watch -n1 'cat /proc/softirqs | grep RCU'
// 调整 RCU 回调数量阈值
sysctl -w kernel.rcu_normal_batch=128 # 单次回调最大数量
sysctl -w kernel.rcu_expedited=1 # 启用快速 RCU(牺牲功耗换低延迟)
九、驱动开发场景实战
9.1 场景一:高速数据采集卡
高速数据采集卡(10Gbps+)每秒产生数百万中断,关键优化策略:
// 策略 1:NAPI-like 轮询 + single buffer 模式
static irqreturn_t daq_irq_handler(int irq, void *dev_id)
{
struct daq_device *dev = dev_id;
// 仅 ACK 中断并禁用硬件中断
writel(0, dev->reg_base + IRQ_ENABLE_REG);
// 调度 tasklet 做批量 DMA 描述符处理
tasklet_schedule(&dev->dma_tasklet);
return IRQ_HANDLED;
}
static void daq_dma_handler(unsigned long data)
{
struct daq_device *dev = (struct daq_device *)data;
struct dma_desc *desc;
int processed = 0;
// 批量处理 DMA 描述符,直到达到预算上限或全部完成
while ((desc = get_completed_desc(dev)) != NULL && processed < 256) {
process_dma_desc(dev, desc);
processed++;
}
// 重新启用硬件中断以接收下一批样本
if (processed < 256) {
writel(IRQ_ENABLE, dev->reg_base + IRQ_ENABLE_REG);
} else {
// 数据量巨大,继续调度 tasklet(类似 NAPI 轮询)
tasklet_schedule(&dev->dma_tasklet);
}
}
9.2 场景二:GPIO 按键驱动
按键驱动需要去抖动处理(不能在中断上下文中 sleep),最适合使用 threaded IRQ:
static irqreturn_btn gpio_key_thread_handler(int irq, void *dev_id)
{
struct gpio_key *key = dev_id;
int val;
// 在 threaded IRQ 中可以安全地睡眠(去抖动)
msleep(20);
val = gpiod_get_value_cansleep(key->gpiod);
input_report_key(key->input, key->code, !val);
input_sync(key->input);
// 唤醒等待按键的进程
if (key->wait_queue)
wake_up_interruptible(&key->wait_queue);
return IRQ_HANDLED;
}
9.3 场景三:复杂 I/O 操作(SPI 传感器驱动)
SPI 传感器需要执行多步操作(读寄存器->处理->写结果),每一步都可能阻塞:
static void spi_sensor_work_handler(struct work_struct *work)
{
struct spi_sensor *sensor = container_of(work, struct spi_sensor, work);
u8 raw_data[32];
int ret;
// 步骤 1:执行 SPI 读取(可能睡眠等待传输完成)
ret = spi_read(sensor->spi, raw_data, sizeof(raw_data));
if (ret)
goto err;
// 步骤 2:数据处理(耗时计算)
process_raw_data(sensor, raw_data);
// 步骤 3:写结果到硬件寄存器
mutex_lock(&sensor->hw_lock);
ret = spi_write(sensor->spi, sensor->reg_cmds, sensor->cmd_size);
mutex_unlock(&sensor->hw_lock);
if (ret)
goto err;
// 步骤 4:通知用户空间
sysfs_notify(&sensor->dev->kobj, NULL, "data_ready");
err:
if (ret)
dev_err(sensor->dev, "sensor operation failed: %d", ret);
}
十、总结与选型指南
Linux 内核下半部机制的选择需要综合考虑三个维度:性能要求、是否需要睡眠 和 同步复杂度。以下是系统化的选型决策树:
1. 是否需要睡眠或阻塞?
├── 是 → WQUEUE(或 THREADED IRQ)
│ └── 需要高优先级? → 自定义 WQ_HIGHPRI 队列
│
└── 否 → 是否需要获取互斥量并发执行?
├── 否 → TASKLET
│ └── 是否要求同类型串行? → TASKLET ✅
│
└── 是 → 是否每次只关心最终状态(不需要严格串行)?
└── 是 → SOFTIRQ(仅限内核子系统)
└── 注意:不能动态注册,需要修改内核源码
简化规则:
┌──────────────────────────────────────────────────┐
│ 设备驱动开发者只需要记住这三个: │
│ │
│ 1. TASKLET:大多数硬件中断默认选择,简单安全 │
│ 2. WQUEUE:需要睡眠/阻塞/文件操作时选择 │
│ 3. THREADED IRQ:新项目推荐,将整个逻辑放在线程 │
│ │
│ 内核子系统开发者额外使用 SOFTIRQ 获取极致性能 │
│ THREAD IRQ 是 PREEMPT_RT 实时系统的标配 │
└──────────────────────────────────────────────────┘
理解这些机制的演进脉络——从全局串行化的 BH、到并行的 Softirq、再到简化使用的 Tasklet、最后到允许睡眠的 Workqueue 和 Threaded IRQ——能够帮助我们在驱动开发中做出最优的架构决策,在中断处理的可维护性、实时性和吞吐量之间找到最佳平衡点。

发表评论 取消回复