深入理解 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 的执行有三个入口:

  1. 硬中断退出时:irq_exit() 调用 invoke_softirq()
  2. ksoftirqd 内核线程:每个 CPU 上的 ksoftirqd/N 线程在软中断过多时避免占用过多的硬中断时间
  3. 显式检查点: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() 时:

  1. 检查 tasklet 是否已被调度(TASKLET_STATE_SCHED 标志位)
  2. 如果未调度,将 tasklet 链接到当前 CPU 的 tasklet_vec 链表
  3. 调用 raise_softirq_irqoff(TASKLET_SOFTIRQ) 触发软中断
  4. 在 TASKLET_SOFTIRQ 的 handler tasklet_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 兜底处理。排查方向:

  1. NIC (网卡) 中断过载:sar -n DEV 1 查看 PPS(每秒数据包量),10G 网卡万兆线速可达 14.88Mpps
  2. BLOCK 中断过载:高 IOPS 场景下,SSD/NVMe 每次完成队列(CQ)都会产生中断
  3. 优化手段:
    • 启用中断合并(Interrupt Coalescing):ethtool -C eth0 rx-usecs 100
    • 使用 NAPI 批量收包替代每次硬中断处理一个包
    • 使用 MSI-X + RSS 多队列网卡,每个队列绑定不同 CPU

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——能够帮助我们在驱动开发中做出最优的架构决策,在中断处理的可维护性、实时性和吞吐量之间找到最佳平衡点。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.369444s