3. NAPI机制原理

3.1 NAPI的引入背景

在早期Linux内核中,网络设备驱动完全依赖传统中断驱动模型:每当一个数据包到达或发送完成,网卡即触发一个硬件中断,CPU立即进入中断服务程序(ISR)进行包处理。这种模型在低速网络(如10/100 Mbps)下工作良好,但当网络速率提升至1 Gbps、10 Gbps甚至更高时,暴露出严重问题:

  • 中断风暴(Interrupt Storm):高吞吐量下每秒产生数十万次中断,CPU被中断上下文完全占据,用户态进程几乎得不到调度。
  • 缓存污染:频繁的中断切换导致CPU指令缓存(I-Cache)和数据缓存(D-Cache)反复失效,系统有效吞吐量反而下降。
  • 上下文切换开销:每次中断涉及保存/恢复寄存器、中断控制器操作、MMU TLB刷新等,单次开销虽小,但累积后极为可观。
关键数据对比: 在1 Gbps链路上,64字节小包每秒可达约1.48M pps。若每个包触发一次中断,CPU每秒需处理148万次中断,每次中断至少消耗数百纳秒,仅中断开销就占满CPU。

为解决上述问题,Linux内核在2.4/2.6时代引入了NAPI(New API)机制,由David S. Miller等人主导设计。NAPI的核心思想是:在高速流量下将中断驱动的接收模式切换为轮询模式,从而消除中断风暴,提升整体吞吐量。

传统中断模型 vs NAPI轮询模型(高吞吐场景) 传统中断驱动模型 网卡中断 网卡中断 网卡中断 网卡中断 每秒数十万次中断 → CPU 100% 忙于中断 缓存频繁失效,吞吐量不升反降 用户态进程饥饿 NAPI轮询模型 中断触发 关闭中断 轮询收包 重新开启中断 中断仅用于唤醒,批量收包在轮询中完成

3.2 轮询与中断的权衡

NAPI并非完全抛弃中断,而是在低流量时使用中断(响应快、延迟低),在高流量时切换为轮询(吞吐高、开销低)。这种动态切换机制需要精确的阈值控制:

工作模式 触发条件 优势 劣势
中断模式 流量低(空闲或轻载) 延迟低,无包时CPU空闲 高流量下产生中断风暴
轮询模式 流量高(NAPI调度后) 批量收包,消除中断开销 持续占用CPU,延迟略增
混合切换 中断触发 → 关闭中断 → 轮询 → 收完重新开中断 兼顾低延迟与高吞吐 切换逻辑增加复杂度
设计哲学: NAPI的权衡本质是“用CPU时间换中断次数”。在高速网络中,CPU时间相对充裕(因为DMA已将数据搬运到内存),而中断次数是瓶颈。NAPI将多次中断合并为一次轮询,是典型的“批处理”思想。

NAPI的切换流程可概括为以下步骤:

  1. 中断触发:网卡收到数据包,触发硬件中断。
  2. 中断处理函数:调用 napi_schedule() 将设备的 napi_struct 加入当前CPU的 softnet_data 轮询列表,并标记软中断 NET_RX_SOFTIRQ。
  3. 关闭硬件中断:驱动在中断处理中关闭该网卡的中断使能,防止新的中断干扰轮询。
  4. 软中断上下文轮询:内核软中断处理函数 net_rx_action() 遍历轮询列表,调用每个NAPI实例的 poll 回调函数批量收包。
  5. 收包完成:当 poll 返回 budget 用完或没有更多包时,重新开启硬件中断,NAPI实例退出轮询状态。
NAPI中断/轮询切换状态机 中断使能 等待包到达 中断触发 napi_schedule() 轮询中 poll() 批量收包 收包完成 重新开中断 包到达 关闭中断 收完/预算用完 重新使能中断 等待下次中断

3.3 NAPI结构体(napi_struct)

NAPI机制的核心数据结构是 struct napi_struct,定义在 <linux/netdevice.h> 中。每个支持NAPI的网络设备(或每个接收队列)都需要关联一个 napi_struct 实例。

/* 位于 include/linux/netdevice.h */
struct napi_struct {
    /* 轮询列表节点,用于将本NAPI挂入CPU的softnet_data轮询链表 */
    struct list_head    poll_list;

    /* 状态标志位,使用NAPI_STATE_*宏操作 */
    unsigned long       state;

    /* 当前NAPI实例允许一次poll回调处理的最大包数 */
    int                 weight;

    /* 驱动实现的轮询回调函数,核心收包逻辑 */
    int                 (*poll)(struct napi_struct *napi, int budget);

    /* 所属网络设备 */
    struct net_device   *dev;

    /* 用于将NAPI实例加入调度队列的链表节点 */
    struct list_head    dev_list;

    /* 所属的CPU(用于RPS/RFS等特性) */
    int                 cpu;

    /* 保护gro_list的锁 */
    spinlock_t          gro_lock;

    /* 通用接收卸载(GRO)相关链表 */
    struct list_head    gro_list;

