3. 网络设备驱动基础
在 Linux 内核中,设备驱动并不是“一种统一写法”。字符设备、块设备、网络设备虽然都叫“设备”,但它们在用户态接口、内核注册路径、数据组织方式上差异很大。理解这些差异,是后续做零拷贝、NAPI、中断合并优化的前提。本节围绕三个问题展开:网络设备与字符设备到底差在哪;网络设备驱动如何注册与初始化;open/stop/xmit 等关键回调在数据路径中扮演什么角色。
一、核心原理:字符设备与网络设备的本质差异
字符设备以“文件”为访问入口。用户态通过 /dev/xxx 打开设备,调用 read()、write()、ioctl(),内核最终落到驱动的 file_operations 回调上。它的核心是“字节流 + 文件语义”,有明确的 open/close 生命周期,数据方向由用户态系统调用决定。
网络设备则完全不同。它不以设备文件暴露给用户态,而是以“网络接口”的形式出现在内核网络栈中,例如 eth0、ens33。用户态通过 socket 编程收发数据,数据包经过协议栈后,才由网络设备驱动完成真正的发送与接收。网络设备驱动不实现 read/write,而是实现 net_device_ops 中的回调,例如 ndo_open、ndo_stop、ndo_start_xmit。它的核心是“数据包 + 网络栈语义”,生命周期由接口 up/down 控制,而不是由文件打开关闭控制。
因此,字符设备驱动关注“如何把字节流安全地交给用户态”,网络设备驱动关注“如何把 skb 高效地送进/送出硬件”。这也解释了为什么网络设备优化往往围绕 skb 分配、DMA 映射、NAPI 轮询和中断合并展开,而不是围绕 file_operations 展开。
二、驱动注册与初始化:从 module_init 到 register_netdev
网络设备驱动的初始化通常分两层:模块层和设备层。模块层负责“驱动何时被加载”,设备层负责“网卡如何被内核识别并注册成网络接口”。典型流程如下:
- 编写
module_init()和module_exit(),分别对应加载和卸载入口。 - 在初始化函数中调用
alloc_etherdev_mqs()或alloc_netdev_mqs()分配struct net_device。 - 填充
netdev_ops、ethtool_ops、irq、features等字段。 - 调用
register_netdev()将设备注册进内核网络设备链表,成功后接口名出现在ip link中。 - 卸载时调用
unregister_netdev()和free_netdev()释放资源。
static const struct net_device_ops my_netdev_ops = {
.ndo_open = my_open,
.ndo_stop = my_stop,
.ndo_start_xmit = my_xmit,
.ndo_get_stats64 = my_get_stats64,
};
static int __init my_driver_init(void)
{
struct net_device *dev;
dev = alloc_etherdev(sizeof(struct my_priv));
if (!dev)
return -ENOMEM;
dev->netdev_ops = &my_netdev_ops;
dev->ethtool_ops = &my_ethtool_ops;
if (register_netdev(dev)) {
free_netdev(dev);
return -ENODEV;
}
return 0;
}
module_init(my_driver_init);
ip link show 能看到接口,但此时接口通常处于 DOWN 状态。只有执行 ip link set dev ethX up 或 ifconfig ethX up,才会触发 ndo_open,真正打开硬件收发通道。三、关键回调函数:open、stop、xmit 的职责边界
ndo_open 是“接口 up”时调用的回调。它通常完成:申请中断、初始化 DMA 环形队列、启动收发队列、使能硬件、开启 NAPI 或软中断。它不负责注册 net_device,也不负责分配 skb 缓冲区池,这些应在 probe 或 init 阶段完成。
ndo_stop 是“接口 down”时调用的回调。它负责关闭硬件、释放中断、停止队列、回收 DMA 缓冲区,确保没有残留的 skb 或 DMA 映射。它与 open 必须成对出现,否则会出现“接口 down 了但中断还在触发”的典型故障。
ndo_start_xmit 是发送路径的核心回调。当协议栈决定通过该网卡发送数据包时,会调用它,并传入一个 skb。驱动需要完成:检查队列是否可发送、建立 DMA 映射、填写发送描述符、通知硬件发送。发送完成后,通常由中断或轮询触发清理,释放 skb 和 DMA 映射。
| 回调 | 触发时机 | 核心职责 | 常见误区 |
|---|---|---|---|
ndo_open |
接口 up | 申请中断、初始化队列、使能硬件 | 在 open 中重复分配长期资源 |
ndo_stop |
接口 down | 关闭硬件、释放中断、回收缓冲区 | 只关硬件,不释放 DMA 映射 |
ndo_start_xmit |
协议栈发送 skb | DMA 映射、填描述符、通知硬件 | 在 xmit 中睡眠或长时间阻塞 |
ndo_get_stats64 |
查询统计 | 返回收发计数、丢包、错误 | 统计字段与硬件寄存器不一致 |
四、案例与场景:一个最小网卡驱动的生命周期
假设我们有一块虚拟网卡,驱动加载后执行 register_netdev(),系统出现 veth0。此时执行 ip link set veth0 up,内核调用 ndo_open,驱动申请中断并启动接收队列。随后应用通过 socket 发送数据,协议栈调用 ndo_start_xmit,驱动把 skb 映射为 DMA 地址并写入发送描述符。硬件发送完成后触发中断,驱动在中断下半部清理发送缓冲区。执行 ip link set veth0 down 时,ndo_stop 被调用,驱动关闭中断并释放 DMA 资源。
# 观察接口状态与驱动绑定
ip link show veth0
ethtool -i veth0
ethtool -S veth0
# 触发 open / stop
ip link set veth0 up
ip link set veth0 down
ndo_start_xmit 当成“同步发送完成”。实际上它只负责把 skb 交给硬件,真正的完成由中断或 NAPI 轮询处理。如果在 xmit 中等待发送完成,会严重拖慢协议栈,甚至造成死锁。五、复盘:为后续性能优化建立边界感
理解字符设备与网络设备的差异,能避免把文件语义套到网络路径上;理解注册与初始化流程,能定位“接口不出现”“up 失败”“down 后中断仍触发”等问题;理解 open/stop/xmit 的职责边界,才能判断优化应该放在哪里。后续的零拷贝、NAPI、中断合并,本质上都是在 ndo_start_xmit、接收中断和轮询路径上做文章。如果这些基础回调的边界没有理清,优化很容易变成“改了很多,但不知道改的是哪一层”。
建议在阅读真实驱动时,先画出三个框:注册路径、open/stop 路径、xmit 路径。把每个函数调用、每个资源申请和释放点标出来,再对照本节表格检查是否有遗漏。这样再看性能优化代码,就能清楚知道每一行改动落在哪个回调、影响哪条数据路径。