写在前面

如果你曾经好奇——网卡每秒收到几十万个包,为什么系统没有崩溃?为什么 Linux 网络栈在高 PPS 下仍能保持吞吐?答案的核心之一就是 NAPI(New API)

NAPI 不是某个单一函数,而是一套”中断触发、软中断批量轮询、按预算公平调度”的收包负载控制框架。理解它,是读懂 Linux 网络收包路径的必经之路。

本文以 Linux 5.10 为主线,6.1 做对比,结合 e1000 和 RK3588 stmmac 驱动实例,从问题动机到源码细节,完整拆解 NAPI 机制。

一、问题:为什么不能每个包一个硬中断?

千兆/万兆网卡可能每秒产生几十万到数百万个包。如果每个包都触发一次硬中断,CPU 会进入 interrupt livelock——看起来一直忙,但业务处理反而推进很少。具体问题包括:

  1. 中断频率过高:CPU 主要时间花在中断入口/出口、寄存器保存恢复、上下文切换上,而不是协议栈本身。
  2. 硬中断上下文限制多:不能睡眠,不适合做复杂协议栈处理、内存回收和大批量逻辑。
  3. 缓存局部性差:每个包独立打断当前执行流,频繁污染 I-cache/D-cache。
  4. 公平性差:一个高流量网卡可能长期占用 CPU,其他软中断、进程调度被拖慢。

NAPI 的解法是:第一次包到达仍靠中断唤醒;进入 NAPI 后,关闭该队列中断,由软中断一次处理一批包,并用 budget 控制单次处理量。

二、核心模型:中断 + 轮询的混合机制

NAPI 不是”纯轮询替代中断”,更准确地说,它是中断触发的自适应批量轮询。基本流程如下:

NAPI 收包主流程

三种流量下的行为:

流量状态 NAPI 行为
无流量/低流量 网卡硬中断唤醒 CPU,延迟低
流量升高 驱动关闭 RX/TX 中断,把 NAPI 放入 softirq 轮询队列
高流量持续 softirq 不断 poll RX ring,避免每个包再次硬中断
流量下降/本轮清空 napi_complete_done() 后重新打开中断,回到低流量中断唤醒模式

以 e1000 为例,中断处理函数 e1000_intr() 读取并清除 ICR,写 IMC 关闭中断,再 __napi_schedule() 调度 NAPI(e1000_main.c:3745);poll 完成后 e1000_clean() 调用 napi_complete_done(),返回 true 才 e1000_irq_enable()e1000_main.c:3795)。

三、核心数据结构:struct napi_struct

NAPI 的所有状态都围绕 struct napi_structinclude/linux/netdevice.h:330)组织,可按职责分为五类:

3.1 调度和状态字段

字段 说明
poll_list 挂到当前 CPU 的 softnet_data->poll_list 或临时 repoll 链表
state 保存 SCHEDMISSEDDISABLE 等状态位
weight 单次 poll 的建议预算
poll 驱动注册的回调,签名 int (*poll)(struct napi_struct *, int)

3.2 设备关联字段

字段 说明
dev 所属 struct net_device
dev_list 挂入 dev->napi_list

3.3 GRO 字段

字段 说明
gro_hash[] / gro_bitmask GRO 聚合哈希表与位掩码
skb / rx_list / rx_count GRO 暂存 skb 链表

NAPI 不只是调度结构,还承载当前 poll 周期内的 GRO 聚合状态。

3.4 定时和延迟开中断字段

字段 说明
timer GRO flush / NAPI watchdog 定时器
defer_hard_irqs_count 延迟重新打开硬中断的计数

3.5 netpoll / busy poll 字段

字段 说明
poll_owner CONFIG_NETPOLL 下使用
napi_hash_node / napi_id busy poll 按 ID 查找 NAPI

3.6 状态位详解

状态位 含义
NAPI_STATE_SCHED 已被调度或正在 poll——NAPI 互斥执行的核心位
NAPI_STATE_MISSED poll 期间又来了事件,需要再跑一轮
NAPI_STATE_DISABLE 正在禁用,napi_schedule_prep() 会拒绝调度
NAPI_STATE_NPSVC netpoll 服务相关,napi_disable() 会等待该状态
NAPI_STATE_IN_BUSY_POLL 当前由 busy poll 路径持有

