Linux内核与网络子系统概览
在深入网卡驱动开发之前,必须先建立对Linux内核整体架构和网络子系统工作方式的基本认知。网卡驱动并非孤立运行的程序,它需要与内核的设备模型、内存管理、中断系统以及协议栈各层紧密协作。本章将围绕内核模块机制、设备驱动模型(device/driver/bus)、网络协议栈分层以及sk_buff核心数据结构展开,为后续编写和理解网卡驱动打下坚实的知识基础。
2.1 内核模块机制:动态加载与生命周期管理
Linux内核采用模块化设计,允许在运行时动态加载和卸载功能代码。网卡驱动通常以内核模块(.ko文件)的形式存在,通过insmod/modprobe命令加载,通过rmmod命令卸载。理解模块机制是编写驱动的前提,它决定了驱动代码的组织方式、编译方式以及与内核的交互方式。
核心原理:模块的加载与卸载流程
一个标准的内核模块必须实现两个关键入口函数:module_init()和module_exit()。当模块被加载时,内核会执行module_init指定的初始化函数,完成硬件探测、资源申请、设备注册等操作;当模块被卸载时,module_exit指定的清理函数会被调用,负责释放资源、注销设备。模块还通过MODULE_LICENSE、MODULE_AUTHOR、MODULE_DESCRIPTION等宏声明自身属性,其中MODULE_LICENSE尤为重要——如果声明为"GPL",则可以使用内核导出的GPL-only符号。
// 网卡驱动模块的基本框架
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/netdevice.h>
static int __init mynic_init(void)
{
// 1. 探测PCI设备或平台设备
// 2. 分配net_device结构体
// 3. 注册网络设备
// 4. 申请DMA缓冲区、注册中断
pr_info("mynic: driver initialized\n");
return 0;
}
static void __exit mynic_exit(void)
{
// 1. 注销网络设备
// 2. 释放DMA缓冲区
// 3. 释放中断
// 4. 释放net_device
pr_info("mynic: driver removed\n");
}
module_init(mynic_init);
module_exit(mynic_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple virtual NIC driver");
模块依赖与符号导出
模块之间可以存在依赖关系。例如,一个网卡驱动可能依赖内核的CRC32校验模块或加密模块。使用modprobe命令时,它会自动解析modules.dep文件中的依赖关系,先加载被依赖的模块。驱动开发者可以使用EXPORT_SYMBOL()导出自己的函数供其他模块使用,但需要注意导出符号会增加内核接口的维护成本。
| 模块操作命令 | 功能说明 | 底层系统调用 |
|---|---|---|
| insmod mynic.ko | 直接加载模块,不解析依赖 | init_module() |
| modprobe mynic | 加载模块并自动解析依赖 | init_module() + 依赖检查 |
| rmmod mynic | 卸载模块,调用exit函数 | delete_module() |
| modinfo mynic.ko | 查看模块信息(作者、许可、参数) | 读取文件元数据 |
dmesg -c清空内核日志,加载后立即执行dmesg查看输出,可以快速判断初始化流程执行到哪一步。
实战场景:模块参数传递
网卡驱动经常需要支持模块参数,例如设置中断聚合阈值、调整环形队列大小等。使用module_param()宏可以轻松实现这一功能。参数可以在加载时通过insmod mynic.ko irq_throttle=500传递,也可以在/sys/module/mynic/parameters/目录下动态修改(如果参数权限允许)。
static int irq_throttle = 1000; // 默认中断聚合阈值
module_param(irq_throttle, int, 0644);
MODULE_PARM_DESC(irq_throttle, "IRQ throttle threshold (packets per interrupt)");
常见误区与复盘
- 误区1:忘记使用
__init和__exit宏。这两个宏会将初始化函数放入特殊的内存段,在模块加载完成后释放,节省内存。不使用它们不会导致错误,但会浪费宝贵的内核内存。 - 误区2:在exit函数中未正确释放所有资源。如果中断或DMA缓冲区未释放,可能导致内核崩溃或内存泄漏。建议在exit函数中按初始化相反的顺序释放资源。
- 误区3:模块卸载时仍有进程引用。如果模块注册了网络设备且该设备处于up状态,卸载会失败。应在exit中先执行
netif_stop_queue()和unregister_netdev()。
2.2 设备驱动模型:device/driver/bus 三角关系
Linux内核从2.6版本开始引入了统一的设备驱动模型,核心思想是将硬件设备、驱动程序和总线抽象为三个独立的对象,通过匹配机制将它们关联起来。网卡驱动开发中,理解这一模型至关重要,因为它决定了驱动如何找到设备、如何与设备通信、如何向用户空间暴露设备信息。
核心原理:三个核心结构体
设备驱动模型由三个核心结构体支撑:struct device代表一个物理或虚拟设备,包含设备ID、电源管理状态、NUMA节点等信息;struct device_driver代表一个驱动程序,包含probe、remove、shutdown等回调函数;struct bus_type代表一种总线类型,定义了设备与驱动如何匹配、如何枚举设备等规则。以PCI网卡为例,其总线类型为pci_bus_type,匹配规则是比对PCI设备的vendor_id和device_id与驱动声明的ID表。
// PCI网卡驱动的ID匹配表
static const struct pci_device_id mynic_pci_tbl[] = {
{ PCI_DEVICE(0x10EC, 0x8168) }, // Realtek RTL8168
{ PCI_DEVICE(0x8086, 0x153A) }, // Intel I217-V
{ 0, }
};
MODULE_DEVICE_TABLE(pci, mynic_pci_tbl);
// probe函数:设备与驱动匹配成功后调用
static int mynic_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
// 1. 启用PCI设备(pci_enable_device)
// 2. 请求MMIO/I/O资源(pci_request_regions)
// 3. 设置DMA掩码(dma_set_mask_and_coherent)
// 4. 分配net_device并初始化
// 5. 注册网络设备(register_netdev)
return 0;
}
static struct pci_driver mynic_pci_driver = {
.name = "mynic",
.id_table = mynic_pci_tbl,
.probe = mynic_probe,
.remove = mynic_remove,
};
匹配流程与sysfs接口
当内核检测到新的PCI设备时,会遍历该总线上的所有驱动,调用驱动的id_table匹配函数。匹配成功后,内核调用驱动的probe函数,完成设备初始化。这一过程通过sysfs文件系统暴露给用户空间:/sys/bus/pci/devices/目录下列出所有PCI设备,/sys/bus/pci/drivers/目录下列出所有PCI驱动,通过bind和unbind文件可以手动绑定或解绑设备与驱动。
实战场景:platform总线与设备树
对于嵌入式平台上的MAC控制器(如DesignWare MAC、Freescale FEC),通常使用platform总线而非PCI总线。platform设备通过设备树(Device Tree)描述,在系统启动时由内核解析并注册。驱动通过of_match_table与设备树节点匹配。
static const struct of_device_id mynic_of_match[] = {
{ .compatible = "snps,dwmac-4.20" },
{ .compatible = "fsl,fec" },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, mynic_of_match);
static struct platform_driver mynic_platform_driver = {
.probe = mynic_platform_probe,
.remove = mynic_platform_remove,
.driver = {
.name = "mynic",
.of_match_table = mynic_of_match,
},
};
module_platform_driver(mynic_platform_driver);
常见误区与复盘
- 误区1:在probe函数中执行耗时操作。probe函数运行在进程上下文,但系统启动时可能并行执行多个probe,耗时操作会拖慢启动速度。建议将硬件初始化拆分为probe(快速)和open(慢速)两个阶段。
- 误区2:忽略电源管理回调。现代网卡需要支持运行时电源管理(Runtime PM),如果驱动未实现suspend/resume回调,系统挂起时网卡可能无法正常工作。
- 误区3:设备树兼容字符串不匹配。在嵌入式开发中,设备树中的compatible属性必须与驱动中的of_match_table完全一致,否则设备无法被驱动识别。
2.3 网络协议栈分层:L2/L3/L4 协作机制
Linux网络协议栈遵循TCP/IP分层模型,但实现上并非严格隔离。理解各层的职责和层间接口,是定位网络问题、优化性能的基础。网卡驱动主要工作在L2层,但需要与L3、L4层频繁交互。
核心原理:各层职责与关键函数
L4传输层负责端到端通信,TCP通过tcp_v4_rcv()接收报文,通过tcp_transmit_skb()发送报文;UDP对应udp_rcv()和udp_send_skb()。L3网络层负责路由和转发,核心函数包括ip_rcv()(接收)、ip_output()(发送)、ip_forward()(转发)。L2数据链路层负责与网卡交互,接收路径的核心是netif_rx()或NAPI机制的netif_receive_skb(),发送路径的核心是dev_queue_xmit()。
| 协议层 | 接收路径关键函数 | 发送路径关键函数 | 核心数据结构 |
|---|---|---|---|
| L4 (TCP) | tcp_v4_rcv() | tcp_transmit_skb() | struct tcp_sock |
| L4 (UDP) | udp_rcv() | udp_send_skb() | struct udp_sock |
| L3 (IP) | ip_rcv() | ip_output() | struct iphdr |
| L2 (以太网) | netif_receive_skb() | dev_queue_xmit() | struct ethhdr |
层间交互:sk_buff的传递
协议栈各层通过struct sk_buff传递数据。接收方向,网卡驱动将DMA缓冲区中的数据封装为sk_buff,调用netif_receive_skb()送入协议栈;L2层处理完以太网头后,根据ethertype调用ip_rcv();L3层处理完IP头后,根据protocol字段调用tcp_v4_rcv()或udp_rcv()。发送方向则相反,应用层数据依次封装TCP头、IP头、以太网头,最终通过驱动发送。
data指针来剥离或添加协议头,而不是复制数据。驱动开发中,正确设置skb_reserve()和skb_put()的偏移量,可以避免额外的内存拷贝,显著提升吞吐量。
实战场景:NAPI与中断收包
传统收包方式中,每个数据包到达都会触发一次中断,高流量下会导致CPU频繁被打断,造成"中断风暴"。NAPI(New API)机制将中断与轮询结合:首次数据包到达时触发中断,驱动关闭中断并进入轮询模式,批量处理后续数据包,直到没有新包到达再重新开启中断。网卡驱动需要实现struct napi_struct的poll回调函数。
static int mynic_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;
struct mynic_priv *priv = container_of(napi, struct mynic_priv, napi);
// 从DMA环形队列中取出数据包
while (work_done < budget) {
struct sk_buff *skb = mynic_fetch_rx_packet(priv);
if (!skb)
break;
// 设置协议类型(eth_type_trans)
skb->protocol = eth_type_trans(skb, priv->netdev);
// 送入协议栈
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
// 数据包处理完毕,重新开启中断
napi_complete_done(napi, work_done);
mynic_enable_irq(priv);
}
return work_done;
}
常见误区与复盘
- 误区1:在中断上下文中调用可能睡眠的函数。网卡驱动的中断处理函数运行在硬中断上下文,不能调用
kmalloc(GFP_KERNEL)、mutex_lock()等可能睡眠的函数。应使用GFP_ATOMIC或推迟到软中断/工作队列中处理。 - 误区2:忽略GRO/GSO特性。现代协议栈支持GRO(Generic Receive Offload)和GSO(Generic Segmentation Offload),驱动应使用
napi_gro_receive()替代netif_receive_skb(),并设置netdev_features_t中的相应标志位。 - 误区3:发送队列停止/唤醒条件设置不当。当DMA环形队列满时,应调用
netif_stop_queue()停止上层发送;当队列有空间时,调用netif_wake_queue()唤醒。条件设置不当会导致丢包或性能下降。
2.4 sk_buff核心数据结构初识
struct sk_buff是Linux网络子系统中最核心的数据结构,它贯穿协议栈的每一层,承载着网络数据的所有元信息。网卡驱动开发中,正确理解和使用sk_buff是必修课。
核心原理:sk_buff的内存布局
sk_buff结构体分为两部分:控制信息(struct sk_buff本身)和数据区(通过指针引用)。控制信息包含链表指针、长度字段、协议信息、时间戳等;数据区通过四个指针管理:head指向数据区起始地址,data指向当前协议层的有效数据起始位置,tail指向有效数据结束位置,end指向数据区末尾。通过调整data和tail指针,各协议层可以方便地添加或剥离协议头。
// sk_buff关键字段
struct sk_buff {
/* 链表管理 */
struct sk_buff *next;
struct sk_buff *prev;
struct sk_buff_head *list;
/* 长度信息 */
unsigned int len; // 实际数据长度
unsigned int data_len; // 分片数据长度
unsigned int truesize; // 总内存占用(含sk_buff本身)
/* 协议信息 */
__be16 protocol; // L3协议类型(ETH_P_IP等)
__u16 transport_header; // L4头偏移
__u16 network_header; // L3头偏移
__u16 mac_header; // L2头偏移
/* 数据区指针 */
unsigned char *head;
unsigned char *data;
unsigned char *tail;
unsigned char *end;
/* 其他 */
struct net_device *dev; // 关联的网络设备
struct sock *sk; // 关联的socket
unsigned long _skb_refdst;
void (*destructor)(struct sk_buff *skb);
};
sk_buff操作API
内核提供了一系列操作sk_buff的辅助函数,驱动开发者必须熟练掌握:alloc_skb()/dev_alloc_skb()用于分配sk_buff;skb_put()在数据区尾部添加数据;skb_push()在数据区头部添加数据(如添加协议头);skb_pull()从数据区头部移除数据(如剥离协议头);skb_reserve()在数据区头部预留空间;kfree_skb()/dev_kfree_skb_any()释放sk_buff。
| 操作 | 函数 | 效果 | 典型使用场景 |
|---|---|---|---|
| 分配 | alloc_skb(size, gfp_mask) | 分配sk_buff和数据区 | 驱动接收路径 |
| 预留头部 | skb_reserve(skb, len) | data指针向后移动len | 为协议头预留空间 |
| 添加尾部数据 | skb_put(skb, len) | tail指针向后移动len | 填充DMA接收数据 |
| 添加头部数据 | skb_push(skb, len) | data指针向前移动len | 添加以太网头 |
| 移除头部数据 | skb_pull(skb, len) | data指针向后移动len | 剥离以太网头 |
| 释放 | kfree_skb(skb) | 释放sk_buff和数据区 | 错误处理、发送完成 |
实战场景:接收路径中的sk_buff操作
以网卡接收一个以太网帧为例,驱动从DMA环形队列中获取数据后,需要正确设置sk_buff的各个字段:
static struct sk_buff *mynic_rx_to_skb(struct mynic_priv *priv,
struct mynic_rx_desc *desc)
{
struct sk_buff *skb;
unsigned int pkt_len = desc->len;
// 1. 分配sk_buff,预留足够的头部空间
skb = napi_alloc_skb(&priv->napi, pkt_len + NET_IP_ALIGN);
if (!skb)
return NULL;
// 2. 预留2字节对齐空间(NET_IP_ALIGN)
skb_reserve(skb, NET_IP_ALIGN);
// 3. 将DMA缓冲区中的数据拷贝到sk_buff数据区
// 注意:如果硬件支持DMA直接写入sk_buff,可以避免拷贝
dma_sync_single_for_cpu(&priv->pdev->dev, desc->dma_addr,
pkt_len, DMA_FROM_DEVICE);
skb_put_data(skb, desc->vaddr, pkt_len);
dma_sync_single_for_device(&priv->pdev->dev, desc->dma_addr,
pkt_len, DMA_FROM_DEVICE);
// 4. 设置协议类型和网络设备
skb->protocol = eth_type_trans(skb, priv->netdev);
skb->dev = priv->netdev;
// 5. 记录接收时间戳(可选)
skb->tstamp = ktime_get_real();
return skb;
}
truesize字段表示该sk_buff实际占用的内存大小(包括sk_buff结构体、数据区、分片等)。在接收路径中,如果truesize设置不准确,会影响协议栈的内存压力判断,可能导致TCP接收窗口计算错误。驱动在设置skb->truesize时应包含实际分配的内存大小,而不是仅包含数据长度。
常见误区与复盘
- 误区1:混淆skb_put和skb_push。skb_put在尾部添加数据(tail指针后移),skb_push在头部添加数据(data指针前移)。在接收路径中,DMA数据应使用skb_put;在发送路径中添加协议头应使用skb_push。
- 误区2:忘记调用skb_reserve预留头部空间。如果接收路径中不预留NET_IP_ALIGN(通常为2字节),IP头可能不对齐,导致某些架构上性能下降甚至崩溃。
- 误区3:在错误上下文释放sk_buff。如果sk_buff被提交到协议栈(如通过netif_receive_skb),驱动就不再拥有所有权,不能自行释放。只有在驱动仍持有所有权时(如DMA错误、校验失败),才能调用kfree_skb释放。