    /* 其他GRO相关字段 */
    unsigned int        gro_count;
    /* ... 更多字段 */
};
字段 类型 作用
poll_list struct list_head 将NAPI实例链接到当前CPU的 softnet_data->poll_list 轮询链表
state unsigned long 状态位:NAPI_STATE_SCHED(已调度)、NAPI_STATE_DISABLE(禁用)等
weight int 单次 poll 回调最多处理的包数,通常为64
poll 函数指针 驱动实现的轮询回调,返回实际处理的包数
dev struct net_device * 指向所属网络设备,用于获取设备上下文
gro_list struct list_head GRO合并链表,用于同流包合并
注意: napi_struct 的 state 字段使用位操作,常见标志位包括:NAPI_STATE_SCHED(表示该NAPI已被调度进入轮询)、NAPI_STATE_DISABLE(表示该NAPI被禁用,不可调度)。驱动在调用 napi_schedule() 前应检查 NAPI_STATE_SCHED 位,避免重复调度。

3.4 NAPI回调函数

NAPI机制的核心回调函数是 poll,由驱动开发者实现,原型如下:

int (*poll)(struct napi_struct *napi, int budget);

参数说明:

  • napi:指向当前NAPI实例的指针,驱动可通过 napi->dev 获取网络设备。
  • budget:本次轮询允许处理的最大包数,由内核软中断处理函数传入,通常为 net_rx_action() 的预算值。

返回值:

  • 返回实际处理的包数(≤ budget)。
  • 若返回值等于 budget,表示可能还有更多包待处理,NAPI保持调度状态,继续轮询。
  • 若返回值小于 budget,表示包已收完,驱动应调用 napi_complete() 退出轮询并重新开启硬件中断。

典型的 poll 回调实现模板:

static int my_napi_poll(struct napi_struct *napi, int budget)
{
    struct my_netdev_priv *priv = netdev_priv(napi->dev);
    int work_done = 0;

    /* 从DMA ring buffer中批量取包 */
    while (work_done < budget) {
        struct sk_buff *skb = my_retrieve_rx_packet(priv);
        if (!skb)
            break;  /* 没有更多包了 */

        /* 可选:进行GRO合并 */
        if (napi_gro_receive(napi, skb) == GRO_DROP)
            dev_kfree_skb(skb);

        work_done++;
    }

    /* 判断是否收完 */
    if (work_done < budget) {
        /* 包已收完,退出轮询,重新开启中断 */
        napi_complete_done(napi, work_done);
        my_enable_rx_interrupt(priv);
    }

    return work_done;
}
关键API函数:
  • napi_schedule(napi):将NAPI加入调度队列,触发软中断。
  • napi_complete(napi):退出轮询状态,允许后续中断再次调度。
  • napi_complete_done(napi, work_done):带工作量的完成函数,用于忙轮询优化。
  • napi_gro_receive(napi, skb):将skb送入GRO合并流程,返回合并结果。
  • netif_napi_add(dev, napi, poll, weight):注册NAPI实例到网络设备。
napi_struct 结构体与回调关系 struct napi_struct poll_list : 链表节点 state : 调度状态位 weight : 预算上限 poll : 回调函数指针 dev : 所属网络设备 gro_list / gro_count 中断处理函数 napi_schedule() 软中断 net_rx_action 遍历 poll_list 调用 poll() 驱动 poll 回调 批量收包 / GRO / napi_complete 调用流程 ① 中断到达 ② napi_schedule() ③ 关闭硬件中断 ④ 触发 NET_RX_SOFTIRQ ⑤ net_rx_action() ⑥ 调用 poll() ⑦ 收完 → napi_complete ⑧ 重新开中断

3.5 NAPI注册与初始化

驱动在探测(probe)阶段需要完成NAPI的注册,典型流程如下:

static int my_netdev_probe(struct platform_device *pdev)
{
    struct net_device *ndev;
    struct my_netdev_priv *priv;

    /* 1. 分配网络设备 */
    ndev = alloc_etherdev(sizeof(struct my_netdev_priv));
    if (!ndev)
        return -ENOMEM;

    priv = netdev_priv(ndev);

    /* 2. 初始化NAPI实例 */
    netif_napi_add(ndev, &priv->napi, my_napi_poll, 64);

    /* 3. 注册网络设备 */
    register_netdev(ndev);

    return 0;
}

static void my_netdev_remove(struct platform_device *pdev)
{
    struct net_device *ndev = platform_get_drvdata(pdev);
    struct my_netdev_priv *priv = netdev_priv(ndev);

    /* 移除NAPI实例 */
    netif_napi_del(&priv->napi);

    unregister_netdev(ndev);
    free_netdev(ndev);
}
最佳实践:
  • netif_napi_add() 的 weight 参数通常设为64,与内核默认的 netdev_budget 一致。
  • 对于多队列网卡,每个队列应独立分配 napi_struct,实现每队列轮询。
  • 在 remove 函数中必须调用 netif_napi_del(),否则卸载模块时可能导致内核崩溃。

3.6 NAPI与DMA的协同点

NAPI机制与DMA传输的协同体现在以下关键环节:

协同环节 DMA角色 NAPI角色 协同效果
包到达 DMA将包从FIFO搬运到内存Ring Buffer 触发中断,调用 napi_schedule() DMA完成数据搬运,NAPI启动软件处理
批量收包 DMA持续搬运后续包到Ring Buffer poll() 从Ring Buffer批量取skb DMA与CPU并行工作,互不阻塞
收包完成 DMA可能仍有包在搬运中 napi_complete() 重新开中断 确保DMA搬运完成后再处理新中断
内存管理 DMA使用dma_alloc_coherent分配一致内存 NAPI处理完后释放skb或归还DMA缓冲区 避免DMA与CPU缓存不一致问题