3. NAPI 的注册与初始化:netif_napi_add() 函数流程、napi_enable() 与 napi_disable() 的实现

NAPI 机制是 Linux 内核网络栈高性能收包的核心基石。在设备驱动能够使用 NAPI 进行轮询收包之前,必须完成两个关键步骤:注册 NAPI 实例(将 napi_struct 结构体与设备绑定)和启用 NAPI(允许调度器将设备加入轮询列表)。本章深入剖析 netif_napi_add()、napi_enable() 和 napi_disable() 三个核心函数的实现细节与性能考量。

3.1 NAPI 核心数据结构:napi_struct

在分析注册函数之前,必须先理解 napi_struct 结构体。它是 NAPI 调度器与设备驱动之间的契约对象,每个支持 NAPI 的网卡队列(或虚拟设备)都拥有一个独立的 napi_struct 实例。

字段 类型 说明
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 poll_func 设备驱动的轮询回调函数指针,由驱动实现
dev struct net_device * 指向所属网络设备
gro_list struct list_head GRO(Generic Receive Offload)同流报文链表头
napi_id int 用于 RPS(Receive Packet Steering)的哈希 ID
性能要点: napi_struct 的 weight 值直接影响收包延迟与 CPU 占用率之间的平衡。weight 过小会导致频繁触发软中断,增加上下文切换开销;weight 过大会延长单次轮询时间,增加其他网络流的延迟。内核默认值 64 是经过广泛测试的折中方案。

3.2 netif_napi_add():注册 NAPI 实例

netif_napi_add() 是设备驱动初始化阶段调用的注册函数,它将驱动提供的 napi_struct 实例与 net_device 关联起来。该函数在 Linux 5.x 之后分为两个变体:netif_napi_add()(传统版本)和 netif_napi_add_weight()(显式指定 weight 版本)。

函数原型与参数

void netif_napi_add(struct net_device *dev,
                    struct napi_struct *napi,
                    int (*poll)(struct napi_struct *, int),
                    int weight);
参数 说明
dev 指向网络设备结构体,NAPI 实例将绑定到此设备
napi 驱动预先分配的 napi_struct 实例指针
poll 驱动实现的轮询回调函数,返回值为已处理报文数
weight 轮询预算,必须 ≤ NAPI_POLL_WEIGHT(默认 64)

函数执行流程

  1. 参数校验:检查 weight 是否超过 NAPI_POLL_WEIGHT(64),若超过则截断并打印警告。
  2. 初始化链表头:调用 INIT_LIST_HEAD(&napi->poll_list),将 poll_list 初始化为空链表。
  3. 设置回调与预算:将 napi->poll 指向驱动传入的 poll 函数,napi->weight 设为传入的 weight 值。
  4. 关联设备:napi->dev 指向 dev,建立双向关联。
  5. 初始化 GRO 相关字段:初始化 gro_list 链表头,重置 gro_count 等统计字段。
  6. 添加到设备链表:将 napi 通过 napi_list 字段添加到 dev->napi_list 链表中,便于设备级别的 NAPI 管理。
  7. 设置 NAPI ID:为 napi->napi_id 分配一个全局唯一的 ID,用于 RPS 哈希。
关键实现细节: netif_napi_add() 不会修改 napi->state 字段。新注册的 NAPI 实例默认处于 未启用 状态,必须显式调用 napi_enable() 后才能被调度器识别。

3.3 napi_enable():激活 NAPI 调度

napi_enable() 是驱动在注册 NAPI 实例后、开启设备中断前必须调用的函数。它的核心作用是清除 NAPI_STATE_DISABLE 标志,使 NAPI 实例可以被软中断调度器加入轮询列表。

函数实现

void napi_enable(struct napi_struct *n)
{
    BUG_ON(!test_bit(NAPI_STATE_SCHED, &n->state));
    smp_mb__before_atomic();
    clear_bit(NAPI_STATE_DISABLE, &n->state);
    smp_mb__after_atomic();
}

流程解析

  1. 断言检查:使用 BUG_ON 确保当前 NAPI 实例 没有 处于 NAPI_STATE_SCHED 状态。如果处于调度状态就启用,说明驱动逻辑存在竞态条件。
  2. 内存屏障:smp_mb__before_atomic() 确保之前的所有写操作在清除标志之前对其他 CPU 可见。
  3. 清除禁用标志:clear_bit(NAPI_STATE_DISABLE, &n->state) 原子地清除禁用位,允许调度器将本 NAPI 实例加入轮询列表。
  4. 内存屏障:smp_mb__after_atomic() 确保清除操作完成后再执行后续指令,防止指令重排导致中断处理函数看到不一致的状态。
常见错误: 驱动必须在 napi_enable() 之后才开启硬件中断。如果先开中断再启用 NAPI,可能发生以下竞态:中断触发 → 驱动调用 napi_schedule() → 发现 NAPI_STATE_DISABLE 仍被置位 → 调度失败 → 报文永久丢失。

