3、NAPI与新API驱动模型:从中断风暴到优雅轮询

网络驱动开发中,最让我头疼的问题就是“中断风暴”。早年做项目时,千兆网口一跑满,CPU 直接被中断吃掉 80%,应用层几乎卡死。后来接触了 NAPI,才明白什么叫“优雅”。

这一章,我们聊聊 NAPI 的原理、实战编写,以及新旧 API 怎么迁移。说白了,就是教你如何让网卡驱动不再“野蛮”地打断 CPU。

3.1 NAPI 原理:为什么需要轮询?

传统网卡驱动的工作方式很简单:来一个包,触发一次中断。CPU 在中断上下文把数据从硬件 FIFO 搬到内存,然后返回。这在低速网络下没问题,但到了千兆、万兆环境,每秒几十万个包,CPU 就彻底沦为“搬运工”了。

NAPI 的核心思想是:中断 + 轮询混合。具体来说:

  • 第一个包到达时,仍然触发中断,但中断处理程序会立即关闭该网卡的中断。
  • 然后,内核将网卡注册到一个“轮询列表”中,由软中断(ksoftirqd)驱动轮询。
  • 轮询函数从硬件批量读取数据包,直到没有数据或达到预算(budget)。
  • 轮询结束后,重新开启中断。

这样做的好处很明显:高负载下减少中断次数,低负载下保持低延迟。我当年调试一个 4 口千兆网卡时,开启 NAPI 后 CPU 占用率从 75% 降到了 20%,效果立竿见影。

核心要点:NAPI 不是纯轮询,而是“中断唤醒 + 轮询收包”的混合模式。它只在数据量大时才关闭中断,用轮询批量处理。

为什么会这样设计?你想想看,如果纯轮询,空闲时 CPU 空转浪费电;如果纯中断,高负载时 CPU 被频繁打断。NAPI 正好在两者之间找到了平衡点。

NAPI 中断+轮询混合工作流程 数据包到达 触发硬件中断 关闭中断 → 注册到轮询列表 ksoftirqd 轮询收包 还有数据? 是 否 重新开启中断

3.2 NAPI 驱动编写实战

写一个 NAPI 驱动,核心就是实现 poll 回调函数,并在中断处理中调用 napi_schedule。我习惯把步骤拆成 4 步:

  1. 初始化 NAPI 结构体:调用 netif_napi_add 注册 poll 函数。
  2. 中断处理函数:关闭本设备中断,调用 napi_schedule 触发轮询。
  3. 实现 poll 函数:批量收包,调用 napi_gro_receive 或 netif_receive_skb。
  4. 收尾:当收完所有包后,调用 napi_complete 并重新开启中断。

下面是一个简化版的 NAPI 驱动骨架:

/* 1. 定义 napi 结构体 */
static struct napi_struct my_napi;

/* 2. poll 回调函数 */
static int my_poll(struct napi_struct *napi, int budget)
{
    struct my_priv *priv = container_of(napi, struct my_priv, napi);
    int work_done = 0;
    struct sk_buff *skb;

    while (work_done < budget) {
        skb = my_hw_read_packet(priv);  /* 从硬件读取数据包 */
        if (!skb)
            break;

        napi_gro_receive(napi, skb);    /* 使用 GRO 合并 */
        work_done++;
    }

    if (work_done < budget) {
        /* 没有更多数据了,退出轮询 */
        napi_complete(napi);
        my_enable_interrupt(priv);      /* 重新开启中断 */
    }

    return work_done;
}

/* 3. 中断处理函数 */
static irqreturn_t my_interrupt(int irq, void *dev_id)
{
    struct my_priv *priv = dev_id;

    my_disable_interrupt(priv);         /* 关闭中断 */
    napi_schedule(&priv->napi);        /* 触发轮询 */

    return IRQ_HANDLED;
}

/* 4. 驱动初始化 */
static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    /* ... 硬件初始化 ... */

    netif_napi_add(netdev, &priv->napi, my_poll, 64);  /* budget=64 */
    napi_enable(&priv->napi);

    return 0;
}

个人经验:budget 值我一般设 64,太大容易导致其他网络协议栈饿死,太小又体现不出批量收包的优势。你可以根据实际流量调整,但别超过 256。

这里有个细节要注意:napi_gro_receive 和 netif_receive_skb 的区别。GRO(Generic Receive Offload)能把多个小包合并成大包,减少上层协议栈的处理开销。我建议能用 GRO 就用,除非你的硬件不支持。

3.3 新旧 API 对比与迁移

旧 API(纯中断模式)和新 API(NAPI)的差异,我用一张表总结:

对比维度 旧 API(纯中断) 新 API(NAPI)
收包方式 每个包触发一次中断 中断唤醒 + 轮询批量收包
高负载 CPU 占用 高(中断风暴) 低(批量处理)
低负载延迟 低(立即响应) 略高(需调度轮询)
核心 API netif_rx napi_schedule + napi_gro_receive
中断控制 始终开启 轮询时关闭,结束后开启
适用场景 低速网络、简单设备 千兆/万兆、高性能设备

迁移其实不复杂。我做过几次从旧 API 到 NAPI 的移植,核心改动就三处:

  • 中断处理:把 netif_rx 换成 napi_schedule,并加上关闭中断的逻辑。
  • 收包路径:把收包逻辑从中断上下文移到 poll 函数中。
  • 初始化:添加 netif_napi_add 和 napi_enable 调用。

避坑指南:我曾经在迁移时忘记在 poll 函数中调用 napi_complete,结果中断一直关着,网卡彻底“聋”了。调试了一下午才发现。记住:napi_complete 和重新开启中断必须成对出现。

还有一个容易踩的坑:预算(budget)耗尽时不要调用 napi_complete。如果 poll 函数返回的 work_done 等于 budget,说明还有数据没处理完,内核会自动再次调度 poll。这时候调用 napi_complete 会导致中断提前开启,造成数据丢失。

嗯,这里要注意:NAPI 不是万能的。如果你的网卡硬件不支持批量 DMA 描述符,或者每次只能读一个包,那 NAPI 的优势就大打折扣。我建议先确认硬件能力,再决定是否迁移。

总结一下:NAPI 的核心价值在于“用轮询换中断”,适合高吞吐场景。迁移时抓住三个关键点:中断控制、poll 函数、budget 管理。只要这三处不出错,基本就能跑起来。