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,核心思想是:在流量低时使用中断获得低延迟,在流量高时切换到轮询避免中断风暴。这种“中断+轮询”混合模式成为现代网卡驱动的标准数据接收路径。
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 实例从轮询列表移除。- 驱动重新开启网卡的中断使能位,恢复中断接收模式。
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 的关键步骤
- 分配并初始化 NAPI 实例:在驱动 probe 函数中调用
netif_napi_add(),传入设备指针、NAPI 结构体、poll()函数指针和 weight。 - 实现
poll()回调:从硬件接收描述符环中批量读取数据包,构造sk_buff,调用napi_gro_receive()或netif_receive_skb()送入协议栈。 - 中断处理函数中调度 NAPI:在 ISR 中调用
napi_schedule(),然后关闭中断。 - 轮询结束时重开中断:当
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。
常见错误:在
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 延迟优先)选择合适的预算值。