Linux内核网络基础:网络协议栈分层、sk_buff结构、net_device结构、网络设备注册与注销的基本流程

网络设备驱动是Linux内核中连接硬件与协议栈的桥梁。要编写出健壮、高效的驱动,首先必须理解内核网络子系统的核心数据结构与运行机制。本章将围绕四个关键主题展开:网络协议栈的分层模型、承载数据包的核心结构sk_buff、描述网络设备的net_device结构,以及设备从注册到注销的完整生命周期。理解这些基础,是后续编写具体驱动(如DMA、中断、NAPI)的必备前提。

Linux网络设备驱动核心结构关系图 网络协议栈分层 应用层 (Socket) 传输层 (TCP/UDP) 网络层 (IP/ICMP) 链路层 (驱动/硬头) 数据流 sk_buff 核心字段 head / data tail / end len / truesize protocol dev / sk cb[] 控制块 next / prev 链表指针 绑定 net_device 关键成员 name (eth0) netdev_ops flags / features mtu / addr_len dev_addr irq / base_addr ethtool_ops / watchdog 网络设备生命周期流程 1. 分配 net_device 2. 初始化/填充 3. register_netdev 4. 内核管理/运行 5. unregister_netdev alloc_netdev → 设置 ops/私有数据 → register_netdev → 内核创建接口 → 用户空间可见 注销时:unregister_netdev → 同步等待RCU → free_netdev 释放内存

2.1 网络协议栈分层模型与驱动的位置

Linux内核网络协议栈严格遵循TCP/IP分层思想,但为了兼顾性能与灵活性,其实现并非完全隔离。从驱动开发视角看,我们最关心的是链路层与网络层之间的接口。协议栈自顶向下依次为:系统调用接口层(Socket)、协议无关层、具体协议层(TCP/UDP/IP)、邻居子系统、网络设备层以及设备驱动层。驱动开发者主要工作在网络设备层与设备驱动层,通过net_device结构向上与协议栈交互,通过硬件操作函数向下控制物理网卡。

数据包的收发路径是理解分层的关键。发送时,协议栈构造sk_buff,逐层添加头部(TCP头→IP头→链路层头),最终通过dev_queue_xmit()将数据交给驱动;接收时,驱动在中断或NAPI上下文中构造sk_buff,填充数据后调用netif_rx()或napi_gro_receive()上送协议栈。驱动开发者必须清楚:驱动不关心TCP或IP的具体逻辑,但必须正确设置sk_buff中的协议字段、设备指针和长度信息,否则协议栈无法正确解析。

核心原理:协议栈分层为驱动提供了清晰的职责边界。驱动只需保证:① 正确收发以太网帧(或其它链路层帧);② 正确填充sk_buff的protocol字段(如ETH_P_IP);③ 管理好net_device的状态与统计信息。其余TCP/IP处理完全由内核完成。

2.2 sk_buff:数据包的核心载体

sk_buff(Socket Kernel Buffer)是Linux网络子系统中最核心的数据结构,它贯穿协议栈的每一层。一个sk_buff实例代表一个正在处理或传输的数据包。其设计精妙之处在于通过head、data、tail、end四个指针管理缓冲区,支持协议栈各层在同一个缓冲区上高效地添加或移除协议头,而无需频繁拷贝数据。

驱动开发中需要重点关注的sk_buff成员包括:

  • head / end:指向整个数据缓冲区的起始和结束位置,是内存管理的边界。
  • data / tail:指向当前协议层实际数据区域的起始和结束位置。驱动接收数据时,需要将data指针指向帧头(如以太网头),并设置tail指向帧尾。
  • len:当前数据长度,等于tail - data。驱动必须正确设置此值。
  • protocol:指示上层协议类型,接收时由驱动通过eth_type_trans()函数设置。
  • dev:接收或发送该数据包的网络设备指针。
  • cb[]:控制块缓冲区,供协议栈各层临时存储私有数据(如TCP重传信息),驱动一般不用。
  • next / prev:用于将多个sk_buff组成链表(如发送队列、接收队列)。
