3、中断处理:顶半部与底半部(tasklet/workqueue/threaded IRQ)的设计取舍

中断处理,说白了就是CPU被硬件喊去干活。但干活也得讲究方式方法——你不能在中断里待太久,否则系统就卡死了。我刚开始做驱动时,就吃过这个亏。

Linux把中断分成了两半:顶半部和底半部。顶半部就是那个紧急响应,底半部才是真正干活的地方。今天咱们就聊聊这三条路:tasklet、workqueue、threaded IRQ,看看它们到底该怎么选。

核心原则:顶半部只做最紧急的事(清中断标志、拷贝少量数据),其余全部扔给底半部。谁违反了这条,谁的系统就会卡顿。

3.1 顶半部:能短则短

顶半部是在中断上下文里跑的。这意味着什么?不能睡眠,不能调度,不能拿信号量。说白了,你就像在急诊室——只能做止血包扎,不能做开刀手术。

顶半部里一般只做三件事:

  • 清除中断标志位(防止重复触发)
  • 拷贝硬件寄存器中的关键数据
  • 唤醒或标记底半部

我在项目中遇到过有人把耗时的数据处理放在顶半部,结果系统响应时间直接飙到毫秒级。嗯,后来他改成了底半部,问题就解决了。

3.2 底半部的三条路

底半部才是真正干活的。Linux给了我们三种选择:tasklet、workqueue、threaded IRQ。我画了张图,帮你理清它们的关系。

中断处理:顶半部与底半部设计取舍 顶半部(中断上下文) Tasklet(软中断) Workqueue(内核线程) Threaded IRQ(中断线程) Tasklet 特性 • 软中断上下文,不可睡眠 • 同类型串行执行 • 开销最小,适合快速处理 Workqueue 特性 • 进程上下文,可睡眠 • 可阻塞、可调度 • 适合耗时操作 Threaded IRQ 特性 • 内核线程上下文 • 可睡眠、可加锁 • 优先级可调 设计取舍总结 快速、不可睡眠 → 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 我的选择策略

你可能会问:到底该用哪个?我个人的经验是这样的:

  1. 先问自己:底半部需要睡眠吗?如果需要,直接排除tasklet。
  2. 再问:中断频率高吗?如果每秒超过1000次,我倾向于用tasklet,因为线程切换开销太大。
  3. 最后问:需要控制优先级吗?如果需要实时性,用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。

嗯,中断处理的设计取舍,说白了就是平衡“响应速度”和“处理能力”。顶半部负责快,底半部负责稳。选对了,你的驱动就能在汽车上稳定跑几年;选错了,可能路试一次就出问题。我建议你在开发阶段多做压力测试,看看高频率中断下系统是否还能正常工作。