3.4 napi_disable():安全停用 NAPI

napi_disable() 是 napi_enable() 的逆操作,用于在设备卸载、链路 down 或调优场景中安全地停止 NAPI 轮询。它是一个 阻塞函数,会等待当前正在执行的 poll 回调完成。

函数实现

void napi_disable(struct napi_struct *n)
{
    might_sleep();
    set_bit(NAPI_STATE_DISABLE, &n->state);

    while (test_and_set_bit(NAPI_STATE_SCHED, &n->state))
        msleep(1);

    while (test_bit(NAPI_STATE_SCHED, &n->state))
        msleep(1);

    clear_bit(NAPI_STATE_SCHED, &n->state);
}

流程解析

  1. 可睡眠检查:might_sleep() 在启用了内核调试选项时,检查当前上下文是否允许睡眠(不能在原子上下文调用)。
  2. 设置禁用标志:原子置位 NAPI_STATE_DISABLE,阻止新的 napi_schedule() 调用将本实例加入调度队列。
  3. 等待调度完成:使用 test_and_set_bit(NAPI_STATE_SCHED) 尝试获取调度锁。如果 SCHED 位已被置位(表示 poll 正在执行或即将执行),则循环等待 1ms。
  4. 等待 poll 退出:获取 SCHED 位后,继续等待直到 SCHED 位被 poll 回调清除(表示 poll 已返回)。
  5. 清除调度位:最后清除 NAPI_STATE_SCHED 位,完成禁用流程。
性能影响: napi_disable() 中的 msleep(1) 循环是不得已的等待机制。在高速网络设备卸载时,如果 poll 回调正在处理大量报文,禁用操作可能阻塞数十毫秒。驱动设计时应尽量在低负载时段执行禁用操作。

3.5 注册与启用的完整流程

下图展示了从 NAPI 实例分配到最终启用的完整生命周期,以及各阶段的状态变化:

NAPI 注册与启用完整流程 驱动分配 napi_struct (静态或动态分配) netif_napi_add() 绑定 dev / poll / weight napi_enable() 清除 DISABLE 标志 此时 NAPI 实例已注册但未启用,state = 0,不可被调度 启用后 state 清除 DISABLE 位,可被 napi_schedule() 加入轮询列表 硬件中断到达 (关闭本中断) napi_schedule() 加入当前 CPU poll_list 软中断 NET_RX_SOFTIRQ 遍历 poll_list 调用 poll 卸载时调用 napi_disable():设置 DISABLE 位 → 等待 SCHED 位释放 → 清除 SCHED 位 napi_disable() 阻塞等待 poll 完成 清除 NAPI 实例 从 dev->napi_list 移除

3.6 多队列场景下的 NAPI 注册策略

现代网卡普遍支持多队列(RSS),每个队列通常对应一个独立的 NAPI 实例。驱动需要为每个队列调用一次 netif_napi_add(),并确保每个 NAPI 实例绑定到正确的 net_device。

场景 NAPI 实例数 注册方式 性能考量
单队列网卡 1 驱动 probe 时注册 1 个 NAPI 简单,无队列选择开销
多队列网卡(RSS) 等于队列数 每个队列独立注册,通常绑定到不同 CPU 需注意 IRQ 亲和性,避免多个 NAPI 争抢同一 CPU
虚拟设备(bonding/bridge) 1 或多个 虚拟设备可注册自己的 NAPI,或复用底层设备 避免 NAPI 嵌套调度导致优先级反转
最佳实践: 在多队列驱动中,建议在 probe() 函数中为所有队列预先分配 napi_struct 数组,然后循环调用 netif_napi_add()。每个 NAPI 实例的 weight 应保持一致,避免调度器出现不公平的轮询时间分配。

3.7 常见问题与调试方法

  • NAPI 注册后无法调度:检查是否调用了 napi_enable()。未启用时 napi_schedule() 会因 NAPI_STATE_DISABLE 标志而直接返回 false。
  • napi_disable() 死锁:如果在 poll 回调中直接调用 napi_disable(),会导致死锁(poll 持有 SCHED 位,disable 等待 SCHED 释放)。正确的做法是在 workqueue 或独立线程中执行禁用操作。
  • weight 设置过大导致软中断饥饿:当 weight 超过 64 时,内核会打印警告并截断。如果驱动强制使用大 weight,可能导致其他网络流(如 SSH)长时间得不到处理。
  • 调试接口:通过 /proc/net/softnet_stat 可以观察每个 CPU 的 NAPI 调度统计,包括 processed(已处理报文数)和 time_squeeze(预算耗尽次数)。
内核版本差异: 在 Linux 5.10 之前,netif_napi_add() 的 weight 参数允许大于 64,但内核会静默截断。5.10 之后增加了显式的 netif_napi_add_weight() 变体,并强化了参数校验。升级内核时需注意驱动兼容性。