Linux 内核 NAPI 机制深度解析:从硬中断到软中断轮询
写在前面
如果你曾经好奇——网卡每秒收到几十万个包,为什么系统没有崩溃?为什么 Linux 网络栈在高 PPS 下仍能保持吞吐?答案的核心之一就是 NAPI(New API)。
NAPI 不是某个单一函数,而是一套”中断触发、软中断批量轮询、按预算公平调度”的收包负载控制框架。理解它,是读懂 Linux 网络收包路径的必经之路。
本文以 Linux 5.10 为主线,6.1 做对比,结合 e1000 和 RK3588 stmmac 驱动实例,从问题动机到源码细节,完整拆解 NAPI 机制。
一、问题:为什么不能每个包一个硬中断?
千兆/万兆网卡可能每秒产生几十万到数百万个包。如果每个包都触发一次硬中断,CPU 会进入 interrupt livelock——看起来一直忙,但业务处理反而推进很少。具体问题包括:
- 中断频率过高:CPU 主要时间花在中断入口/出口、寄存器保存恢复、上下文切换上,而不是协议栈本身。
- 硬中断上下文限制多:不能睡眠,不适合做复杂协议栈处理、内存回收和大批量逻辑。
- 缓存局部性差:每个包独立打断当前执行流,频繁污染 I-cache/D-cache。
- 公平性差:一个高流量网卡可能长期占用 CPU,其他软中断、进程调度被拖慢。
NAPI 的解法是:第一次包到达仍靠中断唤醒;进入 NAPI 后,关闭该队列中断,由软中断一次处理一批包,并用 budget 控制单次处理量。
二、核心模型:中断 + 轮询的混合机制
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_struct(include/linux/netdevice.h:330)组织,可按职责分为五类:
3.1 调度和状态字段
| 字段 | 说明 |
|---|---|
poll_list |
挂到当前 CPU 的 softnet_data->poll_list 或临时 repoll 链表 |
state |
保存 SCHED、MISSED、DISABLE 等状态位 |
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
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、状态机:
- 设置
poll、weight、dev - 初始化
poll_list、gro_hash、timer(回调为napi_watchdog) - 设置
NAPI_STATE_SCHED和NAPI_STATE_NPSVC——让刚注册但尚未napi_enable()的 NAPI 处于”不可被正常调度”的初始状态 - 把 NAPI 挂入
dev->napi_list - 加入 NAPI hash,供 busy poll 按 ID 查找
napi_enable() 会清除 NAPI_STATE_NPSVC 和 NAPI_STATE_SCHED,使 NAPI 进入可调度状态。这样可以防止设备尚未 open、ring 尚未准备好时 NAPI 被错误调度。
4.3 open/close 的典型顺序
1 | open/up: |
napi_disable()(net/core/dev.c:6789)设置 NAPI_STATE_DISABLE,并等待 SCHED、NPSVC 状态可被占有,确保没有正在运行或待运行的 NAPI poll。
4.4 多队列网卡:一个 netdev 多个 NAPI
一个 struct net_device 有 napi_list,可以挂多个 struct napi_struct。多队列网卡通常每个 RX queue(或每个 channel)一个 NAPI。
RK3588 SDK 的 stmmac 驱动就是典型例子:stmmac_napi_add() 遍历 queue,对每个 channel 按需注册 rx_napi 和 tx_napi(stmmac_main.c:4963)。
五、从硬中断到软中断:NAPI 调度链路
5.1 核心调度流程
1 | 驱动 ISR |
真正把 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() 原子替换。逻辑如下:
- 读取旧状态
val。 - 如果有
NAPI_STATE_DISABLE,说明设备正在关闭,返回 false。 - 计算新状态
new = val | SCHED。如果旧状态已经有 SCHED,则额外设置 MISSED。 cmpxchg()原子替换,失败则并发修改了 state,重试。- 返回
!(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 | poll 期间又来包/事件 |
这避免了”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。好处:
- 缓存局部性:中断 CPU 刚访问了设备状态、ring 指针和驱动私有数据。
- 避免跨 CPU 锁竞争:每 CPU
poll_list减少全局队列竞争。 - 符合 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 段:
- 取当前 CPU 的
softnet_data。 - 计算本轮时间上限:当前 jiffies +
netdev_budget_usecs。 - 读取全局包预算
netdev_budget。 - 关本地中断,把
sd->poll_list搬到局部list,减少持有共享队列时间。 - 循环取
list上的第一个 NAPI,调用napi_poll(),从全局 budget 中扣除返回的 work。 - 如果 budget 用尽或时间到,递增
sd->time_squeeze并退出本轮。 - 把新来的
sd->poll_list、repoll和剩余list合并回sd->poll_list,如果仍有待处理 NAPI,再次 raiseNET_RX_SOFTIRQ。
6.1 budget、weight、netdev_budget 三层控制
1 | net_rx_action 总循环受 netdev_budget + netdev_budget_usecs 限制 |
| 概念 | 说明 | 默认值/典型值 |
|---|---|---|
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 加入 repoll。net_rx_action() 在本轮结束时把 repoll 合并回 sd->poll_list,如果非空则再次 raise NET_RX_SOFTIRQ。
repoll 解决两个问题:
- 防止单 NAPI 一次调用无限处理(用完 weight 让出机会)。
- 保留未完成工作(没有 complete 的 NAPI 不丢失)。
6.3 time_squeeze:软中断压力信号
time_squeeze 在全局 budget 用完或时间窗口耗尽时递增。它表示:本 CPU 的 NET_RX softirq 本轮来不及处理完所有待收包工作,被迫提前退出。
如果 /proc/net/softnet_stat 中对应 CPU 的 time_squeeze 持续增长,通常说明:
- 收包压力较大
netdev_budget或netdev_budget_usecs可能偏小- 单 CPU 承担过多 RX queue
- 中断亲和性/RSS/RPS 配置不均衡
七、驱动 poll 回调:收包的核心战场
7.1 RX ring 生命周期
1 | 驱动初始化 RX ring descriptor,分配 DMA buffer |
7.2 驱动 poll 的标准模板
1 | int driver_poll(struct napi_struct *napi, int budget) |
关键规则:
- 返回值 不能超过 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_done 与 work_to_do 控制最多处理数量(e1000_main.c:4349)。
7.4 现代 multi-queue 驱动:stmmac
RK3588 SDK 的 stmmac 驱动为每个 channel 注册独立的 RX/TX NAPI:
1 | 注册:stmmac_napi_add() |
与 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_bitmask、gro_flush_timeout、napi_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 | NAPI poll 提供批处理时机和上下文 |
驱动常调用 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 | 驱动 NAPI 主路径: |
backlog 路径处理已形成 skb 后被放入 CPU backlog 的场景,例如部分虚拟设备、老路径、跨 CPU RPS 入队等。
十一、NAPI 在收包路径中的位置
把 NAPI、RPS/RFS、XDP、page_pool 放在一起看,它们在收包路径中的位置如下:
| 机制 | 位置 | 作用 |
|---|---|---|
| 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 | mount -t tracefs nodev /sys/kernel/tracing |
12.3 time_squeeze 持续增长时的排查思路
- 检查
netdev_budget和netdev_budget_usecs是否偏小。 - 检查单 CPU 是否承担过多 RX queue(
/proc/interrupts)。 - 检查中断亲和性/RSS/RPS 配置是否均衡。
- 检查驱动或上层协议栈处理是否过慢。
十三、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 相关位 | 增加 THREADED、SCHED_THREADED、PREFER_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 的四个关键点:
- MISSED 状态——理解并发中断与 poll 竞态的核心。
- budget 体系——理解 NAPI 公平性和延迟的核心(weight、netdev_budget、netdev_budget_usecs 三层控制)。
napi_complete_done()返回值——驱动重新打开中断前的关键同步点。- GRO 与 NAPI 的绑定——NAPI 不只是调度框架,也是 GRO 批处理上下文。
NAPI 的核心路径在 net/core/dev.c,但真正理解必须结合一个驱动 poll 函数。建议以 e1000 为入门,再过渡到 stmmac 等现代多队列驱动。
关键源码阅读顺序(Linux 5.10):
include/linux/netdevice.h:330—struct napi_struct/ NAPI 状态位include/linux/netdevice.h:454—napi_schedule()/napi_schedule_irqoff()net/core/dev.c:6459—napi_schedule_prep()net/core/dev.c:4289—____napi_schedule()net/core/dev.c:6899—net_rx_action()net/core/dev.c:6833—napi_poll()net/core/dev.c:6502—napi_complete_done()net/core/dev.c:6759—netif_napi_add()drivers/net/ethernet/intel/e1000/e1000_main.c:3795—e1000_clean()net/core/dev.c:6166—napi_gro_receive()
