4. 中断与底半部:中断上下文与进程上下文、中断延迟的测量、Threaded IRQ与Tasklet的使用场景
好,咱们进入第四章。这一章聊中断,而且是座舱系统里最让人头疼的那种——实时性中断。
说实话,我刚开始做座舱系统那会儿,对中断的理解还停留在“来了就处理,处理完就走”的阶段。直到有一次,一个高优先级的中断把整个UI刷新卡了200毫秒,用户投诉说“屏幕冻住了”。嗯,从那以后,我才真正开始研究中断上下文和底半部的那些门道。
4.1 中断上下文 vs 进程上下文
先搞清楚一个基本概念:中断上下文和进程上下文,到底有什么区别?
说白了,进程上下文就是“有靠山”的。它有完整的进程控制块(PCB)、有页表、可以睡眠、可以调度。而中断上下文呢?它就是个“临时工”——没有进程依附,不能睡眠,不能触发调度,连current指针都是无效的。
核心区别一句话总结:
- 进程上下文:可以睡眠、可以持有锁、可以触发调度、有完整的mm_struct
- 中断上下文:不能睡眠、不能持有互斥锁、不能触发调度、没有用户空间映射
我在项目中遇到过这样一个坑:一个同事在中断处理函数里调用了kmalloc(GFP_KERNEL)。你猜怎么着?系统偶尔会死锁。因为GFP_KERNEL分配内存时可能会触发页面回收,而页面回收需要等待I/O——这在中断上下文里是致命的。
避坑指南:
我曾经在中断上下文里调用printk太频繁,导致系统软锁。printk在中断上下文里虽然能用,但如果你打印太多,它会尝试获取控制台锁,而这个锁可能被进程上下文持有——死锁就这么来的。
4.2 中断延迟的测量
中断延迟,说白了就是从中断信号到达CPU,到CPU开始执行你的中断服务函数,这中间隔了多久。
为什么会关心这个?因为座舱系统里,触控中断、CAN总线中断、视频帧中断,每一个都有严格的deadline。触控中断延迟超过10ms,用户就能感觉到“点下去没反应”。
测量中断延迟,我常用的方法有两种:
方法一:硬件示波器 + GPIO Toggle
这是最土但最可靠的方法。在中断入口处拉高一个GPIO,在中断处理完拉低。用示波器看这个脉冲的宽度,就是中断处理时间。至于延迟?看中断信号和GPIO上升沿之间的时间差。
// 伪代码示例
irqreturn_t my_irq_handler(int irq, void *dev_id) {
gpio_set_value(DEBUG_GPIO, 1); // 标记进入中断
// ... 实际处理 ...
gpio_set_value(DEBUG_GPIO, 0); // 标记退出中断
return IRQ_HANDLED;
}
方法二:内核的ftrace工具
如果你不想动硬件,ftrace是个好选择。它可以直接记录中断进入和退出的时间戳。
# 启用中断跟踪
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo irq_handler_entry > /sys/kernel/debug/tracing/set_event
echo irq_handler_exit > /sys/kernel/debug/tracing/set_event
cat /sys/kernel/debug/tracing/trace_pipe
我个人习惯用ftrace做初步分析,然后用示波器做最终验证。为什么?因为ftrace本身也会引入一点延迟,虽然很小,但在微秒级的测量中,这点误差不能忽略。
经验之谈:
测量中断延迟时,别忘了考虑中断嵌套的情况。高优先级中断可以抢占低优先级中断,这时候低优先级中断的延迟会显著增加。我在一个项目中就发现,某个低优先级的中断偶尔延迟达到50ms,最后查出来是因为被一个高频的定时器中断反复抢占。
4.3 Threaded IRQ vs Tasklet
好,现在进入重头戏。中断处理分上半部和下半部,这个大家都知道。上半部做最紧急的事(比如清中断标志、读硬件寄存器),下半部做剩下的处理。
下半部的实现方式有好几种:Tasklet、Workqueue、Threaded IRQ。这里我重点讲Threaded IRQ和Tasklet,因为这两个在座舱系统里用得最多。
Tasklet:轻量级,但有限制
Tasklet本质上是在软中断上下文中执行的。它的特点是:
- 执行在软中断上下文,不能睡眠
- 同一个Tasklet不会在多个CPU上同时执行
- 调度开销极小,适合快速处理
什么时候用Tasklet?我个人觉得,当你的下半部处理时间很短(几十微秒级别),而且不需要睡眠时,Tasklet是最佳选择。比如:简单的数据拷贝、状态更新、触发DMA传输。
// Tasklet使用示例
void my_tasklet_handler(unsigned long data) {
// 这里不能睡眠,不能调用可能睡眠的函数
struct my_device *dev = (struct my_device *)data;
dev->status = readl(dev->regs + STATUS_REG);
wake_up_interruptible(&dev->waitq);
}
DECLARE_TASKLET(my_tasklet, my_tasklet_handler, (unsigned long)&my_device);
// 在上半部中调度
irqreturn_t my_irq_handler(int irq, void *dev_id) {
// 上半部:只做最紧急的事
writel(0, dev->regs + INT_CLEAR);
tasklet_schedule(&my_tasklet);
return IRQ_HANDLED;
}
Threaded IRQ:灵活,但开销大
Threaded IRQ是内核后来引入的机制。它的下半部是在一个独立的内核线程中执行的,所以:
- 可以睡眠,可以持有互斥锁
- 可以被调度,可以被其他进程抢占
- 可以设置优先级
我在项目中遇到过这样一个场景:一个触摸屏驱动需要读取I2C总线上的数据。I2C传输本身就需要等待,如果在Tasklet里做,根本不行——Tasklet不能睡眠。这时候Threaded IRQ就是唯一的选择。
// Threaded IRQ使用示例
static irqreturn_t touch_irq_handler(int irq, void *dev_id) {
// 上半部:清中断,读状态
writel(0, dev->regs + INT_CLEAR);
return IRQ_WAKE_THREAD; // 唤醒下半部线程
}
static irqreturn_t touch_irq_thread(int irq, void *dev_id) {
// 下半部:可以睡眠,可以调用i2c_transfer
struct i2c_msg msg;
// ... 初始化I2C消息 ...
i2c_transfer(dev->i2c_adapter, &msg, 1);
// 处理触摸数据
input_report_key(dev->input, BTN_TOUCH, 1);
input_sync(dev->input);
return IRQ_HANDLED;
}
// 注册时指定thread_fn
request_threaded_irq(irq, touch_irq_handler, touch_irq_thread,
IRQF_TRIGGER_RISING, "touch", dev);
选择指南:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 处理时间 < 50μs,不睡眠 | Tasklet | 开销小,延迟低 |
| 需要访问I2C/SPI等慢速总线 | Threaded IRQ | 可以睡眠等待传输完成 |
| 需要持有互斥锁 | Threaded IRQ | Tasklet不能持有mutex |
| 处理时间不确定,可能很长 | Threaded IRQ | 可以被调度,不影响其他中断 |
| 对延迟极度敏感(< 10μs) | 上半部直接处理 | 连Tasklet的开销都嫌大 |
4.4 实战中的选择与调优
嗯,这里我想分享一个真实的案例。之前做一个车载仪表盘的项目,有一个轮速传感器的中断。轮速信号频率很高,大概每1ms来一次中断。每次中断需要读取一个32位的寄存器,然后更新一个计数器。
一开始我用的是Tasklet,因为觉得处理很简单。但后来发现,当系统负载高的时候,Tasklet的执行会被延迟,导致计数器更新不及时,车速显示有抖动。
我当时的做法是:把整个处理都放在上半部。因为读取寄存器只需要几个CPU周期,更新计数器也很快,整个处理时间不到1μs。上半部做完就返回,根本不需要下半部。
我的建议:
不要为了用下半部而用下半部。如果上半部能在几微秒内处理完,就直接处理掉。下半部的调度本身也有开销——Tasklet的调度开销大约在0.5-1μs,Threaded IRQ的线程切换开销更大,可能到5-10μs。
反过来,如果你的中断处理需要几十毫秒,那就必须用Threaded IRQ。我曾经见过一个驱动,在Tasklet里做复杂的数学运算,结果导致其他中断的延迟飙升到100ms以上。这就是典型的“下半部滥用”。
最后说一句:中断延迟的优化,没有银弹。你得根据具体的硬件、负载、实时性要求来权衡。我的习惯是:先用ftrace测量实际延迟,再决定用哪种方案。数据说话,别靠猜。