2. NAPI 机制原理

2.1 NAPI 的诞生背景:中断风暴的困境

在早期 Linux 内核中,网络设备驱动完全依赖传统中断模式接收数据包。每个到达的数据包都会触发一次硬件中断,CPU 立即暂停当前任务,执行中断服务程序(ISR)将数据从硬件 FIFO 拷贝到内核缓冲区。这种模式在低负载下延迟极低,但在高负载下会引发严重的中断风暴。

中断风暴的核心问题:当网络流量达到每秒数万甚至数十万数据包时,CPU 几乎全部时间都在处理中断上下文切换和 ISR 执行,导致系统吞吐量急剧下降,甚至触发软锁(soft lockup)或丢包。
对比维度 传统中断模式(高负载) NAPI 模式(高负载)
中断触发频率 每个数据包触发一次 仅在轮询开始/结束时触发
CPU 上下文切换 极高(中断-返回-中断) 极低(一次中断后持续轮询)
吞吐量瓶颈 中断处理开销占满 CPU 轮询循环处理能力
丢包风险 高(中断堆积导致 FIFO 溢出) 低(主动控制处理节奏)
适用场景 低延迟、低吞吐 高吞吐、高负载

NAPI(New API)由 David S. Miller 等人于 2002 年引入 Linux 2.4.20,核心思想是:在流量低时使用中断获得低延迟,在流量高时切换到轮询避免中断风暴。这种“中断+轮询”混合模式成为现代网卡驱动的标准数据接收路径。

传统中断模式 vs NAPI 模式对比 传统中断模式 每个数据包 → 一次硬件中断 ISR 拷贝数据 → 返回 下一个数据包 → 再次中断 CPU 被中断淹没 吞吐量急剧下降 ❌ 中断风暴 NAPI 模式 第一个数据包 → 中断触发 关闭中断 → 注册轮询 轮询批量处理数据包 处理完毕 → 重新开启中断 CPU 利用率平稳 ✅ 高效吞吐

2.2 轮询与中断结合的工作模式

NAPI 的核心工作流程分为三个阶段:中断触发阶段、轮询处理阶段、中断重开阶段。驱动开发者需要理解这三个阶段的切换条件和数据流。

2.2.1 中断触发阶段(Entry)

  • 网卡硬件收到数据包,产生硬件中断。
  • 驱动 ISR 调用 napi_schedule() 将 NAPI 结构体加入 CPU 的 softnet 轮询列表。
  • ISR 立即关闭该网卡的中断使能位,防止后续数据包继续触发中断。
  • 触发软中断 NET_RX_SOFTIRQ,进入轮询上下文。

2.2.2 轮询处理阶段(Poll)

  • 内核软中断服务程序遍历当前 CPU 的轮询列表,调用每个 NAPI 实例的 poll() 回调函数。
  • poll() 函数从硬件 FIFO 或 DMA 区域批量读取数据包,构造 sk_buff 并送入协议栈。
  • poll() 必须遵守预算(budget)限制:每次调用最多处理 budget 个数据包(通常为 64)。
  • 如果实际处理数量 < budget,表示硬件已空,轮询结束;如果 == budget,表示可能还有数据,继续轮询。

2.2.3 中断重开阶段(Exit)

  • 当 poll() 返回 0(表示处理完毕)时,内核调用 napi_complete()。
  • napi_complete() 将 NAPI 实例从轮询列表移除。
  • 驱动重新开启网卡的中断使能位,恢复中断接收模式。
NAPI 中断+轮询工作流程 数据包到达硬件 硬件中断触发 napi_schedule() 关闭中断使能 poll() 批量处理 处理完毕 → napi_complete() 仍有数据 → 继续轮询 重新开启中断使能,回到中断等待状态

2.3 NAPI 核心数据结构

驱动开发者必须掌握 struct napi_struct 的字段含义和生命周期。该结构体是 NAPI 轮询的“句柄”,每个网卡队列对应一个实例。

