Linux网络设备驱动开发实战30讲 - 第4节

第四讲:网络设备驱动框架解析——net_device_ops、关键字段与设备生命周期

网络设备驱动是Linux内核中结构最复杂、抽象层次最深的驱动类型之一。与字符设备驱动(cdev)和块设备驱动(gendisk)不同,网络设备驱动并不直接暴露设备节点,而是通过内核网络协议栈与用户空间的socket接口交互。驱动开发者面对的核心对象是struct net_device,而驱动行为的核心入口则是struct net_device_ops。本讲将围绕这两个核心结构体,深入解析网络设备驱动的框架组成、初始化注册流程以及卸载时的资源回收路径,帮助读者建立从“硬件抽象”到“内核挂接”的完整认知。

Linux网络设备驱动框架总览 用户空间 socket / ioctl / netlink 内核网络协议栈 TCP/IP · 邻居子系统 流量控制 · 数据包调度 struct net_device_ops • ndo_open / ndo_stop • ndo_start_xmit • ndo_set_rx_mode • ndo_set_mac_address • ndo_change_mtu / ndo_do_ioctl • ndo_get_stats64 / ndo_tx_timeout struct net_device • name[IFNAMSIZ] · irq · base_addr • netdev_ops · header_ops · ethtool_ops • mtu · flags · features · priv_flags • stats · dev_addr · broadcast • priv_destructor · watchdog_timeo • state · reg_state · needs_free_netdev 绑定 调用 私有数据区 (priv_data) 硬件寄存器映射 · 锁 · 队列 · 统计信息 通过 netdev_priv() 访问 硬件 (MAC/PHY/控制器) DMA描述符 · 中断控制器 · MDIO总线 寄存器组 · FIFO · 电源管理单元 生命周期管理 alloc_netdev() register_netdev() unregister_netdev() free_netdev() 关键设计要点 ① net_device 是“设备”的通用抽象,net_device_ops 是“驱动行为”的抽象,二者通过 netdev_ops 指针绑定。 ② 私有数据区与 net_device 在同一块内存中分配,一次分配、统一释放,避免内存碎片。 ③ 注册/注销必须遵循内核提供的生命周期接口,不能直接操作内部链表。

上图展示了网络设备驱动的整体框架。从用户空间的socket调用,到内核协议栈的处理,再到驱动层net_device_ops回调函数的执行,最终作用于硬件设备,整个链路清晰而严谨。下面我们将分模块深入解析。

一、核心原理:net_device_ops与net_device的分工协作

1.1 net_device_ops:驱动行为的函数指针集合

struct net_device_ops是网络设备驱动的“操作手册”,它定义了一组函数指针,内核协议栈通过这些指针调用驱动提供的具体实现。该结构体在include/linux/netdevice.h中定义,包含数十个回调函数,但驱动开发者只需实现硬件相关的必要接口。最核心的成员包括:

  • ndo_open:打开设备,分配DMA描述符、注册中断、启动硬件发送/接收队列。
  • ndo_stop:停止设备,释放中断、停止队列、关闭硬件。
  • ndo_start_xmit:发送数据包,将skb挂入硬件发送队列并触发DMA传输。
  • ndo_set_rx_mode:设置接收模式(单播/多播/混杂模式),通常与硬件过滤表相关。
  • ndo_set_mac_address:修改MAC地址,需同步更新硬件寄存器。
  • ndo_change_mtu:修改MTU值,需检查硬件支持范围并重新配置。
  • ndo_do_ioctl:处理私有ioctl命令,用于扩展驱动特有功能。
  • ndo_get_stats64:获取统计信息,返回收发字节数、包数、错误数等。
  • ndo_tx_timeout:发送超时处理,通常用于复位硬件或重置队列。

这些回调函数由内核在特定时机调用。例如,当用户执行ifconfig eth0 up时,内核会调用ndo_open;当协议栈需要发送数据时,会调用ndo_start_xmit。驱动开发者需要确保这些函数的实现是线程安全的,并且能够在中断上下文或软中断上下文中正确执行。

1.2 net_device:设备属性的通用容器

struct net_device是内核中最大的结构体之一,它描述了网络设备的通用属性。该结构体不仅包含硬件相关信息,还包含协议栈运行所需的各类状态。关键字段分类如下:

