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 看作“串行化的软中断”。
使用套路:
- 定义处理函数:
void my_tasklet_func(unsigned long data) - 声明并初始化:
DECLARE_TASKLET(my_tasklet, my_tasklet_func, data) - 调度执行:
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 三者对比与选型
选哪个?我有个简单的判断流程:
- 能不能睡眠? 能 → 工作队列;不能 → 看下一步
- 执行时间多长? 超过 100μs → 工作队列;短小 → 看下一步
- 需要跨 CPU 并发吗? 需要且能处理重入 → 软中断;不需要 → Tasklet
说白了,90% 的驱动场景用 Tasklet 就够了。工作队列是“保底方案”,软中断是“性能极致方案”——但普通驱动别碰它。
下面这张图是我自己总结的选型逻辑,画成 SVG 方便你记:
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()。