Linux网络设备驱动开发实战30讲 - 第4节
第四讲:网络设备驱动框架解析——net_device_ops、关键字段与设备生命周期
网络设备驱动是Linux内核中结构最复杂、抽象层次最深的驱动类型之一。与字符设备驱动(cdev)和块设备驱动(gendisk)不同,网络设备驱动并不直接暴露设备节点,而是通过内核网络协议栈与用户空间的socket接口交互。驱动开发者面对的核心对象是struct net_device,而驱动行为的核心入口则是struct net_device_ops。本讲将围绕这两个核心结构体,深入解析网络设备驱动的框架组成、初始化注册流程以及卸载时的资源回收路径,帮助读者建立从“硬件抽象”到“内核挂接”的完整认知。
上图展示了网络设备驱动的整体框架。从用户空间的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()将设备注册到内核网络子系统。该函数执行以下关键步骤:
- 检查设备名称是否合法且未被占用。
- 将
net_device加入全局设备链表。 - 创建sysfs属性文件(如/sys/class/net/eth0/)。
- 通知协议栈新设备上线,触发NETDEV_REGISTER事件。
- 如果设备状态为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缓冲区、取消映射、释放锁等私有资源。
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中释放,否则会造成内存泄漏。
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)入手,逐步过渡到真实硬件,这样能够更清晰地理解框架的边界与内核的调用时机。