字段类别 典型字段 作用说明
标识字段 name[IFNAMSIZ] 设备名称,如"eth0"、"wlan0"
硬件信息 irq、base_addr、mem_start 中断号、I/O基地址、内存映射起始地址
操作集合 netdev_ops、header_ops、ethtool_ops 驱动行为、头部构造、ethtool扩展操作
协议参数 mtu、flags、features 最大传输单元、设备标志、特性位图
地址信息 dev_addr、broadcast MAC地址、广播地址
统计信息 stats(struct net_device_stats) 收发统计,可通过get_stats64获取
生命周期 reg_state、needs_free_netdev 注册状态、是否需要自动释放
私有数据 priv_destructor、priv_flags 析构函数、私有标志位

值得注意的是,net_device结构体本身并不包含硬件寄存器的映射地址或DMA描述符等具体硬件资源。这些资源通常存放在“私有数据区”(private data area)中,该区域紧跟在net_device结构体之后分配,通过netdev_priv()函数访问。这种设计使得net_device保持通用性,而私有数据区则承载驱动特有的硬件上下文。

二、判断方法与操作流程:设备初始化与注册

2.1 alloc_netdev:分配与初始化

设备驱动的第一步是分配net_device结构体。内核提供了alloc_netdev()函数,其原型为:

struct net_device *alloc_netdev(
    int sizeof_priv,        // 私有数据区大小(字节)
    const char *name,       // 设备名称模板,如"eth%d"
    unsigned char name_assign_type, // 命名方式
    void (*setup)(struct net_device *ndev) // 初始化回调
);

该函数会一次性分配net_device结构体和私有数据区的连续内存,并调用setup回调进行基础初始化。在setup回调中,驱动需要设置:

  • ndev->netdev_ops:绑定操作函数集。
  • ndev->ethtool_ops:绑定ethtool扩展操作。
  • ndev->mtu:设置默认MTU值。
  • ndev->flags:设置IFF_BROADCAST等标志。
  • ndev->features:声明硬件支持的特性(如NETIF_F_SG、NETIF_F_TSO)。
  • ndev->priv_destructor:设置析构函数,用于释放驱动私有资源。

对于以太网设备,内核提供了便捷的alloc_etherdev()函数,它内部调用alloc_netdev并自动填充以太网相关的默认设置(如以太网头部操作、广播地址等)。

2.2 register_netdev:挂接内核

分配并初始化完成后,驱动调用register_netdev()将设备注册到内核网络子系统。该函数执行以下关键步骤:

  1. 检查设备名称是否合法且未被占用。
  2. 将net_device加入全局设备链表。
  3. 创建sysfs属性文件(如/sys/class/net/eth0/)。
  4. 通知协议栈新设备上线,触发NETDEV_REGISTER事件。
  5. 如果设备状态为UP,则调用ndo_open启动设备。

注册成功后,用户可以通过ifconfig -a或ip link show看到该设备。此时设备处于“未激活”状态(没有IP地址),但已经可以被协议栈管理。

2.3 卸载流程:unregister与free

设备卸载是注册的逆过程,但需要注意资源释放的顺序。标准流程如下:

unregister_netdev(ndev);  // 1. 从内核注销设备
// 2. 等待所有引用计数归零(内核自动处理)
free_netdev(ndev);        // 3. 释放net_device和私有数据区

unregister_netdev()会执行以下操作:

  • 调用ndo_stop停止设备(如果设备处于UP状态)。
  • 从全局链表移除设备。
  • 删除sysfs属性文件。
  • 通知协议栈NETDEV_UNREGISTER事件。
  • 等待所有引用该设备的用户(如socket绑定)释放引用。

free_netdev()则负责最终的内存释放。如果驱动设置了priv_destructor,该函数会在释放内存前调用它,驱动可以在其中释放DMA缓冲区、取消映射、释放锁等私有资源。

风险提示:在卸载流程中,驱动必须确保没有未完成的数据包发送或接收操作。如果硬件仍在DMA传输中,直接释放内存会导致内核崩溃。正确的做法是在ndo_stop中等待所有DMA操作完成,并禁用硬件中断。

三、案例拆解:虚拟以太网驱动veth的初始化与注册

为了更直观地理解上述流程,我们以内核自带的虚拟以太网驱动veth为例进行拆解。veth驱动创建一对虚拟网卡,数据包从一个设备进入,从另一个设备出来,常用于容器网络命名空间隔离。

