4. 中断子系统:Linux中断处理机制,顶半部与底半部(tasklet、workqueue、threaded IRQ)的设计与选择
中断处理,是嵌入式系统里最核心、也最容易出问题的地方之一。我见过不少工程师,写驱动时把整个中断处理搞得又长又重,结果系统响应一塌糊涂。说白了,中断处理就像接电话——你不可能在电话里把整件事都办完,先记下来,回头再处理。
Linux内核把中断处理分成了两段:顶半部(Top Half)和底半部(Bottom Half)。这个设计,我当年刚接触时也觉得多此一举,直到被一个死锁问题折磨了三天……嗯,从那以后我就老老实实按规矩来了。
4.1 顶半部:快进快出
顶半部就是中断服务程序(ISR)。它的原则只有一个:越快越好。
为什么?因为顶半部执行时,当前中断线是被屏蔽的。你拖得越久,其他中断就进不来。我有个项目,网卡驱动里在顶半部做了个复杂的链表遍历,结果一秒钟丢了几百个包。后来改成只记录标志位,问题立刻解决。
顶半部里能做什么?
- 读取硬件状态寄存器
- 清除中断标志位
- 把数据拷贝到内存缓冲区
- 触发底半部处理
绝对不能做什么?
- 调用可能导致睡眠的函数(比如
kmalloc(GFP_KERNEL)) - 获取信号量(mutex)
- 进行大量数据拷贝或复杂计算
printk 也要小心。我见过有人调试时在ISR里加了一堆打印,结果系统直接卡死。因为 printk 可能触发控制台驱动的中断,形成递归。
4.2 底半部:三种武器
顶半部干完最紧急的活,剩下的就交给底半部。Linux提供了三种主流机制:tasklet、workqueue 和 threaded IRQ。怎么选?我个人的经验是:看你的底半部要不要睡觉。
4.2.1 tasklet:轻量级,但不能睡
Tasklet 是基于软中断实现的。它运行在中断上下文中,所以不能睡眠,也不能调用任何可能阻塞的函数。
什么时候用 tasklet?
- 处理时间很短(微秒级)
- 不需要等待任何资源
- 对实时性要求较高
代码示例:
// 定义 tasklet 处理函数
void my_tasklet_handler(unsigned long data)
{
// 这里不能睡眠
// 做一些轻量级的数据处理
printk(KERN_INFO "tasklet: data = %lu\n", data);
}
// 声明 tasklet 结构体
DECLARE_TASKLET(my_tasklet, my_tasklet_handler, 0);
// 在顶半部中调度
irqreturn_t my_isr(int irq, void *dev_id)
{
// 清除中断标志
// ...
// 调度 tasklet
tasklet_schedule(&my_tasklet);
return IRQ_HANDLED;
}
4.2.2 workqueue:能睡觉,但慢一点
Workqueue 运行在进程上下文。这意味着它可以睡眠、可以获取信号量、可以调用 kmalloc(GFP_KERNEL)。说白了,它就是一个内核线程。
什么时候用 workqueue?
- 需要访问 I2C/SPI 等慢速总线
- 需要分配大量内存
- 需要等待硬件操作完成
代码示例:
// 定义 workqueue 处理函数
void my_work_handler(struct work_struct *work)
{
// 这里可以睡眠
// 比如读取 I2C 设备
msleep(10);
printk(KERN_INFO "work: done\n");
}
// 声明 work 结构体
DECLARE_WORK(my_work, my_work_handler);
// 在顶半部中调度
irqreturn_t my_isr(int irq, void *dev_id)
{
// 清除中断标志
// ...
// 调度 work
schedule_work(&my_work);
return IRQ_HANDLED;
}
4.2.3 threaded IRQ:把顶半部和底半部合二为一
Threaded IRQ 是内核 2.6.30 之后引入的。它把整个中断处理都放到一个内核线程里。顶半部只做最必要的事(比如确认中断来自本设备),然后唤醒线程,剩下的全在线程里做。
什么时候用 threaded IRQ?
- 你的顶半部几乎没什么事可做
- 底半部需要睡眠,但又不想用 workqueue 的额外开销
- 希望代码更简洁,不用分开写顶半部和底半部
代码示例:
// threaded IRQ 处理函数
irqreturn_t my_threaded_handler(int irq, void *dev_id)
{
// 这里运行在进程上下文,可以睡眠
// 比如读取硬件寄存器
u32 val = ioread32(my_base + REG_STATUS);
printk(KERN_INFO "threaded: val = %u\n", val);
return IRQ_HANDLED;
}
// 顶半部(快速检查)
irqreturn_t my_isr(int irq, void *dev_id)
{
// 快速确认中断是否来自本设备
if (!(ioread32(my_base + REG_INT) & MY_INT_BIT))
return IRQ_NONE;
// 清除中断标志
iowrite32(MY_INT_BIT, my_base + REG_INT);
// 返回 IRQ_WAKE_THREAD 唤醒线程
return IRQ_WAKE_THREAD;
}
// 注册中断
request_threaded_irq(irq, my_isr, my_threaded_handler,
IRQF_TRIGGER_RISING, "my_device", dev_id);
sched_setscheduler 把线程优先级调高。否则中断响应可能被其他进程拖慢。
4.3 如何选择?一张表说清楚
| 特性 | tasklet | workqueue | threaded IRQ |
|---|---|---|---|
| 运行上下文 | 中断上下文 | 进程上下文 | 进程上下文 |
| 能否睡眠 | 不能 | 能 | 能 |
| 调度延迟 | 低(微秒级) | 中(毫秒级) | 中(毫秒级) |
| 并行性 | 同类型串行 | 可并行 | 每个IRQ一个线程 |
| 代码复杂度 | 低 | 中 | 低(合二为一) |
| 适用场景 | 轻量、快速处理 | 需要睡眠、慢速设备 | 顶半部极简、需要睡眠 |
4.4 我的选择原则
做了这么多年驱动,我总结了一套简单的选择方法:
- 先问自己:底半部需要睡眠吗?
- 不需要 → 用 tasklet
- 需要 → 看下一步
- 再问:顶半部除了确认中断还有别的事吗?
- 几乎没有 → 用 threaded IRQ,代码最干净
- 还有别的事(比如拷贝少量数据)→ 用 workqueue
- 最后问:对实时性要求有多高?
- 极高(微秒级)→ 考虑 tasklet 甚至直接在顶半部处理
- 一般(毫秒级)→ workqueue 或 threaded IRQ 都行
4.5 避坑指南
最后分享几个我踩过的坑:
- 不要在 tasklet 里用
msleep: 这会导致内核崩溃。我见过有人从 workqueue 代码直接复制到 tasklet 里,忘了删 sleep,结果系统直接 panic。 - 注意 workqueue 的并发: 默认的
system_wq是共享的。如果你的底半部任务很重,建议自己创建专用的 workqueue,避免影响其他驱动。 - Threaded IRQ 的
IRQF_ONESHOT标志: 如果你的中断是电平触发,记得加上这个标志。否则中断线会一直被触发,导致线程反复唤醒。我当年调一个 GPIO 中断,没加这个标志,结果 CPU 占用率直接飙到 100%。 - 测试底半部的执行时间: 用
ktime_get在顶半部和底半部各打一个时间戳,看看延迟是否在可接受范围内。我有个项目,workqueue 的调度延迟到了 10 毫秒,后来发现是系统负载太高,换了专用高优先级 workqueue 才解决。
中断处理的设计,说白了就是权衡——在响应速度和功能完整性之间找平衡点。你想想看,一个中断来了,你是想让它快点结束,还是想让它把事情办完?这个问题的答案,决定了你选哪种底半部机制。