核心状态机:

NAPI 状态机

四、驱动如何接入 NAPI

4.1 注册与启用:两步走

第一步——probe 阶段注册 NAPI:调用 netif_napi_add(dev, &priv->napi, poll_fn, weight)。e1000 在 e1000_main.c:1015 注册 adapter->napi,poll 函数为 e1000_clean,weight 为 64。

第二步——open/up 阶段启用 NAPI:调用 napi_enable(),随后打开设备中断。e1000 在 e1000_up() 中先 napi_enable()e1000_irq_enable()

为什么分两步?注册是把 NAPI 对象初始化并挂到 netdev;启用是设备真正开始运行时解除初始禁用状态。

4.2 netif_napi_add() 做了哪些初始化?

该函数(net/core/dev.c:6759)不只是填一个回调,它还把 NAPI 接入 netdev、GRO、timer、busy poll/hash、状态机:

  • 设置 pollweightdev
  • 初始化 poll_listgro_hashtimer(回调为 napi_watchdog
  • 设置 NAPI_STATE_SCHEDNAPI_STATE_NPSVC——让刚注册但尚未 napi_enable() 的 NAPI 处于”不可被正常调度”的初始状态
  • 把 NAPI 挂入 dev->napi_list
  • 加入 NAPI hash,供 busy poll 按 ID 查找

napi_enable() 会清除 NAPI_STATE_NPSVCNAPI_STATE_SCHED,使 NAPI 进入可调度状态。这样可以防止设备尚未 open、ring 尚未准备好时 NAPI 被错误调度。

4.3 open/close 的典型顺序

1
2
3
4
5
6
7
open/up:
分配/初始化 ring → 配置硬件 → 清 DOWN 标志
→ napi_enable() → 打开设备中断 → netif_start_queue()

close/down:
停止发送队列 → 关闭设备中断 → napi_disable()
→ 清理 ring / 释放资源

napi_disable()net/core/dev.c:6789)设置 NAPI_STATE_DISABLE,并等待 SCHEDNPSVC 状态可被占有,确保没有正在运行或待运行的 NAPI poll。

4.4 多队列网卡:一个 netdev 多个 NAPI

一个 struct net_devicenapi_list,可以挂多个 struct napi_struct。多队列网卡通常每个 RX queue(或每个 channel)一个 NAPI。

RK3588 SDK 的 stmmac 驱动就是典型例子:stmmac_napi_add() 遍历 queue,对每个 channel 按需注册 rx_napitx_napistmmac_main.c:4963)。

五、从硬中断到软中断:NAPI 调度链路

5.1 核心调度流程

1
2
3
4
5
6
7
8
9
驱动 ISR
→ napi_schedule_prep() ← 原子状态切换
→ 设置 NAPI_STATE_SCHED 或 MISSED
→ ____napi_schedule()
→ list_add_tail(&napi->poll_list, &sd->poll_list)
→ __raise_softirq_irqoff(NET_RX_SOFTIRQ)
→ net_rx_action()
→ napi_poll()
→ 驱动 napi->poll()

真正把 NAPI 挂入每 CPU softnet_data->poll_list 并触发 NET_RX_SOFTIRQ 的代码是 ____napi_schedule()net/core/dev.c:4289)。

5.2 napi_schedule_prep() 的原子状态机

该函数(net/core/dev.c:6459)循环读取 n->state,计算新状态,用 cmpxchg() 原子替换。逻辑如下:

  1. 读取旧状态 val
  2. 如果有 NAPI_STATE_DISABLE,说明设备正在关闭,返回 false。
  3. 计算新状态 new = val | SCHED。如果旧状态已经有 SCHED,则额外设置 MISSED
  4. cmpxchg() 原子替换,失败则并发修改了 state,重试。
  5. 返回 !(val & SCHED):只有原来没被调度时,调用者才应真正入队。