指针/字段 作用 驱动中的典型操作
head / end 缓冲区边界 分配时由alloc_skb()设置,驱动不修改
data / tail 当前数据区间 接收时用skb_put()扩展数据区;发送时用skb_push()添加头部
len 数据长度 接收后必须等于实际帧长(含FCS则需减去)
protocol 上层协议标识 调用eth_type_trans(skb, dev)自动设置
ip_summed 校验和状态 若硬件已计算校验和,置为CHECKSUM_UNNECESSARY
实战经验:在接收路径中,驱动通常先调用netdev_alloc_skb()分配带硬件头预留空间的sk_buff,然后通过DMA将数据填入缓冲区,最后用skb_put()调整长度并上送。切勿直接使用kmalloc分配缓冲区,因为sk_buff需要与协议栈的内存池和缓存对齐机制配合。

2.3 net_device:网络设备的抽象

net_device是内核中描述一个网络接口(如eth0、wlan0)的巨型结构,包含数百个字段。驱动开发者的核心任务之一就是正确初始化该结构并实现其操作函数集net_device_ops。该结构体不仅包含硬件信息(如MAC地址、IRQ、I/O地址),还包含设备状态(up/down)、统计信息(rx_packets、tx_packets)、以及各种能力标志(如NETIF_F_HW_CSUM表示硬件校验和)。

驱动需要重点初始化的字段包括:

  • name:设备名称,如"eth%d"由内核自动编号。
  • netdev_ops:指向struct net_device_ops的指针,包含ndo_open、ndo_stop、ndo_start_xmit、ndo_set_mac_address等关键回调。
  • ethtool_ops:实现ethtool工具支持,用于查询/设置链路速度、寄存器等。
  • mtu:最大传输单元,默认1500。
  • dev_addr:MAC地址缓冲区。
  • features:设备能力标志,如NETIF_F_SG(分散聚合)、NETIF_F_TSO(TCP分段卸载)。
  • priv_destructor:设备释放时的回调函数。
常见误区:很多初学者混淆net_device与PCI设备结构pci_dev。前者是网络协议栈的抽象,后者是总线设备的抽象。驱动中通常通过netdev_priv()获取自定义私有数据区,并在私有数据中保存pci_dev指针、寄存器映射地址、DMA描述符等硬件信息。两者通过netdev_priv()和pci_set_drvdata()建立关联。

2.4 网络设备注册与注销流程

设备驱动的加载与卸载过程,对应着网络设备的注册与注销。以PCI网卡驱动为例,标准流程如下:

  1. 探测阶段(probe):PCI子系统调用驱动的probe函数。在此函数中,驱动首先启用PCI设备(pci_enable_device()),请求I/O或内存资源,然后调用alloc_netdev()分配net_device结构及其私有数据。
  2. 初始化阶段:设置net_device的各个字段,包括netdev_ops、ethtool_ops、MAC地址、MTU等。同时初始化硬件:复位芯片、配置DMA描述符环、注册中断处理函数。
  3. 注册阶段:调用register_netdev()将设备注册到内核网络子系统。此函数会创建设备节点(如eth0),并触发协议栈的初始化(如分配IP地址所需的底层结构)。注册成功后,设备对用户空间可见。
  4. 运行阶段:设备正常收发数据。驱动通过netif_start_queue()开启发送队列,通过NAPI或中断接收数据。
  5. 注销阶段(remove):调用unregister_netdev()从内核中移除设备,然后调用free_netdev()释放net_device结构。在注销前,必须确保:停止发送队列(netif_stop_queue())、禁用NAPI(napi_disable())、释放中断、释放DMA缓冲区。
关键流程清单(以PCI驱动为例):
probe():
  pci_enable_device(pdev)
  pci_request_regions(pdev)
  alloc_netdev(sizeof(priv), "eth%d", NET_NAME_UNKNOWN, ether_setup)
  netdev->netdev_ops = &my_netdev_ops
  netdev->ethtool_ops = &my_ethtool_ops
  // 初始化硬件:复位、DMA环、中断
  register_netdev(netdev)

