3. 中断处理优化:让CPU少被“打扰”

中断,是外设跟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 知识体系总览

下面这张图,我把这几种优化方法的关系和适用场景梳理了一下。你写驱动时,可以对照着选方案。

中断处理优化知识体系 中断处理优化 上下文划分 上半部:紧急操作 下半部:耗时操作 中断合并 攒批处理,降低频率 适合高吞吐场景 中断线程化 ISR转为内核线程 可睡眠,可调度 NAPI / Threaded IRQ NAPI:中断+轮询混合 Threaded IRQ:硬中断+线程

嗯,这几种方法不是互斥的。我经常在一个驱动里同时用中断合并和NAPI,效果叠加。关键是要根据你的硬件特性和业务场景,找到最合适的组合。

最后说一句,中断优化没有银弹。别光看理论,拿示波器或者ftrace实际测一下中断响应时间,比什么都管用。