返回值含义

  • true:你抢到了从 idle → scheduled 的权利,请入队。
  • false:已经 scheduled 或 disabled;如果是已 scheduled,MISSED 已记录,不要重复入队。

为什么用原子操作?因为 NAPI 的调度状态可能被多个上下文并发修改:硬中断、softirq poll、busy poll、设备关闭路径、同一设备不同 CPU 上的中断。如果不用原子操作,可能导致同一 NAPI 被重复挂入链表、poll 和 complete 同时改状态导致丢事件。

5.3 MISSED:解决 poll 与中断的竞态

“已经 SCHED” 时再次 schedule,不能重复入 poll_list,也不能丢掉”新事件”。所以设置 NAPI_STATE_MISSED 作为补偿信号。

napi_complete_done()net/core/dev.c:6502)会检查旧状态是否包含 MISSED。如果包含,它不会回到 idle,而是重新设置 SCHED 并调用 __napi_schedule(n),返回 false。

1
2
3
4
poll 期间又来包/事件
→ schedule 发现已 SCHED → 设置 MISSED
→ poll 本轮准备 complete
→ complete 发现 MISSED → 再跑一轮 NAPI

这避免了”poll 刚清空旧事件、准备开中断时,新事件刚好到来但被屏蔽/丢失”的竞态。

5.4 napi_schedule() vs napi_schedule_irqoff()

两者都先调用 napi_schedule_prep(),区别在入队时是否假设硬中断已经关闭:

版本 说明
napi_schedule() 内部 __napi_schedule()local_irq_save() / local_irq_restore()
napi_schedule_irqoff() 调用者保证 hard IRQ 已关闭,省掉本地 IRQ 保存恢复

注意:在 CONFIG_PREEMPT_RT 下,__napi_schedule_irqoff() 会退化为 __napi_schedule(),因为 RT 内核中硬中断可能被线程化,”hard IRQ 已关闭”的假设不一定成立。

5.5 为什么 poll 通常运行在触发中断的 CPU 上?

因为 __napi_schedule() 使用 this_cpu_ptr(&softnet_data),把 NAPI 加到当前 CPU 的 poll_list。好处:

  1. 缓存局部性:中断 CPU 刚访问了设备状态、ring 指针和驱动私有数据。
  2. 避免跨 CPU 锁竞争:每 CPU poll_list 减少全局队列竞争。
  3. 符合 IRQ affinity/RSS 设计:多队列网卡常把不同 RX queue 中断绑到不同 CPU,NAPI 跟随中断 CPU 自然实现并行。

但 RPS/RFS、busy poll、threaded NAPI 等机制可能改变后续处理所在 CPU。

六、net_rx_action():每 CPU 的 NAPI 调度器

net_rx_action()NET_RX_SOFTIRQ 的处理函数(net/core/dev.c:6899),本质是一个每 CPU 的 NAPI 调度器。核心逻辑分 7 段:

  1. 取当前 CPU 的 softnet_data
  2. 计算本轮时间上限:当前 jiffies + netdev_budget_usecs
  3. 读取全局包预算 netdev_budget
  4. 关本地中断,把 sd->poll_list 搬到局部 list,减少持有共享队列时间。
  5. 循环取 list 上的第一个 NAPI,调用 napi_poll(),从全局 budget 中扣除返回的 work。
  6. 如果 budget 用尽或时间到,递增 sd->time_squeeze 并退出本轮。
  7. 把新来的 sd->poll_listrepoll 和剩余 list 合并回 sd->poll_list,如果仍有待处理 NAPI,再次 raise NET_RX_SOFTIRQ

6.1 budget、weight、netdev_budget 三层控制

