3、中断处理:顶半部与底半部(tasklet/workqueue/threaded IRQ)的设计取舍
中断处理,说白了就是CPU被硬件喊去干活。但干活也得讲究方式方法——你不能在中断里待太久,否则系统就卡死了。我刚开始做驱动时,就吃过这个亏。
Linux把中断分成了两半:顶半部和底半部。顶半部就是那个紧急响应,底半部才是真正干活的地方。今天咱们就聊聊这三条路:tasklet、workqueue、threaded IRQ,看看它们到底该怎么选。
核心原则:顶半部只做最紧急的事(清中断标志、拷贝少量数据),其余全部扔给底半部。谁违反了这条,谁的系统就会卡顿。
3.1 顶半部:能短则短
顶半部是在中断上下文里跑的。这意味着什么?不能睡眠,不能调度,不能拿信号量。说白了,你就像在急诊室——只能做止血包扎,不能做开刀手术。
顶半部里一般只做三件事:
- 清除中断标志位(防止重复触发)
- 拷贝硬件寄存器中的关键数据
- 唤醒或标记底半部
我在项目中遇到过有人把耗时的数据处理放在顶半部,结果系统响应时间直接飙到毫秒级。嗯,后来他改成了底半部,问题就解决了。
3.2 底半部的三条路
底半部才是真正干活的。Linux给了我们三种选择:tasklet、workqueue、threaded IRQ。我画了张图,帮你理清它们的关系。
3.3 Tasklet:轻量级选手
Tasklet跑在软中断上下文里。它不能睡眠,不能阻塞,但开销极小。我一般用它来处理那些“快进快出”的任务——比如更新一个统计计数器,或者把数据塞进一个环形缓冲区。
Tasklet有个特点:同类型的tasklet是串行执行的。你想想看,如果你的设备同时触发两次中断,第二个tasklet必须等第一个跑完。这既是优点也是缺点——省了锁,但可能延迟。
我的习惯:如果底半部处理时间在几十微秒以内,而且不需要睡眠,我首选tasklet。比如GPIO按键的去抖处理,用tasklet就很合适。
3.4 Workqueue:能干重活
Workqueue跑在进程上下文里。这意味着它可以睡眠,可以拿信号量,可以做任何你想做的事。说白了,它就是一个内核线程,只不过被封装成了work的形式。
我建议在以下场景用workqueue:
- 需要访问I2C/SPI设备(这些总线操作可能睡眠)
- 需要分配大量内存
- 需要等待硬件完成某个操作
我曾经在车载摄像头驱动里用workqueue处理图像数据。每次中断来了,顶半部只把DMA完成的标志位清掉,然后调度workqueue去处理一帧图像。整个过程流畅得很。
注意:Workqueue默认使用系统的工作队列(system_wq),如果所有驱动都往里塞任务,可能会互相影响。我建议为你的设备创建专用的workqueue,避免“邻居”拖慢你。
3.5 Threaded IRQ:中断线程化
Threaded IRQ是内核后来引入的机制。它把整个中断处理(包括顶半部和底半部)都放在一个内核线程里。你注册中断时,直接提供一个线程函数,内核会为这个中断创建一个内核线程。
它的好处很明显:
- 可以睡眠,可以加锁
- 优先级可以调整(用rtprio)
- 天然支持嵌套和并发
但代价是——线程切换的开销比tasklet大。如果你的中断频率很高(比如每秒几万次),用threaded IRQ可能会让CPU忙于线程切换。
| 特性 | Tasklet | Workqueue | Threaded IRQ |
|---|---|---|---|
| 上下文 | 软中断 | 进程 | 内核线程 |
| 可睡眠 | ❌ | ✅ | ✅ |
| 开销 | 最小 | 中等 | 较大 |
| 并发性 | 同类型串行 | 可并行 | 可并行 |
| 适用场景 | 快速、轻量 | 耗时、可阻塞 | 需要优先级控制 |
3.6 我的选择策略
你可能会问:到底该用哪个?我个人的经验是这样的:
- 先问自己:底半部需要睡眠吗?如果需要,直接排除tasklet。
- 再问:中断频率高吗?如果每秒超过1000次,我倾向于用tasklet,因为线程切换开销太大。
- 最后问:需要控制优先级吗?如果需要实时性,用threaded IRQ配合rtprio。
我曾经在车载信息娱乐系统里调试一个触摸屏驱动。触摸中断频率不高(每秒几十次),但处理时需要访问I2C。我一开始用了tasklet,结果发现I2C操作会睡眠,导致内核报错。后来改成workqueue,问题就解决了。
避坑指南:我曾经见过有人把耗时的文件操作放在tasklet里,结果系统直接死锁。记住:tasklet里绝对不能睡眠,不能拿信号量,不能做任何可能阻塞的操作。
3.7 代码示例:三种方式对比
下面是一个简单的虚拟设备驱动,展示了三种底半部的注册方式。你一看就明白了。
// 方式一:Tasklet
static void my_tasklet_handler(unsigned long data)
{
// 不能睡眠,不能阻塞
// 适合快速处理
pr_info("Tasklet: processing data\n");
}
DECLARE_TASKLET(my_tasklet, my_tasklet_handler, 0);
static irqreturn_t my_irq_handler_tl(int irq, void *dev_id)
{
// 顶半部:清中断,调度tasklet
tasklet_schedule(&my_tasklet);
return IRQ_HANDLED;
}
// 方式二:Workqueue
static void my_work_handler(struct work_struct *work)
{
// 可以睡眠,可以阻塞
// 适合耗时操作
msleep(10); // 模拟耗时操作
pr_info("Workqueue: processing data\n");
}
static DECLARE_WORK(my_work, my_work_handler);
static irqreturn_t my_irq_handler_wq(int irq, void *dev_id)
{
schedule_work(&my_work);
return IRQ_HANDLED;
}
// 方式三:Threaded IRQ
static irqreturn_t my_threaded_handler(int irq, void *dev_id)
{
// 在内核线程中运行,可以睡眠
// 优先级可调
msleep(20);
pr_info("Threaded IRQ: processing data\n");
return IRQ_HANDLED;
}
static int my_probe(struct platform_device *pdev)
{
// 注册threaded IRQ
return request_threaded_irq(irq, NULL, my_threaded_handler,
IRQF_TRIGGER_RISING, "my_dev", dev);
}
小技巧:如果你不确定该用哪种,可以先从workqueue开始。它最通用,也最安全。等性能测试发现问题后,再考虑优化成tasklet或threaded IRQ。
嗯,中断处理的设计取舍,说白了就是平衡“响应速度”和“处理能力”。顶半部负责快,底半部负责稳。选对了,你的驱动就能在汽车上稳定跑几年;选错了,可能路试一次就出问题。我建议你在开发阶段多做压力测试,看看高频率中断下系统是否还能正常工作。