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的核心思想是:在高速流量下将中断驱动的接收模式切换为轮询模式,从而消除中断风暴,提升整体吞吐量。
3.2 轮询与中断的权衡
NAPI并非完全抛弃中断,而是在低流量时使用中断(响应快、延迟低),在高流量时切换为轮询(吞吐高、开销低)。这种动态切换机制需要精确的阈值控制:
| 工作模式 | 触发条件 | 优势 | 劣势 |
|---|---|---|---|
| 中断模式 | 流量低(空闲或轻载) | 延迟低,无包时CPU空闲 | 高流量下产生中断风暴 |
| 轮询模式 | 流量高(NAPI调度后) | 批量收包,消除中断开销 | 持续占用CPU,延迟略增 |
| 混合切换 | 中断触发 → 关闭中断 → 轮询 → 收完重新开中断 | 兼顾低延迟与高吞吐 | 切换逻辑增加复杂度 |
设计哲学: NAPI的权衡本质是“用CPU时间换中断次数”。在高速网络中,CPU时间相对充裕(因为DMA已将数据搬运到内存),而中断次数是瓶颈。NAPI将多次中断合并为一次轮询,是典型的“批处理”思想。
NAPI的切换流程可概括为以下步骤:
- 中断触发:网卡收到数据包,触发硬件中断。
- 中断处理函数:调用
napi_schedule()将设备的napi_struct加入当前CPU的softnet_data轮询列表,并标记软中断NET_RX_SOFTIRQ。 - 关闭硬件中断:驱动在中断处理中关闭该网卡的中断使能,防止新的中断干扰轮询。
- 软中断上下文轮询:内核软中断处理函数
net_rx_action()遍历轮询列表,调用每个NAPI实例的poll回调函数批量收包。 - 收包完成:当
poll返回budget用完或没有更多包时,重新开启硬件中断,NAPI实例退出轮询状态。
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实例到网络设备。
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缓存不一致问题 |