1
2
3
net_rx_action 总循环受 netdev_budget + netdev_budget_usecs 限制
每个 napi_poll 又受 napi->weight 限制
驱动 poll 的入参 budget 通常就是 napi->weight
概念 说明 默认值/典型值
netdev_budget 一次 NET_RX_SOFTIRQ 总 budget 300(/proc/sys/net/core/netdev_budget
netdev_budget_usecs 一次 softirq 的时间上限 2000μs
napi->weight 单个 NAPI 每次 poll 的 budget e1000 为 64,stmmac 为 64

类比:netdev_budget 是总饭票,weight 是单个队列单次最多能拿的饭票。

6.2 repoll 链表

napi_poll() 如果发现驱动返回 work >= weight 且没有 complete,就把该 NAPI 加入 repollnet_rx_action() 在本轮结束时把 repoll 合并回 sd->poll_list,如果非空则再次 raise NET_RX_SOFTIRQ

repoll 解决两个问题:

  1. 防止单 NAPI 一次调用无限处理(用完 weight 让出机会)。
  2. 保留未完成工作(没有 complete 的 NAPI 不丢失)。

6.3 time_squeeze:软中断压力信号

time_squeeze 在全局 budget 用完或时间窗口耗尽时递增。它表示:本 CPU 的 NET_RX softirq 本轮来不及处理完所有待收包工作,被迫提前退出。

如果 /proc/net/softnet_stat 中对应 CPU 的 time_squeeze 持续增长,通常说明:

  • 收包压力较大
  • netdev_budgetnetdev_budget_usecs 可能偏小
  • 单 CPU 承担过多 RX queue
  • 中断亲和性/RSS/RPS 配置不均衡

七、驱动 poll 回调:收包的核心战场

7.1 RX ring 生命周期

1
2
3
4
5
6
7
8
9
驱动初始化 RX ring descriptor,分配 DMA buffer
→ 网卡收到包,DMA 写入 buffer,设置 descriptor 状态位(如 DD 位)
→ 网卡产生中断
→ ISR 关闭中断并 schedule NAPI
→ NAPI poll 扫描 RX ring
→ 构造或复用 skb
→ napi_gro_receive() 上交协议栈
→ 补充新的 RX buffer 给 descriptor
→ ring 清空后 complete 并重新开中断

7.2 驱动 poll 的标准模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int driver_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;

clean_tx_irq(...); /* 清 TX completion */
clean_rx(..., &work_done, budget); /* 清 RX ring */

if (work_done < budget) {
/* RX ring 已清空 */
if (napi_complete_done(napi, work_done))
enable_rx_irq(); /* 只有返回 true 才重新开中断 */
}
return work_done;
}

关键规则

  • 返回值 不能超过 budget,否则违反 NAPI 调度契约(napi_poll() 中有检查,超限会打印错误)。
  • 返回 budget → ring 可能还有包,NAPI 不 complete,继续 repoll。
  • 返回小于 budget → 本轮清空,可以 complete 并重新开中断。

注意:如果驱动 poll 正好处理了 budget 个包但 ring 已空,传统 NAPI 语义仍倾向于返回 budget 并让下一轮确认——这是为了避免边界歧义。

7.3 e1000 实例

e1000_clean() 先调 e1000_clean_tx_irq() 清 TX,再调 e1000_clean_rx_irq() 清 RX。后者循环检查 rx_desc->status & E1000_RXD_STAT_DD,用 work_donework_to_do 控制最多处理数量(e1000_main.c:4349)。

7.4 现代 multi-queue 驱动:stmmac

RK3588 SDK 的 stmmac 驱动为每个 channel 注册独立的 RX/TX NAPI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
注册:stmmac_napi_add()
→ 每个 channel: rx_napi + tx_napi

中断调度:stmmac_napi_check()
→ 读 DMA 中断状态
→ RX 事件: disable RX DMA irq + __napi_schedule(&ch->rx_napi)
→ TX 事件: 同理

RX poll:stmmac_napi_poll_rx()
→ stmmac_rx(priv, budget, chan)
→ work_done < budget 且 napi_complete_done() 返回 true → 重开 RX DMA irq

TX poll:stmmac_napi_poll_tx()
→ stmmac_tx_clean() → 完成后重开 TX DMA irq

与 e1000 核心模式一致,但 stmmac 更接近现代多队列/分 RX-TX NAPI 的设计。

八、napi_complete_done():从轮询回到中断的关键同步点

8.1 为什么 work_done < budget 才 complete?

