2、网络设备驱动:struct net_device 结构体深度解析,NAPI与新式轮询机制,驱动注册与数据收发流程
网络设备驱动,说白了就是内核和物理网卡之间的翻译官。你想想看,数据从网线进来,怎么变成 sk_buff 往上送?这中间全靠驱动在干活。今天我们就把它彻底拆开看。
2.1 struct net_device:网络设备的身份证
每个网络设备在内核里都对应一个 struct net_device。这个结构体有多大?我记得第一次看源码时吓了一跳——足足几百行。但别慌,核心字段其实就几类。
| 分类 | 关键字段 | 作用 |
|---|---|---|
| 标识 | name、ifindex、dev_id |
设备名字、索引、物理位置 |
| 配置 | mtu、flags、features |
MTU、IFF_UP 等标志、硬件 offload 能力 |
| 地址 | dev_addr、broadcast |
MAC 地址、广播地址 |
| 统计 | stats |
收发包计数、丢包、错误统计 |
| 操作函数 | netdev_ops |
驱动实现的 open/stop/xmit/ioctl 等 |
| NAPI | napi_list、poll |
NAPI 轮询相关 |
重点:netdev_ops 是驱动和内核的契约。你实现了 ndo_start_xmit,内核就知道怎么把数据交给你发出去。你实现了 ndo_open,内核就知道怎么激活你的硬件。
我个人习惯在调试驱动时,先看 features 字段。为什么?因为很多诡异的问题都是硬件 offload 没对齐导致的。比如你开了 TSO,但驱动没正确处理分段,就会出现莫名其妙的丢包。
2.2 NAPI:告别老式中断风暴
老式驱动怎么收包?来一个中断收一个。流量小的时候还行,一旦大流量进来,CPU 就忙着响应中断,什么事都干不了。这叫 中断风暴。
NAPI 的思路很简单:中断只通知一次,然后切到轮询模式。内核把中断关了,驱动自己循环去收包,直到收完或者达到预算。
2.2.1 NAPI 的核心流程
- 硬件收到包,触发中断。
- 中断处理函数调用
napi_schedule(),把 napi_struct 挂到 CPU 的 poll_list 上。 - 软中断(NET_RX_SOFTIRQ)触发,调用 napi_struct 的
poll方法。 - poll 方法循环收包,每收一个包调用
napi_gro_receive()或netif_receive_skb()。 - 收完或达到预算后,重新开启中断。
小技巧:预算值(budget)很关键。设太大,其他协议栈没时间跑;设太小,频繁切中断。我一般从 64 开始调,看 CPU 占用率和吞吐量的平衡点。
2.2.2 NAPI 的注册
// 驱动初始化时调用
netif_napi_add(netdev, &priv->napi, my_poll, 64);
// 驱动卸载时调用
netif_napi_del(&priv->napi);
这里 my_poll 是你自己实现的收包函数。原型长这样:
int my_poll(struct napi_struct *napi, int budget)
{
struct my_priv *priv = container_of(napi, struct my_priv, napi);
int work_done = 0;
while (work_done < budget) {
// 从硬件 FIFO 取包
struct sk_buff *skb = my_hw_receive(priv);
if (!skb)
break;
// 送协议栈
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
// 收完了,重新开中断
napi_complete_done(napi, work_done);
my_hw_enable_irq(priv);
}
return work_done;
}
注意:千万别在 poll 里做耗时操作。我曾经见过有人在 poll 里加了个 printk,结果 10G 网卡直接掉到百兆水平。NAPI 的核心理念就是快进快出。
2.3 驱动注册:从模块到设备
驱动怎么告诉内核「我来了」?靠 module_init 和 platform_driver_register(或者 PCI 的 pci_register_driver)。
2.3.1 典型的驱动初始化流程
- 探测设备:probe 函数被调用,分配 net_device。
- 分配 net_device:
alloc_etherdev(sizeof(struct my_priv))。 - 设置操作函数:填充
netdev_ops、ethtool_ops。 - 注册 NAPI:
netif_napi_add。 - 注册设备:
register_netdev(netdev)。
static int my_probe(struct platform_device *pdev)
{
struct net_device *ndev;
struct my_priv *priv;
// 1. 分配 net_device + 私有数据
ndev = alloc_etherdev(sizeof(struct my_priv));
if (!ndev)
return -ENOMEM;
priv = netdev_priv(ndev);
priv->ndev = ndev;
// 2. 设置操作函数
ndev->netdev_ops = &my_netdev_ops;
ndev->ethtool_ops = &my_ethtool_ops;
// 3. 注册 NAPI
netif_napi_add(ndev, &priv->napi, my_poll, 64);
// 4. 注册到内核
register_netdev(ndev);
return 0;
}
关键点:alloc_etherdev 分配的是 struct net_device + 私有数据区。私有数据区用 netdev_priv() 访问。这个设计很巧妙——设备本身和驱动私有数据在同一个内存块里,缓存友好。
2.4 数据收发流程:从硬件到协议栈
好了,驱动注册完了,设备起来了。数据怎么走?
2.4.1 发送流程
- 上层调用
dev_queue_xmit(skb)。 - 内核经过流量控制(TC),最终调用
ndo_start_xmit。 - 驱动把 skb 的数据拷贝到硬件 DMA 缓冲区。
- 硬件自动发出去。
- 发送完成中断触发,驱动释放 skb。
static netdev_tx_t my_start_xmit(struct sk_buff *skb,
struct net_device *ndev)
{
struct my_priv *priv = netdev_priv(ndev);
// 把数据写到硬件描述符
my_hw_write_tx_desc(priv, skb->data, skb->len);
// 通知硬件发送
my_hw_kick(priv);
// 统计
ndev->stats.tx_packets++;
ndev->stats.tx_bytes += skb->len;
// 释放 skb(或者等中断再释放)
dev_kfree_skb(skb);
return NETDEV_TX_OK;
}
经验之谈:发送时别急着 dev_kfree_skb。如果硬件支持 DMA 回读,可以等发送完成中断再释放。这样能减少内存拷贝,但要注意 skb 不能被上层复用。
2.4.2 接收流程
- 硬件收到包,写入 DMA 缓冲区。
- 硬件触发中断(或 NAPI 轮询)。
- 驱动从 DMA 缓冲区拷贝数据到 skb。
- 调用
napi_gro_receive或netif_receive_skb。 - 协议栈处理,最终送到 socket。
接收路径上有个关键优化——GRO(Generic Receive Offload)。它把多个小包合并成大包再送协议栈,减少协议栈处理次数。我调过一个问题:某款网卡 GRO 没开,UDP 小包场景下 CPU 直接跑满。开了 GRO 后,CPU 降了 40%。
2.5 整体架构图
下面这张图把驱动、NAPI、协议栈的关系画清楚了:
2.6 避坑指南
做驱动开发这些年,踩过的坑不少。挑几个典型的说说:
- 中断上下文不能睡眠:我曾经在中断处理里调了
kmalloc(GFP_KERNEL),结果系统直接死锁。记住,中断里只能用GFP_ATOMIC。 - NAPI 预算别设太大:设 128 以上,其他软中断可能饿死。我一般 64 起步,压测时再调。
- net_device 的 features 要对齐硬件能力:比如硬件不支持 TSO,你却在 features 里开了,内核会帮你做分段,但性能会下降。不如关掉让协议栈处理。
- DMA 缓冲区要 cache 对齐:不对齐的话,在某些架构上会导致 cache 一致性错误,数据错乱。我吃过一次亏,排查了两天才发现是 DMA 地址没对齐。
重要提醒:注册 net_device 之前,一定要确保所有字段都初始化好了。尤其是 netdev_ops,如果某个函数指针是 NULL,内核调用时直接 oops。我习惯在 probe 最后加一个 BUG_ON(!ndev->netdev_ops) 做防御。
驱动开发说难也难,说简单也简单。核心就是理解 net_device 这个结构体,把 NAPI 的流程跑通,数据收发路径理清楚。剩下的就是和硬件寄存器打交道了——那又是另一门手艺活。