/* 来源:include/linux/netdevice.h */
struct napi_struct {
    struct list_head    poll_list;      /* 挂入 CPU softnet 轮询链表 */
    unsigned long       state;          /* NAPI_STATE_SCHED 等状态位 */
    int                 weight;         /* 每次 poll 最大处理包数(budget) */
    int                 (*poll)(struct napi_struct *, int budget);
    #ifdef CONFIG_NETPOLL
    spinlock_t          poll_lock;
    int                 poll_owner;
    #endif
    struct net_device   *dev;           /* 所属网络设备 */
    struct list_head    dev_list;       /* 设备内 NAPI 实例链表 */
    struct sk_buff      *gro_list;      /* GRO 合并链表(可选) */
    struct sk_buff      *skb;           /* 当前处理中的 skb(调试用) */
    unsigned int        gro_count;      /* GRO 合并计数 */
};
字段 作用 驱动初始化注意事项
poll_list 将 NAPI 实例链接到当前 CPU 的 softnet_data 轮询队列 由 napi_schedule() 自动管理,驱动不要直接操作
state 标记 NAPI 是否被调度(NAPI_STATE_SCHED) 使用 napi_disable()/napi_enable() 控制
weight 每次 poll() 调用的最大处理预算 通常设为 64,与网卡队列深度匹配
poll 驱动实现的轮询回调函数指针 必须实现,返回实际处理包数
dev 指向所属 net_device 结构体 在 netif_napi_add() 时绑定

2.4 驱动实现 NAPI 的关键步骤

  1. 分配并初始化 NAPI 实例:在驱动 probe 函数中调用 netif_napi_add(),传入设备指针、NAPI 结构体、poll() 函数指针和 weight。
  2. 实现 poll() 回调:从硬件接收描述符环中批量读取数据包,构造 sk_buff,调用 napi_gro_receive() 或 netif_receive_skb() 送入协议栈。
  3. 中断处理函数中调度 NAPI:在 ISR 中调用 napi_schedule(),然后关闭中断。
  4. 轮询结束时重开中断:当 poll() 返回 0 时,调用 napi_complete() 并重新使能中断。
驱动开发提示:在 poll() 函数中,务必遵守 budget 限制。如果硬件队列中还有数据但已处理满 budget,必须返回 budget(非 0),让内核继续调度轮询。如果返回 0 但硬件仍有数据,会导致数据包永远留在 FIFO 中,造成丢包。

2.5 NAPI 的调度与竞争条件

NAPI 在多 CPU 环境下需要特别注意调度竞争。核心规则是:

  • napi_schedule() 只能在中断上下文调用(硬中断或软中断)。
  • 同一个 NAPI 实例同时只能在一个 CPU 上被轮询,由 NAPI_STATE_SCHED 位保证。
  • 如果驱动使用多队列(RSS),每个队列应有独立的 NAPI 实例,绑定到不同 CPU。
多队列 NAPI 绑定关系 网卡硬件(多队列) RX Queue 0 RX Queue 1 RX Queue 2 RX Queue 3 NAPI 0 → CPU 0 NAPI 1 → CPU 1 NAPI 2 → CPU 2 NAPI 3 → CPU 3
常见错误:在 poll() 函数中调用 napi_schedule() 会导致递归调度,引发内核崩溃。正确的做法是:poll() 只负责处理数据,调度由 ISR 负责。如果需要在 poll() 中重新触发调度,应使用 napi_schedule_irqoff() 并确保中断已关闭。

2.6 NAPI 的预算(Budget)与性能调优

预算(budget)是 NAPI 性能调优的核心参数。每个 NAPI 实例的 weight 决定了单次 poll() 调用的最大处理量。全局预算 netdev_budget 限制了所有 NAPI 实例在单次软中断中的总处理量。

参数 默认值 调优方向
weight(单实例) 64 高吞吐场景可增大到 128~256,低延迟场景保持 64
netdev_budget(全局) 300 多队列场景可增大到 600~1200
dev_weight(sysfs) 64 可通过 /sys/class/net/eth0/dev_weight 动态调整

调优原则:预算越大,单次轮询处理包数越多,中断频率越低,但单次软中断延迟越高。驱动开发者应根据业务场景(吞吐优先 vs 延迟优先)选择合适的预算值。