因为 work_done < budget 通常表示在预算耗尽前 ring 已无更多包,本轮工作完成,可以退出 polling 模式并重新依赖中断唤醒。

如果 work_done == budget,可能 ring 还有更多包,或刚好处理完但驱动无法可靠区分。保守起见,NAPI 约定不要在返回 budget 时 complete——避免高流量下反复开关中断,也避免边界竞态导致漏处理。

8.2 napi_complete_done() 返回 false 时绝不能开中断

返回 false 表示 NAPI 没有真正回到”由硬中断唤醒”的状态。常见原因:

  • NAPI_STATE_MISSED 存在,已重新 schedule
  • 当前处于 busy poll 状态 NAPI_STATE_IN_BUSY_POLL
  • 配置了 GRO flush timeout / defer hard IRQ,需要延迟开中断
  • 当前处于 netpoll 服务状态 NAPI_STATE_NPSVC

如果驱动不管返回值直接开中断,可能导致 poll 仍在进行时又收到中断、busy poll 语义被破坏、相同事件被双重处理。

所以 e1000 的写法是:只有 napi_complete_done() 返回 true,才 e1000_irq_enable()

8.3 GRO flush 与 hard IRQ defer

napi_complete_done() 会根据 gro_bitmaskgro_flush_timeoutnapi_defer_hard_irqs 决定是否立即 flush GRO、是否启动 NAPI timer、是否返回 false 延迟驱动开中断。

  • GRO 希望多等一点以便合并更多同流包,提高吞吐。
  • hard IRQ defer 允许 NAPI 暂时不重新打开硬中断,通过 timer 或后续轮询继续观察。
  • 这是一种吞吐与延迟的折中:延迟开中断提高批处理效率,但可能增加低流量包延迟。

九、GRO:NAPI 承载的批处理优化

NAPI 不只是调度框架,也承载 GRO(Generic Receive Offload)批处理上下文。GRO 相关状态放在 struct napi_struct 中。

9.1 工作原理

1
2
3
NAPI poll 提供批处理时机和上下文
→ GRO 利用这个上下文聚合同流 skb
→ NAPI complete / budget耗尽 / timeout 触发 GRO flush

驱动常调用 napi_gro_receive(napi, skb)net/core/dev.c:6166)上交包,该函数进入 GRO 逻辑,尝试把同流包合并,最后通过正常收包路径交给协议栈。

9.2 napi_gro_receive() vs netif_receive_skb()

函数 说明
netif_receive_skb() 直接把 skb 交给协议栈收包路径
napi_gro_receive() NAPI 上下文中的 GRO 入口,先尝试聚合,再决定 hold/merge/flush/normal receive

现代高性能驱动应优先使用 napi_gro_receive(),让 TCP/IPv4/IPv6 等流量通过 GRO 降低协议栈处理次数。

十、backlog NAPI:非驱动收包路径

每 CPU 的 softnet_data 中有一个特殊的 backlog NAPI(include/linux/netdevice.h:3307),它不是某个物理网卡队列的 NAPI,而是内核为 backlog 收包队列准备的通用 NAPI 上下文。

两条收包路径的对比:

1
2
3
4
5
6
7
驱动 NAPI 主路径:
硬件 RX ring → 驱动 NAPI poll → 构造 skb → napi_gro_receive() → 协议栈

backlog 路径:
已有 skb → netif_rx()/enqueue_to_backlog()
→ softnet_data input_pkt_queue/process_queue
→ process_backlog() → __netif_receive_skb() → 协议栈

backlog 路径处理已形成 skb 后被放入 CPU backlog 的场景,例如部分虚拟设备、老路径、跨 CPU RPS 入队等。

十一、NAPI 在收包路径中的位置

把 NAPI、RPS/RFS、XDP、page_pool 放在一起看,它们在收包路径中的位置如下:

Linux 网络收包路径全景

机制 位置 作用
NAPI 调度框架 调度和执行驱动收包 poll
XDP 驱动 RX 路径,skb 构造前 可 drop/pass/tx/redirect
page_pool RX buffer 分配/回收 降低 page 分配释放成本
RPS/RFS skb 存在后,协议栈前 按 flow/hash/socket 亲和性分发到目标 CPU

