4. 中断下半部机制:软中断、Tasklet、工作队列

中断处理,说白了就是“上半部赶紧收,下半部慢慢算”。

上半部(硬中断)里,我们只做最紧急的事——比如清中断标志、拷贝硬件寄存器。剩下的重活,全扔给下半部。Linux 给了我们三种下半部机制:软中断、Tasklet、工作队列。选哪个?我踩过不少坑,今天一次性讲清楚。

4.1 软中断(SoftIRQ)

软中断是下半部里“跑得最快”的那个。它直接运行在硬中断上下文中,可以嵌套,但不能睡眠。说白了,它还是中断上下文,不能调用 schedule() 或 wait_event()。

核心特点:

  • 静态分配,编译时确定(目前内核只有 10 个软中断向量)
  • 同一类型的软中断可以在多个 CPU 上同时运行
  • 必须可重入,要自己处理并发
  • 不能睡眠、不能阻塞

我一般不建议普通驱动直接使用软中断。为什么?因为它的 API 太底层了。你得自己注册软中断向量、自己处理 raise_softirq()、自己加锁。我在一个网卡驱动项目里试过,调试并发问题差点崩溃。

软中断的典型用户是网络子系统(NET_TX_SOFTIRQ、NET_RX_SOFTIRQ)和块设备层(BLOCK_SOFTIRQ)。普通驱动,用 Tasklet 就够了。

4.2 Tasklet

Tasklet 是软中断的“封装版”。它基于软中断实现(TASKLET_SOFTIRQ 和 HI_SOFTIRQ),但用起来简单得多。

Tasklet 有个重要特性:同一个 Tasklet 不会在多个 CPU 上同时执行。这大大降低了锁的复杂度。我习惯把 Tasklet 看作“串行化的软中断”。

使用套路:

  1. 定义处理函数:void my_tasklet_func(unsigned long data)
  2. 声明并初始化:DECLARE_TASKLET(my_tasklet, my_tasklet_func, data)
  3. 调度执行:tasklet_schedule(&my_tasklet)

举个例子,一个按键驱动,硬中断里只读 GPIO 电平,然后调度 Tasklet 做消抖和上报:

// 上半部
static irqreturn_t button_isr(int irq, void *dev_id)
{
    struct button_dev *bdev = dev_id;
    bdev->state = gpio_get_value(bdev->gpio);
    tasklet_schedule(&bdev->tasklet);
    return IRQ_HANDLED;
}

// 下半部
static void button_tasklet(unsigned long data)
{
    struct button_dev *bdev = (struct button_dev *)data;
    // 消抖、上报 input 事件
    if (bdev->state == 0)
        input_report_key(bdev->input, KEY_ENTER, 1);
    else
        input_report_key(bdev->input, KEY_ENTER, 0);
    input_sync(bdev->input);
}

Tasklet 不能睡眠,这点和软中断一样。如果你需要在下半部里等硬件、等 DMA 完成、或者调用 copy_to_user(),那就得用工作队列了。

4.3 工作队列(Workqueue)

工作队列跑在进程上下文里。它可以睡眠、可以调度、可以拿 mmap_sem。说白了,它就是一个内核线程,只不过被封装成了“延迟执行的工作”。

我最早接触工作队列是在一个 USB 摄像头驱动里。硬中断里收到一帧数据,但解码和投递需要分配内存、需要调用 V4L2 的 vb2_buffer_done()——这些都不能在中断上下文做。工作队列正好接这个活。

工作队列 vs Tasklet 的关键区别:

特性 Tasklet 工作队列
上下文 软中断(原子) 进程(可睡眠)
并发 同类型串行 可并行(多 worker)
调度开销 极低(微秒级) 较高(毫秒级)
适合场景 短小、快速、无阻塞 耗时、可能阻塞、需要进程 API

现代内核推荐使用 kworker 线程池(system_wq、system_highpri_wq 等),而不是自己创建内核线程。用 schedule_work() 或 queue_work() 把工作扔进去就行。

// 定义一个工作
struct work_struct my_work;
INIT_WORK(&my_work, my_work_handler);

// 调度
schedule_work(&my_work);

// 处理函数(可以睡眠)
void my_work_handler(struct work_struct *work)
{
    // 可以调用 msleep(), copy_to_user(), kmalloc(GFP_KERNEL) 等
    msleep(10);
    copy_to_user(ubuf, kbuf, len);
}

注意:工作队列的延迟不可控。如果硬中断频率很高,工作队列可能堆积。我曾经在一个 SPI 驱动里用工作队列处理每帧 1KB 的数据,结果 10MHz 时钟下工作队列根本跟不上,最后换成了 Tasklet + 环形缓冲区才解决。

4.4 三者对比与选型

选哪个?我有个简单的判断流程:

  1. 能不能睡眠? 能 → 工作队列;不能 → 看下一步
  2. 执行时间多长? 超过 100μs → 工作队列;短小 → 看下一步
  3. 需要跨 CPU 并发吗? 需要且能处理重入 → 软中断;不需要 → Tasklet

说白了,90% 的驱动场景用 Tasklet 就够了。工作队列是“保底方案”,软中断是“性能极致方案”——但普通驱动别碰它。

下面这张图是我自己总结的选型逻辑,画成 SVG 方便你记:

中断下半部选型决策树 中断下半部 需要睡眠/阻塞? 不需要睡眠 → 工作队列 执行时间 < 100μs? → 工作队列 需要跨 CPU 并发? 不需要并发 → 软中断(慎用) → Tasklet(推荐) 提示:90% 的驱动场景用 Tasklet 即可,工作队列是保底方案,软中断留给内核子系统

4.5 避坑指南

这些年我踩过的坑,列几个典型的:

  • Tasklet 里调用 msleep() —— 内核直接 oops。Tasklet 是原子上下文,不能睡眠。
  • 工作队列里不加锁访问共享数据 —— 工作队列可能并行执行(多个 worker 线程),该加 spinlock 还是得加。
  • 软中断里用 kmalloc(GFP_KERNEL) —— 会触发调度,死锁。软中断里只能用 GFP_ATOMIC。
  • 忘记 flush_work() 或 tasklet_kill() —— 模块卸载时,下半部可能还在跑,访问已释放的内存,直接 panic。

我曾经在一个音频驱动里,卸载模块时没等 Tasklet 执行完就释放了 DMA 缓冲区。结果 Tasklet 还在写那块内存,系统直接挂死。从那以后,我每次卸载前都会加 tasklet_kill(&my_tasklet),工作队列则用 cancel_work_sync() 或 flush_workqueue()。