3、NAPI与新API驱动模型:从中断风暴到优雅轮询
网络驱动开发中,最让我头疼的问题就是“中断风暴”。早年做项目时,千兆网口一跑满,CPU 直接被中断吃掉 80%,应用层几乎卡死。后来接触了 NAPI,才明白什么叫“优雅”。
这一章,我们聊聊 NAPI 的原理、实战编写,以及新旧 API 怎么迁移。说白了,就是教你如何让网卡驱动不再“野蛮”地打断 CPU。
3.1 NAPI 原理:为什么需要轮询?
传统网卡驱动的工作方式很简单:来一个包,触发一次中断。CPU 在中断上下文把数据从硬件 FIFO 搬到内存,然后返回。这在低速网络下没问题,但到了千兆、万兆环境,每秒几十万个包,CPU 就彻底沦为“搬运工”了。
NAPI 的核心思想是:中断 + 轮询混合。具体来说:
- 第一个包到达时,仍然触发中断,但中断处理程序会立即关闭该网卡的中断。
- 然后,内核将网卡注册到一个“轮询列表”中,由软中断(ksoftirqd)驱动轮询。
- 轮询函数从硬件批量读取数据包,直到没有数据或达到预算(budget)。
- 轮询结束后,重新开启中断。
这样做的好处很明显:高负载下减少中断次数,低负载下保持低延迟。我当年调试一个 4 口千兆网卡时,开启 NAPI 后 CPU 占用率从 75% 降到了 20%,效果立竿见影。
核心要点:NAPI 不是纯轮询,而是“中断唤醒 + 轮询收包”的混合模式。它只在数据量大时才关闭中断,用轮询批量处理。
为什么会这样设计?你想想看,如果纯轮询,空闲时 CPU 空转浪费电;如果纯中断,高负载时 CPU 被频繁打断。NAPI 正好在两者之间找到了平衡点。
3.2 NAPI 驱动编写实战
写一个 NAPI 驱动,核心就是实现 poll 回调函数,并在中断处理中调用 napi_schedule。我习惯把步骤拆成 4 步:
- 初始化 NAPI 结构体:调用
netif_napi_add注册 poll 函数。 - 中断处理函数:关闭本设备中断,调用
napi_schedule触发轮询。 - 实现 poll 函数:批量收包,调用
napi_gro_receive或netif_receive_skb。 - 收尾:当收完所有包后,调用
napi_complete并重新开启中断。
下面是一个简化版的 NAPI 驱动骨架:
/* 1. 定义 napi 结构体 */
static struct napi_struct my_napi;
/* 2. poll 回调函数 */
static int my_poll(struct napi_struct *napi, int budget)
{
struct my_priv *priv = container_of(napi, struct my_priv, napi);
int work_done = 0;
struct sk_buff *skb;
while (work_done < budget) {
skb = my_hw_read_packet(priv); /* 从硬件读取数据包 */
if (!skb)
break;
napi_gro_receive(napi, skb); /* 使用 GRO 合并 */
work_done++;
}
if (work_done < budget) {
/* 没有更多数据了,退出轮询 */
napi_complete(napi);
my_enable_interrupt(priv); /* 重新开启中断 */
}
return work_done;
}
/* 3. 中断处理函数 */
static irqreturn_t my_interrupt(int irq, void *dev_id)
{
struct my_priv *priv = dev_id;
my_disable_interrupt(priv); /* 关闭中断 */
napi_schedule(&priv->napi); /* 触发轮询 */
return IRQ_HANDLED;
}
/* 4. 驱动初始化 */
static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
/* ... 硬件初始化 ... */
netif_napi_add(netdev, &priv->napi, my_poll, 64); /* budget=64 */
napi_enable(&priv->napi);
return 0;
}
个人经验:budget 值我一般设 64,太大容易导致其他网络协议栈饿死,太小又体现不出批量收包的优势。你可以根据实际流量调整,但别超过 256。
这里有个细节要注意:napi_gro_receive 和 netif_receive_skb 的区别。GRO(Generic Receive Offload)能把多个小包合并成大包,减少上层协议栈的处理开销。我建议能用 GRO 就用,除非你的硬件不支持。
3.3 新旧 API 对比与迁移
旧 API(纯中断模式)和新 API(NAPI)的差异,我用一张表总结:
| 对比维度 | 旧 API(纯中断) | 新 API(NAPI) |
|---|---|---|
| 收包方式 | 每个包触发一次中断 | 中断唤醒 + 轮询批量收包 |
| 高负载 CPU 占用 | 高(中断风暴) | 低(批量处理) |
| 低负载延迟 | 低(立即响应) | 略高(需调度轮询) |
| 核心 API | netif_rx |
napi_schedule + napi_gro_receive |
| 中断控制 | 始终开启 | 轮询时关闭,结束后开启 |
| 适用场景 | 低速网络、简单设备 | 千兆/万兆、高性能设备 |
迁移其实不复杂。我做过几次从旧 API 到 NAPI 的移植,核心改动就三处:
- 中断处理:把
netif_rx换成napi_schedule,并加上关闭中断的逻辑。 - 收包路径:把收包逻辑从中断上下文移到 poll 函数中。
- 初始化:添加
netif_napi_add和napi_enable调用。
避坑指南:我曾经在迁移时忘记在 poll 函数中调用 napi_complete,结果中断一直关着,网卡彻底“聋”了。调试了一下午才发现。记住:napi_complete 和重新开启中断必须成对出现。
还有一个容易踩的坑:预算(budget)耗尽时不要调用 napi_complete。如果 poll 函数返回的 work_done 等于 budget,说明还有数据没处理完,内核会自动再次调度 poll。这时候调用 napi_complete 会导致中断提前开启,造成数据丢失。
嗯,这里要注意:NAPI 不是万能的。如果你的网卡硬件不支持批量 DMA 描述符,或者每次只能读一个包,那 NAPI 的优势就大打折扣。我建议先确认硬件能力,再决定是否迁移。
总结一下:NAPI 的核心价值在于“用轮询换中断”,适合高吞吐场景。迁移时抓住三个关键点:中断控制、poll 函数、budget 管理。只要这三处不出错,基本就能跑起来。