十二、观测与调优

12.1 关键观测点

观测项 方法
NET_RX softirq 次数 /proc/softirqs
dropped / time_squeeze /proc/net/softnet_stat
驱动/网卡队列统计 ethtool -S <ifname>
中断合并参数 ethtool -c <ifname>
全局 budget sysctl net.core.netdev_budget
全局时间限制 sysctl net.core.netdev_budget_usecs
NAPI poll tracepoint tracepoint:napi:napi_poll

12.2 ftrace 观察 NAPI poll

1
2
3
4
5
6
7
8
9
10
mount -t tracefs nodev /sys/kernel/tracing
cd /sys/kernel/tracing

echo 0 > tracing_on
echo > trace
echo 1 > events/napi/napi_poll/enable
echo 1 > tracing_on
# 产生网络流量(如 iperf3)
echo 0 > tracing_on
cat trace

12.3 time_squeeze 持续增长时的排查思路

  1. 检查 netdev_budgetnetdev_budget_usecs 是否偏小。
  2. 检查单 CPU 是否承担过多 RX queue(/proc/interrupts)。
  3. 检查中断亲和性/RSS/RPS 配置是否均衡。
  4. 检查驱动或上层协议栈处理是否过慢。

十三、Linux 5.10 vs 6.1:NAPI 关键差异

维度 Linux 5.10 Linux 6.1
struct napi_struct thread 字段 增加 struct task_struct *thread,支持 threaded NAPI
状态位 无 threaded/busy poll 相关位 增加 THREADEDSCHED_THREADEDPREFER_BUSY_POLL
NAPI 注册 API netif_napi_add(dev, napi, poll, weight) netif_napi_add_weight(dev, napi, poll, weight) + 默认 weight 的 netif_napi_add()
napi_enable() inline 实现 变成普通函数实现
net_rx_action() 基本框架 主体相似,增加 skb_defer_free_flush() 等新逻辑
threaded NAPI 不支持 支持把 NAPI poll 从 softirq 转移到可调度线程

threaded NAPI 的意义:把 NAPI poll 从 softirq 上下文转移到可调度线程,改善实时性控制、允许调度器管理 NAPI 执行、与 PREEMPT_RT 更好配合。代价是线程调度开销和行为复杂度增加,并非所有场景都需要。

迁移注意事项:驱动代码从 5.10 迁移到 6.1 时,netif_napi_add() 参数数量变化,指定 weight 应改用 netif_napi_add_weight()

十四、总结

回到那句话:NAPI 不是”纯轮询替代中断”,而是”中断触发、软中断批量轮询、按预算公平调度、完成后再开中断”的收包负载控制框架。

理解 NAPI 的四个关键点:

  1. MISSED 状态——理解并发中断与 poll 竞态的核心。
  2. budget 体系——理解 NAPI 公平性和延迟的核心(weight、netdev_budget、netdev_budget_usecs 三层控制)。
  3. napi_complete_done() 返回值——驱动重新打开中断前的关键同步点。
  4. GRO 与 NAPI 的绑定——NAPI 不只是调度框架,也是 GRO 批处理上下文。

NAPI 的核心路径在 net/core/dev.c,但真正理解必须结合一个驱动 poll 函数。建议以 e1000 为入门,再过渡到 stmmac 等现代多队列驱动。


关键源码阅读顺序(Linux 5.10)

  1. include/linux/netdevice.h:330struct napi_struct / NAPI 状态位
  2. include/linux/netdevice.h:454napi_schedule() / napi_schedule_irqoff()
  3. net/core/dev.c:6459napi_schedule_prep()
  4. net/core/dev.c:4289____napi_schedule()
  5. net/core/dev.c:6899net_rx_action()
  6. net/core/dev.c:6833napi_poll()
  7. net/core/dev.c:6502napi_complete_done()
  8. net/core/dev.c:6759netif_napi_add()
  9. drivers/net/ethernet/intel/e1000/e1000_main.c:3795e1000_clean()
  10. net/core/dev.c:6166napi_gro_receive()