Linux 网络驱动 NAPI 机制深度解析:从硬件中断到软中断的收包路径全链路分析
一、千兆线速下的中断风暴:当每个数据包都触发一次硬中断
在 10Gbps 以太网线速下,64 字节最小帧的到达间隔约为 67.2ns。如果每个帧都触发一次 IRQ,CPU 将完全陷入"中断处理→上下文切换→返回"的死循环中,用户态进程几乎没有执行时间。这就是经典的中断风暴问题。
Linux 内核从 2.6 版本引入的 NAPI(New API)机制,就是为解决这个矛盾而生。它的核心思想是"中断 + 轮询"混合模式:首包用硬中断唤醒收包流程,后续在软中断上下文中批量轮询收包,直至队列清空或预算耗尽。本文从 igb(Intel Gigabit)驱动入手,逐段分析收包路径中的关键数据结构与执行逻辑。
在嵌入式 Linux 场景下,NAPI 同样适用于 ARM SoC 上的 dwmac、stmmac 等以太网 MAC 驱动。理解这条路径对于定位"高吞吐下丢包"、"软中断 CPU 占用异常"等问题至关重要。
二、从硬件中断到软中断:NAPI 收包的七个关键阶段
NAPI 收包全链路涉及硬件队列、中断子系统、软中断线程、网络栈、socket 缓冲五个层次。下面以 Mermaid 时序图展示核心流程。
sequenceDiagram
participant NIC as 网卡硬件
participant IRQ as 硬中断上下文
participant NET_RX as NET_RX_SOFTIRQ
participant Driver as 驱动(napi_struct)
participant Stack as 网络协议栈
participant Socket as 用户Socket
NIC->>IRQ: 1. 数据包到达,触发 MSI-X 中断
IRQ->>Driver: 2. 调用 igb_msix_ring()
Note over IRQ,Driver: napi_schedule_prep() 检查 NAPI_STATE_SCHED
IRQ->>NET_RX: 3. __napi_schedule() 触发 NET_RX_SOFTIRQ
IRQ->>IRQ: 4. 禁用同队列中断(napi->state |= NAPI_STATE_SCHED)
Note over NET_RX: 硬中断返回后,ksoftirqd 接管
NET_RX->>Driver: 5. net_rx_action() → igb_poll()
loop 预算循环(budget=64)
Driver->>NIC: 6. igb_clean_rx_irq() 批量搬移 skb
Driver->>Stack: 7. napi_gro_receive()
Stack->>Stack: 协议解析、路由查找
Stack->>Socket: 数据递交给 recvmsg
end
Driver->>Driver: 预算耗尽或队列空
Driver->>NET_RX: 返回 work_done
NET_RX->>IRQ: 队列未空? 重新调度软中断
NET_RX->>NIC: 队列已空? 重新启用中断
关键数据结构 napi_struct 维护了中断与轮询之间的状态切换:
// include/linux/netdevice.h(关键字段摘录)
struct napi_struct {
struct list_head poll_list; // 挂入 softnet_data 的 poll_list
unsigned long state; // NAPI_STATE_SCHED / NAPI_STATE_NPSVC 等标志
int weight; // 每次 poll 调用的处理预算
int (*poll)(struct napi_struct *, int);
struct net_device *dev; // 反向引用网络设备
};
收包软中断 net_rx_action() 的执行逻辑决定了吞吐天花板:
// net/core/dev.c —— 简化的核心逻辑
static __latent_entropy void net_rx_action(struct softirq_action *h)
{
struct softnet_data *sd = this_cpu_ptr(&softnet_data);
unsigned long time_limit = jiffies + 2; // 时间预算:2 jiffy
int budget = netdev_budget; // 进程预算:默认 300
LIST_HEAD(list);
list_splice_init(&sd->poll_list, &list);
while (list) {
struct napi_struct *n = list_first_entry(&list, …);
int work, weight;
/* 调用驱动的 poll 函数,返回实际处理的包数 */
work = n->poll(n, weight);
budget -= work;
/* 驱动报告处理完毕,且未超预算 → 重新启用中断 */
if (unlikely(work < weight))
list_del_init(&n->poll_list); // 移出 poll_list,允许下次中断
else
list_move_tail(&n->poll_list, &list); // 继续排队
/* 预算耗尽或时间超限 → 中断轮询,等待下一个软中断 */
if (unlikely(budget <= 0 || time_after(jiffies, time_limit))) {
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
break;
}
}
}
三、驱动层 poll 实现:igb_clean_rx_irq 的三级缓冲策略
以 Intel igb 驱动为例,igb_poll() 最终调用 igb_clean_rx_irq(),这个函数实现了 DMA 环形缓冲区和 skb 之间的数据接力:
// drivers/net/ethernet/intel/igb/igb_main.c
static int igb_clean_rx_irq(struct igb_q_vector *q_vector,
struct igb_ring *rx_ring,
const int budget)
{
unsigned int total_bytes = 0, total_packets = 0;
u16 cleaned_count = igb_desc_unused(rx_ring);
/* 循环条件:未超预算 且 硬件描述符就绪 */
while (likely(total_packets < budget)) {
union e1000_adv_rx_desc *rx_desc;
struct sk_buff *skb;
u32 staterr;
u16 i;
// 获取当前待处理的描述符索引
i = rx_ring->next_to_clean;
rx_desc = IGB_RX_DESC(rx_ring, i);
/* 读取硬件写回的 DD(Descriptor Done)标志
* 此标志由 DMA 硬件置位,表示数据已写入指定内存 */
staterr = le32_to_cpu(rx_desc->wb.upper.status_error);
if (!(staterr & E1000_RXD_STAT_DD))
break; // 无新包可处理
/* DMA 映射的 buffer 已完成传输,取出 skb */
skb = rx_ring->rx_buffer_info[i].skb;
if (!skb) {
// 防御性检查:skb 不应为空
rx_ring->rx_stats.alloc_failed++;
goto next_desc;
}
// 从 DMA 区域解除映射
dma_unmap_single(rx_ring->dev,
rx_ring->rx_buffer_info[i].dma,
rx_ring->rx_buffer_info[i].page_offset,
DMA_FROM_DEVICE);
// 从状态字提取包长度(低 14 位)
u16 length = le16_to_cpu(rx_desc->wb.upper.length) & 0x3FFF;
// 设置 skb 协议、校验和等元数据
skb_put(skb, length);
igb_rx_checksum(rx_ring, rx_desc, skb);
skb->protocol = eth_type_trans(skb, rx_ring->netdev);
/* GRO(Generic Receive Offload)合并:
* 在驱动层将同一条流的连续小包合并为一个大包,
* 减少协议栈处理次数。这是 NAPI 性能的关键优化 */
napi_gro_receive(&q_vector->napi, skb);
total_bytes += length;
next_desc:
cleaned_count++;
rx_ring->next_to_clean = (i < rx_ring->count) ? i + 1 : 0;
total_packets++;
}
/* 批次更新硬件 tail 指针,释放已消费的描述符 */
if (cleaned_count)
igb_alloc_rx_buffers(rx_ring, cleaned_count);
return total_packets;
}
四、边界分析:软中断时间预算与 CPU 亲和性的权衡
NAPI 设计中的两个关键常数定义了它的行为边界:
| netdev_budget | 300 | 单次软中断最大处理包数 | 增大提升吞吐但提高延迟 |
| time_limit | 2 jiffy | 单次软中断最大执行时间 | 限制实时性的硬约束 |
| weight | 64 | 每个 NAPI 实例的单次 poll 预算 | 决定队列公平性 |
架构权衡:
busy polling 模式:当开启 SO_BUSY_POLL 时,应用层在 recvmsg() 中直接轮询驱动队列,跳过软中断。延迟可以从 50us 降至 10us 以内,代价是 CPU 资源独占。适用场景是单应用独占 CPU 的嵌入式网关。
RSS(Receive Side Scaling)与 NAPI 的协作:多队列网卡配合 irq_affinity 将中断绑定到不同 CPU 核心。每个核心独立运行 softnet_data,避免跨核锁竞争。在 4 核 A55 SoC 上,正确配置 RSS+irq_affinity 可将吞吐从 600Mbps 提升至 900Mbps+。
禁用场景:延迟敏感的单包收发场景(如工业以太网 EtherCAT),中断合并和轮询引入的延迟不可接受。此时应使用 preempt-rt 内核并禁用该网口的 NAPI。
五、总结
NAPI 机制通过"中断唤醒 + 软中断轮询"的双模策略,在吞吐量和 CPU 利用率之间取得了工程平衡。深入理解 napi_struct 状态切换、net_rx_action 的预算和时间控制、驱动层 poll 的缓冲区管理三者之间的协作,是优化嵌入式 Linux 网络性能的必备知识。在实际调优中,应重点关注中断亲和性配置与 netdev_budget 参数的匹配关系,并结合 perf top 观察 net_rx_action 和 igb_poll 的 CPU 占用比例,以此决定是否需要开启 busy polling 或调整 RSS 队列数。