remove():
  unregister_netdev(netdev)
  // 释放中断、DMA资源、取消映射
  free_netdev(netdev)
  pci_release_regions(pdev)
  pci_disable_device(pdev)

2.5 案例拆解:从零注册一个虚拟网卡驱动

为了加深理解,我们拆解一个简单的虚拟网卡驱动注册流程。该驱动不关联真实硬件,仅演示net_device的注册与注销。假设我们编写一个名为vnet的驱动,其probe函数核心逻辑如下:

static int vnet_probe(struct platform_device *pdev)
{
    struct net_device *ndev;
    struct vnet_priv *priv;

    // 1. 分配 net_device,私有数据大小为 sizeof(struct vnet_priv)
    ndev = alloc_netdev(sizeof(struct vnet_priv), "vnet%d", NET_NAME_UNKNOWN, vnet_setup);
    if (!ndev)
        return -ENOMEM;

    // 2. 设置操作函数集
    ndev->netdev_ops = &vnet_netdev_ops;
    ndev->ethtool_ops = &vnet_ethtool_ops;

    // 3. 设置MAC地址(示例:随机生成)
    eth_hw_addr_random(ndev);

    // 4. 设置特性:支持校验和卸载(虚拟设备模拟)
    ndev->features |= NETIF_F_HW_CSUM | NETIF_F_SG;

    // 5. 获取私有数据并初始化
    priv = netdev_priv(ndev);
    priv->ndev = ndev;
    spin_lock_init(&priv->lock);

    // 6. 注册设备
    int ret = register_netdev(ndev);
    if (ret) {
        free_netdev(ndev);
        return ret;
    }

    platform_set_drvdata(pdev, ndev);
    netdev_info(ndev, "vnet device registered\n");
    return 0;
}

对应的remove函数:

static int vnet_remove(struct platform_device *pdev)
{
    struct net_device *ndev = platform_get_drvdata(pdev);
    unregister_netdev(ndev);
    free_netdev(ndev);
    return 0;
}

在这个案例中,我们注意到几个关键点:① alloc_netdev的最后一个参数是setup函数,用于设置默认的以太网参数(如ether_setup);② 注册前必须完成所有必要的字段初始化,因为register_netdev之后内核可能立即调用ndo_open;③ 注销时先unregister再free,顺序不能颠倒。

复盘经验:在实际项目中,我们曾遇到一个驱动在register_netdev后立即崩溃的问题。原因是ndo_open回调中访问了尚未初始化的DMA描述符。解决方案是在probe阶段完成所有硬件资源的分配与初始化,确保设备注册后随时可以被打开。此外,unregister_netdev会同步等待RCU,确保没有正在进行的发送/接收操作,因此不能在原子上下文中调用。

2.6 常见误区与总结

在编写网络设备驱动时,开发者常犯以下错误:

  • 错误使用sk_buff指针:例如在DMA完成后忘记调用skb_put()调整长度,导致协议栈读取越界或长度为零。
  • 忽略net_device的features标志:如果硬件不支持某种卸载功能却声明支持,会导致校验和错误或TSO异常。
  • 注册顺序错误:在register_netdev之后才初始化硬件,可能导致设备一注册就被打开而崩溃。
  • 注销时资源泄漏:忘记释放中断、DMA缓冲或取消映射,导致内存泄漏或系统崩溃。
  • 混淆netif_start_queue与netif_wake_queue:前者用于初始启动,后者用于发送超时后恢复队列。
风险提示:在SMP(对称多处理)系统上,net_device的统计字段(如stats)会被多个CPU并发更新。驱动应使用dev_get_stats()或原子操作,避免数据竞争。同样,sk_buff的链表操作必须加锁或使用skb_queue_head/skb_queue_tail等安全接口。