Linux内核与网络子系统概览

在深入网卡驱动开发之前,必须先建立对Linux内核整体架构和网络子系统工作方式的基本认知。网卡驱动并非孤立运行的程序,它需要与内核的设备模型、内存管理、中断系统以及协议栈各层紧密协作。本章将围绕内核模块机制、设备驱动模型(device/driver/bus)、网络协议栈分层以及sk_buff核心数据结构展开,为后续编写和理解网卡驱动打下坚实的知识基础。

Linux内核网络子系统总体架构 用户空间(User Space) 应用程序(Socket API / 系统调用) 内核空间(Kernel Space) 系统调用接口(sys_socket / sys_sendto / sys_recvfrom ...) 网络协议栈分层 L4 传输层:TCP / UDP / SCTP(struct sock, tcp_write_xmit) L3 网络层:IP / ICMP / ARP(ip_rcv, ip_output, neigh子系统) L2 数据链路层:dev_queue_xmit, netif_rx, 网卡驱动 设备驱动模型 struct device / struct device_driver struct bus_type / struct class platform_bus / pci_bus / usb_bus sk_buff 核心数据结构 struct sk_buff(控制信息) struct skb_shared_info(分片/校验) 数据区(head → data → tail → end) 硬件层:网卡控制器(MAC/PHY)、DMA引擎、中断控制器

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 查看模块信息(作者、许可、参数) 读取文件元数据
经验提醒:在开发调试阶段,建议使用insmod而非modprobe,因为insmod的输出更直接,便于定位加载失败的原因。同时,在模块加载前先执行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头、以太网头,最终通过驱动发送。

性能关键点:协议栈各层之间的数据传递是零拷贝的——通过调整sk_buff中的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;
}
风险提示:sk_buff的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释放。
总结复盘:本章从四个维度建立了Linux网络子系统的整体认知。内核模块机制是驱动代码的组织形式,设备驱动模型是驱动与硬件交互的框架,协议栈分层是数据流动的路径,sk_buff是贯穿全程的数据载体。四者相互关联:驱动通过模块机制加载,通过设备模型与硬件绑定,通过协议栈接口收发数据,通过sk_buff承载数据。理解这些基础概念后,后续章节将深入网卡驱动的具体实现细节,包括DMA管理、中断处理、NAPI机制、硬件特性配置等。建议读者在阅读后续章节时,不断回顾本章的知识框架,将具体实现映射到整体架构中,形成系统化的知识体系。