3. 性能分析工具入门
中断,是外设跟CPU打招呼的方式。但打招呼太频繁,CPU就干不了正事了。我见过不少项目,CPU 50%以上的时间都在处理中断,真正干活的时间反而没多少。这显然不合理。
中断优化的核心思路其实就一句话:让中断处理尽可能短,把耗时操作挪到“非紧急”的上下文里去做。下面我结合自己的经验,聊聊几个实战中常用的技巧。
3.1 中断上下文的划分:分清“紧急”和“重要”
很多人写中断服务函数(ISR),喜欢把所有事情都塞进去。比如读数据、解析协议、更新状态、甚至调用printf打印日志。这其实是个大坑。
中断上下文里,系统是关中断或者至少是屏蔽了同级中断的。你在里面待得越久,其他中断就进不来,实时性反而会下降。我早期做的一个串口驱动就犯过这个错,结果丢包率居高不下。
正确的做法是把ISR分成两部分:
- 上半部(Top Half):只做最紧急的事,比如从硬件寄存器里把数据读出来,清中断标志位。这部分必须快,微秒级完成。
- 下半部(Bottom Half):把数据拷贝到缓冲区、唤醒等待队列、或者提交给协议栈。这部分可以慢一点,但绝对不能阻塞。
核心原则:ISR里不要做任何可能引起阻塞的操作,比如获取信号量、申请内存、或者调用延时函数。
我习惯在ISR里只做三件事:读数据、清标志、触发下半部。剩下的,交给内核调度器去处理。
3.2 中断合并(Interrupt Coalescing):攒一波再处理
有些外设,比如网卡,数据量特别大。每来一个包就触发一次中断,CPU会疯掉的。这时候就需要中断合并。
说白了,就是让硬件等一等,攒够一定数量的数据包,或者等一个超时时间,再统一发一次中断。这样CPU一次中断就能处理多个数据,效率高很多。
我在调优一个千兆网卡驱动时,就靠这个把CPU占用率从80%降到了30%。不过要注意,合并的代价是延迟变大了。如果你做的是音频或者工业控制,延迟敏感,那中断合并就得谨慎使用。
| 参数 | 高吞吐场景 | 低延迟场景 |
|---|---|---|
| 合并阈值(包数) | 64 ~ 256 | 1 ~ 8 |
| 超时时间(μs) | 100 ~ 500 | 10 ~ 50 |
| 适用外设 | 网卡、存储控制器 | 音频、CAN总线 |
小技巧:很多网卡驱动支持动态调整合并参数。你可以根据当前流量自动切换,比如流量大时合并,流量小时直通。我一般在驱动里加一个自适应算法,效果不错。
3.3 中断线程化:把ISR变成内核线程
传统ISR运行在中断上下文,不能睡眠,不能使用很多内核API。这限制太大了。Linux从2.6版本开始支持中断线程化,也就是把中断处理函数放到一个内核线程里去执行。
这样做的好处很明显:
- 线程可以睡眠,可以获取信号量,可以调用阻塞式API。
- 线程可以被调度器管理,优先级可以调整,不会抢占其他重要任务。
- 多个中断可以共享一个线程,减少线程切换开销。
我曾经在一个多串口项目中,把8个串口的中断全部线程化,然后绑定到同一个线程里。结果中断处理的总时间减少了40%,因为避免了频繁的上下文切换。
注意:中断线程化不是万能的。如果中断频率极高(比如每秒几万次),线程切换的开销反而会超过收益。这时候还是老老实实用传统ISR加NAPI吧。
3.4 NAPI与Threaded IRQ:减少中断开销的利器
NAPI(New API)是Linux网络子系统里的一套机制,专门用来应对高流量场景。它的核心思想是:中断来了先关中断,然后轮询收包,直到收完为止。
为什么这样能减少开销?因为在高流量下,每次中断都要保存恢复上下文,代价很大。而轮询模式只需要一次上下文切换,就能处理成百上千个包。
我调过的一个WiFi驱动,用NAPI之后,吞吐量提升了3倍。代码实现也不复杂:
// NAPI 回调函数示例
static int my_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;
struct sk_buff *skb;
// 从硬件收包,最多收 budget 个
while (work_done < budget) {
skb = my_hw_receive_packet(priv);
if (!skb)
break;
napi_gro_receive(napi, skb);
work_done++;
}
// 如果收满了,继续轮询;否则重新开中断
if (work_done < budget) {
napi_complete_done(napi, work_done);
my_hw_enable_interrupt(priv);
}
return work_done;
}
Threaded IRQ则是另一种思路。它把ISR拆成两部分:一个极短的硬中断处理函数(只做硬件操作),和一个可睡眠的线程化处理函数。你可以用request_threaded_irq()来注册:
// 硬中断处理函数(只做最少的硬件操作)
static irqreturn_t my_hard_irq(int irq, void *dev_id)
{
struct my_dev *dev = dev_id;
// 读取状态寄存器,清中断标志
dev->status = readl(dev->regs + STATUS_REG);
writel(dev->status, dev->regs + CLEAR_IRQ_REG);
return IRQ_WAKE_THREAD; // 唤醒线程化处理函数
}
// 线程化处理函数(可以做任何操作)
static irqreturn_t my_thread_irq(int irq, void *dev_id)
{
struct my_dev *dev = dev_id;
// 处理数据,可以睡眠,可以加锁
process_data(dev);
return IRQ_HANDLED;
}
// 注册中断
request_threaded_irq(irq, my_hard_irq, my_thread_irq,
IRQF_TRIGGER_RISING, "my_driver", dev);
选择建议:
- 中断频率 < 1000次/秒:用传统ISR就够了。
- 中断频率 1000~10000次/秒:用Threaded IRQ,代码简单,维护方便。
- 中断频率 > 10000次/秒:用NAPI,性能最好。
3.5 知识体系总览
下面这张图,我把这几种优化方法的关系和适用场景梳理了一下。你写驱动时,可以对照着选方案。
嗯,这几种方法不是互斥的。我经常在一个驱动里同时用中断合并和NAPI,效果叠加。关键是要根据你的硬件特性和业务场景,找到最合适的组合。
最后说一句,中断优化没有银弹。别光看理论,拿示波器或者ftrace实际测一下中断响应时间,比什么都管用。