3.1 场景与约束

在Docker或Kubernetes环境中,每个容器拥有独立的网络命名空间,需要通过veth对将容器与宿主机桥接。veth驱动必须支持动态创建和销毁,且不能依赖具体硬件。

3.2 初始化流程分析

veth驱动的初始化入口是veth_init(),它调用rtnl_link_register()注册链路操作。当用户执行ip link add veth0 type veth peer name veth1时,内核调用veth_newlink(),其核心流程如下:

// 1. 分配第一个net_device
dev = alloc_netdev(sizeof(struct veth_priv), "veth%d", NET_NAME_ENUM, veth_setup);
// 2. 分配第二个net_device
peer = alloc_netdev(sizeof(struct veth_priv), "veth%d", NET_NAME_ENUM, veth_setup);
// 3. 设置私有数据中的peer指针
priv = netdev_priv(dev);
priv->peer = peer;
// 4. 注册两个设备
register_netdevice(dev);
register_netdevice(peer);

在veth_setup()回调中,驱动设置了netdev_ops为veth_netdev_ops,并设置了ethtool_ops。关键的是,veth_netdev_ops中的ndo_start_xmit实现为veth_xmit(),该函数将skb直接传递给对端设备的接收队列,实现了数据的“虚拟转发”。

3.3 卸载流程与注意事项

当用户执行ip link del veth0时,内核调用veth_dellink()。该函数需要同时注销两个设备:

unregister_netdevice(dev);
unregister_netdevice(peer);
// 内核会在所有引用释放后调用free_netdev

这里有一个关键细节:veth驱动没有设置priv_destructor,因为私有数据区只包含一个peer指针,没有需要手动释放的资源。但如果驱动在私有数据区分配了DMA缓冲区或内核内存,则必须在priv_destructor中释放,否则会造成内存泄漏。

复盘经验:veth驱动的设计展示了“最小化”原则——只实现必要的回调函数,将资源管理简化到极致。对于真实硬件驱动,建议在ndo_init(或setup)中分配所有资源,在priv_destructor中释放所有资源,确保错误路径上的资源回收一致性。

四、常见误区与总结复盘

4.1 误区一:混淆alloc_netdev与register_netdev的职责

初学者常将两者混为一谈。实际上,alloc_netdev只是分配内存并做基础初始化,此时设备对内核协议栈不可见;register_netdev才将设备正式挂接。如果只分配不注册,设备无法使用;如果注册后不分配(不可能),内核会崩溃。正确的顺序必须是:先alloc,再register,最后unregister+free。

4.2 误区二:在ndo_open中分配DMA缓冲区,但忘记在ndo_stop中释放

这会导致设备反复up/down时内存泄漏。正确的做法是:在ndo_open中分配,在ndo_stop中释放,并在priv_destructor中做最终兜底释放(防止设备从未open但直接卸载的情况)。

4.3 误区三:忽略net_device的引用计数

内核通过引用计数管理net_device的生命周期。当用户打开socket并绑定到该设备时,引用计数会增加。驱动不能假设unregister_netdev返回后设备内存立即释放,必须等待free_netdev被调用。驱动可以通过dev_hold()/dev_put()管理自己的引用,但必须确保配对使用。

4.4 总结:网络设备驱动框架的关键设计原则

原则 说明 实践建议
单一职责 net_device描述“是什么”,net_device_ops描述“能做什么” 不要在net_device中存放硬件寄存器地址,使用私有数据区
生命周期对称 alloc对应free,register对应unregister,open对应stop 在错误路径中也要保证对称性,使用goto统一出口
延迟释放 引用计数归零后才真正释放内存 不要在unregister后立即访问设备字段
中断安全 ndo_start_xmit可能在软中断上下文调用 使用spin_lock或NAPI机制保护发送队列

网络设备驱动框架的设计体现了Linux内核“通用抽象+私有扩展”的经典思想。理解net_device与net_device_ops的分工,掌握alloc_netdev/register_netdev/unregister_netdev/free_netdev的生命周期管理,是编写正确、健壮的网络设备驱动的基础。在实际开发中,建议从简单的虚拟设备驱动(如veth、dummy)入手,逐步过渡到真实硬件,这样能够更清晰地理解框架的边界与内核的调用时机。