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 展开。

字符设备驱动 网络设备驱动 用户态:open/read/write/ioctl VFS → file_operations 驱动回调:open/read/write/release 硬件寄存器 / 字符流 用户态:socket 收发 内核网络栈:TCP/IP → dev_queue_xmit net_device_ops:open/stop/xmit DMA / 环形队列 / 中断 / NAPI

二、驱动注册与初始化:从 module_init 到 register_netdev

网络设备驱动的初始化通常分两层:模块层和设备层。模块层负责“驱动何时被加载”,设备层负责“网卡如何被内核识别并注册成网络接口”。典型流程如下:

  1. 编写 module_init() 和 module_exit(),分别对应加载和卸载入口。
  2. 在初始化函数中调用 alloc_etherdev_mqs() 或 alloc_netdev_mqs() 分配 struct net_device。
  3. 填充 netdev_ops、ethtool_ops、irq、features 等字段。
  4. 调用 register_netdev() 将设备注册进内核网络设备链表,成功后接口名出现在 ip link 中。
  5. 卸载时调用 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 路径。把每个函数调用、每个资源申请和释放点标出来,再对照本节表格检查是否有遗漏。这样再看性能优化代码,就能清楚知道每一行改动落在哪个回调、影响哪条数据路径。