基础网络模型图
一般网络设计分为三层架构和五层设计:
一、三层架构
用户空间的应用层
- 位于最上层,是用户直接使用的网络应用程序,如浏览器、邮件客户端、即时通讯软件等。这些程序通过系统调用(如
socket
接口)向内核空间的网络协议栈发起网络请求(如发送数据、建立连接)。内核空间的网络协议栈层
- 架构的核心层,负责实现网络协议(如 TCP/IP、UDP 等)的逻辑。包括数据的封装、解封装,协议规则处理(如路由选择、可靠传输控制),是网络数据处理的核心枢纽。
物理硬件层
- 最底层,包含网卡等物理设备,负责将网络数据转换为电信号、光信号等物理信号进行传输,实现数据的实际发送与接收。
二、五层设计(协议栈层的细分)
系统调用接口层
- 作为用户空间与内核空间的桥梁,提供标准化的系统调用接口(如
socket()
、send()
、recv()
等)。应用层通过这些接口请求内核的网络服务,内核也通过此层向应用返回处理结果。协议无关的接口层
- 屏蔽不同网络协议的差异,为上层提供统一的操作接口。无论底层是 TCP、UDP 还是其他协议,上层只需通过该层的统一接口即可访问网络功能,简化了应用开发。
网络协议实现层
- 具体实现各种网络协议的逻辑,如:
- IP 层:处理路由选择、数据包分片重组;
- TCP 层:实现可靠传输、流量控制、拥塞控制;
- UDP 层:处理无连接的快速数据传输。
该层是协议栈的核心,决定了网络数据的传输规则。驱动接口层
- 为网络设备驱动程序提供统一的编程接口规范。无论网卡硬件如何差异,驱动程序只需遵循该接口标准,即可与内核协议栈交互,方便驱动开发与维护。
驱动程序层
- 直接操作物理硬件(如网卡),实现数据的硬件层面收发。负责控制网卡寄存器、处理硬件中断,将内核协议栈的网络数据转换为网卡可处理的信号,或反之。
其中Linux内核网络栈设计其中三层,分别是数据链路层、网络层和传输层。内核栈的任务就是将接收到的数据包从网络设备驱动程序传递给网络层(通常是ipv4或ipv6),接下来,如果数据包的目的地为当前设备,Linux内核网络栈就将其传递给传输层(TCP或者UDP协议监听套接字)。 如果数据包需要转发,就将其交给数据链路层进行传输。其中有可能产生数据包丢失、可能需要重组数据包、需要计算数据包的检验和等等。
一、套接字缓冲区管理数据
1.sk_buff
在内核分析(收到)网络分组时,底层协议的数据将传递到更高的层。发送数据时顺序相反,各种协议产生的数据(首部和净荷)依次向更低的层传递,直至最终发送。这些操作的速度对网络子系统的性能有决定性的影响,因此内核使用一种特殊的结构,称为套接字缓冲区(socket buffer),它表示一个网络数据包,由双向链表构成,sk_buff结构表示一个包含报头的入站或出站数据包(SKB表示套接字缓冲区)。具体源码分析如下:
1.
sk_buff
是什么?
sk_buff
(Socket Buffer,简称 SKB)是 Linux 内核中用于管理网络数据包的核心数据结构。它表示一个包含协议首部(如 MAC、IP、TCP)和净荷的网络数据包,主要用于高效处理网络分组的收发和协议栈的分层操作。以下是其核心特点:
双向链表结构:
sk_buff
通过next
和prev
指针构成双向链表,方便内核管理多个数据包(例如排队或重组分片)。指针分层:通过多组指针(如
mac_header
、network_header
、transport_header
)快速定位不同协议层的首部,避免数据复制。动态调整:通过操作指针(如
head
、data
、tail
、end
)动态增删协议首部或调整数据区域,提升性能。
2.
sk_buff
的结构与字段解析以下是关键字段及其作用(基于用户提供的代码片段和描述):
链表指针:
struct sk_buff *next; // 指向下一个 sk_buff struct sk_buff *prev; // 指向上一个 sk_buff用于将多个 SKB 组织成链表(例如接收队列或发送队列)。
数据指针:
head
和end
:指向数据缓冲区在内存中的起始和结束位置(固定边界)。
data
和tail
:指向当前协议层的数据起始和结束位置(随协议处理动态调整)。协议首部指针:
mac_header
:指向 MAC 层首部(如以太网帧头)。
network_header
:指向网络层首部(如 IPv4/IPv6 头)。
transport_header
:指向传输层首部(如 TCP/UDP 头)。
3.
sk_buff
的使用场景接收数据包(从底层到上层)
网卡驱动:收到原始数据后,分配一个
sk_buff
,将数据拷贝到head
和end
之间的缓冲区。MAC 层处理:通过
mac_header
解析以太网帧头,剥离后更新data
指针指向 IP 层数据。IP 层处理:通过
network_header
解析 IP 头,剥离后更新data
指向传输层数据(如 TCP)。传输层处理:通过
transport_header
解析 TCP/UDP 头,最终将净荷传递给应用层。发送数据包(从上层到底层)
应用层:用户数据填充到
data
和tail
之间。传输层:通过
skb_push
添加 TCP/UDP 头,更新transport_header
。网络层:通过
skb_push
添加 IP 头,更新network_header
。MAC 层:通过
skb_push
添加以太网帧头,更新mac_header
。网卡驱动:将
sk_buff
加入发送队列,最终由网卡发送。
4. 关键操作函数
分配/释放:
struct sk_buff *skb = alloc_skb(size, GFP_KERNEL); // 分配 SKB kfree_skb(skb); // 释放 SKB调整数据区域:
skb_reserve(skb, len)
:预留头部空间(用于后续添加协议首部)。
skb_push(skb, len)
:向数据区域头部添加数据(如添加协议头)。
skb_pull(skb, len)
:从数据区域头部移除数据(如剥离协议头)。
skb_put(skb, len)
:扩展数据区域尾部(用于添加净荷)。
5. 示例:发送 TCP 数据包
// 1. 分配 SKB,预留协议头空间 struct sk_buff *skb = alloc_skb(MAX_SIZE, GFP_KERNEL); skb_reserve(skb, ETH_HLEN + IP_HLEN + TCP_HLEN); // 2. 填充应用层数据 memcpy(skb_put(skb, payload_len), user_data, payload_len); // 3. 添加 TCP 头 skb_push(skb, TCP_HLEN); skb->transport_header = skb->data; tcp_header = (struct tcphdr *)skb->transport_header; // 填充 TCP 头字段... // 4. 添加 IP 头 skb_push(skb, IP_HLEN); skb->network_header = skb->data; ip_header = (struct iphdr *)skb->network_header; // 填充 IP 头字段... // 5. 添加 MAC 头 skb_push(skb, ETH_HLEN); skb->mac_header = skb->data; eth_header = (struct ethhdr *)skb->mac_header; // 填充 MAC 头字段... // 6. 将 SKB 交给网卡驱动发送 dev_queue_xmit(skb);
6. 性能优势
零拷贝:原来的方式是头部位置不变,每次新插入头部信息,需要将原有头部以及数据向后拷贝,从而空出位置插入新的头部;而这种方式只需要将头部指针向前扩展就可以无需拷贝原有数据。
高效链表管理:双向链表支持快速插入、删除和遍历数据包。
分层解耦:各协议层只需操作自己的首部指针,无需关心其他层细节。
2.sk_buff_data_t
1. 代码片段分析
用户提供的代码片段是一个条件编译的定义,用于在 32 位系统中定义
sk_buff_data_t
类型:#ifdef Nt_5KRUH_DATA_USES_OFFSEI typedef unsigned int sk_buff_data_t; #else typedef unsigned char *sk_buff_data_t; // 修正后的正确语法(原代码存在笔误) #endif
条件编译逻辑:
如果定义了宏
Nt_5KRUH_DATA_USES_OFFSEI
,则sk_buff_data_t
被定义为unsigned int
。否则,
sk_buff_data_t
被定义为unsigned char *
)。
2.
sk_buff_data_t
的用途
sk_buff_data_t
在 Linux 内核中用于表示 套接字缓冲区(sk_buff
)的指针或偏移量,具体用途包括:
管理数据缓冲区的指针(如
head
、data
、tail
、end
)。在内存中高效定位协议首部(如 MAC、IP、TCP 头)或净荷数据。
3. 使用场景
场景 1:使用偏移量(
Nt_5KRUH_DATA_USES_OFFSEI
已定义)
定义:
sk_buff_data_t
为unsigned int
,表示相对于某个基地址的偏移量。使用方式:
// 示例:获取实际指针 char *base = skb->head; // 缓冲区的基地址 sk_buff_data_t data_offset = skb->data; // 偏移量 char *data_ptr = base + data_offset; // 实际数据指针
优势:
当缓冲区内存移动时,只需更新基地址,无需修改所有指针。
节省内存(偏移量占 4 字节,指针在 32 位系统也占 4 字节,但逻辑更灵活)。
场景 2:使用指针(
Nt_5KRUH_DATA_USES_OFFSEI
未定义)
定义:
sk_buff_data_t
为unsigned char *
,直接指向内存地址。使用方式:
// 示例:直接访问数据 unsigned char *data_ptr = skb->data;
优势:
直接操作指针,无需计算偏移量,代码更简洁。
适用于对性能要求极高且缓冲区位置固定的场景。
3.从套接字缓冲区获取TCP/UDP首部:
1.
tcp_hdr
函数
作用:
该函数用于获取指向sk_buff
(套接字缓冲区)中 TCP 协议头 的指针。
通过skb_transport_header(skb)
获取传输层头部指针后,将其强制转换为struct tcphdr *
类型(TCP 头结构体)。使用案例:
在接收 TCP 数据包时,解析 TCP 头部字段(如源端口、目的端口、序列号、确认号等)。// 示例:获取 TCP 头部并解析源端口 struct sk_buff *skb = ...; // 接收到的数据包 struct tcphdr *tcp = tcp_hdr(skb); u16 src_port = ntohs(tcp->source); // 源端口(网络字节序转主机字节序) u16 dst_port = ntohs(tcp->dest); // 目的端口
2.
udp_hdr
函数
作用:
该函数用于获取指向sk_buff
中 UDP 协议头 的指针。
通过skb_transport_header(skb)
获取传输层头部指针后,将其强制转换为struct udphdr *
类型(UDP 头结构体)。使用案例:
在处理 UDP 数据包时,解析 UDP 头部字段(如源端口、目的端口、长度、校验和等)。// 示例:获取 UDP 头部并计算校验和 struct sk_buff *skb = ...; // 接收到的数据包 struct udphdr *udp = udp_hdr(skb); u16 src_port = ntohs(udp->source); // 源端口 u16 len = ntohs(udp->len); // UDP 数据包长度
4. Linux内核提供用于操作套接字缓冲区的标准函数
1.
alloc_skb
函数作用:分配
sk_buff
结构体及关联的数据缓冲区,用于创建新的套接字缓冲区,供网络协议栈封装数据使用。案例
#include <linux/skbuff.h> // 模拟分配 sk_buff 用于网络数据发送 void example_alloc_skb() { struct sk_buff *skb; // 分配 2048 字节的缓冲区,GFP_KERNEL 表示内核态分配内存 skb = alloc_skb(2048, GFP_KERNEL); if (!skb) { // 分配失败处理 return; } // 填充数据(示例) memcpy(skb->data, "network data", 12); skb->len = 12; // 设置数据长度 // 后续可将 skb 交给协议层发送 kfree_skb(skb); // 使用完毕释放 }
2.
skb_copy
函数作用:复制
sk_buff
结构体及其关联的数据(包括头部和数据部分),生成完全独立的副本,原skb
与副本互不影响。案例
#include <linux/skbuff.h> // 模拟复制 skb 用于协议处理 void example_skb_copy() { struct sk_buff *original_skb, *copied_skb; original_skb = alloc_skb(1024, GFP_KERNEL); if (!original_skb) return; // 填充原始 skb 数据 memcpy(original_skb->data, "original data", 13); original_skb->len = 13; // 复制 skb copied_skb = skb_copy(original_skb, GFP_KERNEL); if (!copied_skb) { kfree_skb(original_skb); return; } // 修改副本数据(不影响原始 skb) memcpy(copied_skb->data, "copied data", 11); copied_skb->len = 11; kfree_skb(original_skb); kfree_skb(copied_skb); }
3.
skb_clone
函数作用:克隆
sk_buff
结构体,共享底层数据缓冲区。新skb
与原skb
的数据部分共享内存,仅元数据(如头部指针、长度等)独立,节省内存开销。
案例#include <linux/skbuff.h> // 模拟克隆 skb 共享数据 void example_skb_clone() { struct sk_buff *original_skb, *cloned_skb; original_skb = alloc_skb(1024, GFP_KERNEL); if (!original_skb) return; // 填充原始 skb 数据 memcpy(original_skb->data, "shared data", 11); original_skb->len = 11; // 克隆 skb(共享数据) cloned_skb = skb_clone(original_skb, GFP_KERNEL); if (!cloned_skb) { kfree_skb(original_skb); return; } // 验证数据共享:修改原始 skb 数据,克隆的 skb 数据同步变化 memcpy(original_skb->data, "updated data", 12); // 两者数据内容相同 kfree_skb(original_skb); kfree_skb(cloned_skb); }
1.
skb_tailroom
函数作用:计算
sk_buff
尾部剩余可用空间。通过skb->end - skb->tail
计算尾部空间(非非线性缓冲区场景另有处理),用于判断能否在数据包末尾追加数据(如调用skb_put
前检查空间)。案例
#include <linux/skbuff.h> void use_skb_tailroom() { struct sk_buff *skb = alloc_skb(2048, GFP_KERNEL); if (!skb) return; // 检查尾部空间是否足够追加 100 字节数据 int tail_space = skb_tailroom(skb); if (tail_space >= 100) { unsigned char *new_tail = skb_put(skb, 100); if (new_tail) { // 填充数据 memset(new_tail, 0xAA, 100); } } kfree_skb(skb); }
2.
skb_headroom
函数作用:计算
sk_buff
头部剩余可用空间。通过skb->data - skb->head
得出头部空间大小,用于判断能否在数据包头部添加协议首部(如调用skb_push
前确认空间)。案例
#include <linux/skbuff.h> void use_skb_headroom() { struct sk_buff *skb = alloc_skb(2048, GFP_KERNEL); if (!skb) return; // 检查头部空间是否足够添加 20 字节的协议首部 unsigned int head_space = skb_headroom(skb); if (head_space >= 20) { unsigned char *new_data = skb_push(skb, 20); if (new_data) { // 填充协议首部数据 memset(new_data, 0xBB, 20); } } kfree_skb(skb); }
3.
skb_realloc_headroom
函数作用:重新分配
sk_buff
的头部空间。当需要扩展头部空间(如原有头部空间不足)时,调整缓冲区,确保满足新的头部空间需求,返回新的sk_buff
指针。案例
#include <linux/skbuff.h> void use_skb_realloc_headroom() { struct sk_buff *skb = alloc_skb(1024, GFP_KERNEL); if (!skb) return; // 原有头部空间不足,需扩展到 100 字节 unsigned int required_headroom = 100; struct sk_buff *new_skb = skb_realloc_headroom(skb, required_headroom); if (new_skb) { skb = new_skb; // 检查新头部空间 unsigned int new_head_space = skb_headroom(skb); if (new_head_space >= required_headroom) { // 成功扩展,可添加首部 unsigned char *new_data = skb_push(skb, 100); if (new_data) { memset(new_data, 0xCC, 100); } } } kfree_skb(skb); }
二、管理套接字缓冲区数据
套接字缓冲区结构不仅包含上述指针,还包括用于处理相关的数据和管理套接字缓冲区自身的其他成员。下面列出的是一些最重要的成员:
- tstamp 保存了分组到达的时间。
- dev 指定了处理分组的网络设备。
- sk 是一个指针,指向用于处理该分组的套接字对应的 socket 实例。
- dst 表示接下来该分组通过内核网络实现的路由。
- next 和 prev 用于将套接字缓冲区保存到一个双向链表中。
使用一个表头来实现套接字缓冲区的等待队列。结构如下:
三、补充net_device
1.net_device结构体
net_device 结构体存储着网络设备的所有信息,每个设备都有这种结构。所有设备的 net_device 结构放在一个全局变量 dev_base 所有全局列表中。和 sk_buff 一样,整体结构相当庞大的。结构体中有一个 next 指针,用来连接系统中所有网络设备。内核把这些连接起来的设备组成一个链表,并由全局变量 dev_base 指向链表的第一个元素。net_device 结构体具体源码如下:
该结构体包含设备参数:设备的IRQ号、设备的MAC地址、设备名称(eth1、eth0)、设备的标志(up、down)、与设备相关的组播地址清单、设备支持的功能、网络设备回调函数的对象(net_device_ops)、设备最后一次发送数据包的时间戳、设备最后一次接收数据包的时间戳。
1. 设备的 IRQ 号
- 作用:IRQ(Interrupt Request,中断请求)号是网络设备与内核通信的 “信号通道”。当设备完成数据接收或准备好发送数据时,会通过触发 IRQ 中断通知内核。例如,网卡收到网络数据后,通过预设的 IRQ 号向内核发送中断信号,内核响应后调用对应的处理函数读取数据。
- 意义:确保内核及时感知设备的状态变化,实现高效的数据交互,是设备与内核协同工作的基础。
2. 设备的 MAC 地址
- 作用:MAC 地址是网络设备的物理层唯一标识符,遵循 IEEE 802 标准。在数据链路层,以太网帧通过源 MAC 和目标 MAC 地址实现设备间通信。例如,当主机向路由器发送数据时,数据帧的目标 MAC 地址会填写路由器接口的 MAC 地址。
- 意义:保障数据链路层通信的准确性,是局域网内设备定位的关键标识。
3. 设备名称(如 eth1、eth0)
- 作用:设备名称是用户和内核操作设备的 “身份标识”。用户通过名称(如
eth0
)执行配置命令(如ifconfig eth0 up
启用设备),内核通过名称管理设备的网络参数(如 IP 地址绑定)。- 意义:提供直观的设备区分方式,方便系统管理和用户操作。
4. 设备的标志(up、down)
- 作用:标志位用于表示设备的工作状态。
up
表示设备处于启用状态,可正常收发数据;down
表示设备禁用,停止数据传输。例如,使用ifconfig eth0 down
命令将设备状态设为down
,此时设备不再处理网络数据。- 意义:内核和用户通过状态标志控制设备的工作流程,确保网络通信的可控性。
5. 与设备相关的组播地址清单
- 作用:存储设备订阅的组播地址。当网络中传输组播数据(如视频会议、流媒体广播)时,设备仅接收目标地址属于自身组播清单的数据。例如,视频会议软件会将设备加入特定组播地址,设备只处理该组播地址的数据流。
- 意义:实现高效的组播数据过滤,减少无关数据处理,优化网络带宽利用。
6. 设备支持的功能
- 作用:记录设备支持的特性,如是否支持 VLAN 划分、硬件校验和卸载、巨帧(Jumbo Frame)等。例如,若设备支持硬件校验和卸载,内核会将 TCP/UDP 校验和计算任务交给设备硬件处理,减轻 CPU 负担。
- 意义:内核根据设备功能优化数据处理流程,充分发挥硬件性能。
7. 网络设备回调函数的对象(net_device_ops)
- 作用:
net_device_ops
是一个函数指针集合,包含设备操作的核心函数,如ndo_start_xmit
(发送数据)、ndo_init
(设备初始化)等。内核通过调用这些函数实现对设备的控制。例如,发送网络数据时,内核调用ndo_start_xmit
触发设备的硬件发送流程。- 意义:提供统一的设备操作接口,屏蔽不同硬件的实现差异,确保内核网络子系统的通用性。
8. 设备最后一次发送 / 接收数据包的时间戳
- 作用:记录设备最近一次发送和接收数据包的时间。这些时间戳用于网络统计、监控和调试,例如计算设备的空闲时间、分析网络流量突发情况。
- 意义:帮助管理员或内核诊断网络性能问题,如通过时间戳发现设备长时间未接收数据,可能存在链路故障。
2.网络设备注册和注销函数
1.
register_netdevice
函数
- 作用:将网络设备(通过
net_device
结构体表示)注册到 Linux 内核。内核通过此函数完成设备的初始化检查、资源登记,并将设备加入全局网络设备链表(由dev_base
管理),使设备能够被内核网络子系统识别和管理。函数会校验设备状态(如reg_state
是否为未初始化状态),确保注册过程合法。2.
unregister_netdevice
函数
- 作用:从 Linux 内核中注销已注册的网络设备。它会移除设备在全局链表中的节点,释放设备相关资源(如网络配置、统计信息等),使设备不再被内核网络子系统管理,通常用于设备驱动卸载或设备移除场景。
使用案例
#include <linux/module.h> #include <linux/netdevice.h> #include <linux/etherdevice.h> // 简化模拟 net_device 结构体(实际内核中该结构体更复杂) struct net_device *sim_net_dev; // 初始化模拟网络设备 static int __init net_device_module_init(void) { // 分配 net_device 结构体 sim_net_dev = alloc_netdev(sizeof(struct net_device), "sim_dev", ether_setup); if (!sim_net_dev) { return -ENOMEM; } // 注册网络设备到内核 int ret = register_netdevice(sim_net_dev); if (ret < 0) { free_netdev(sim_net_dev); return ret; } printk(KERN_INFO "Network device registered successfully\n"); return 0; } // 卸载模块时反注册设备 static void __exit net_device_module_exit(void) { if (sim_net_dev) { unregister_netdevice(sim_net_dev); free_netdev(sim_net_dev); printk(KERN_INFO "Network device unregistered successfully\n"); } } module_init(net_device_module_init); module_exit(net_device_module_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("Net Device Registration Example");
在 Linux 内核的网络子系统中,net_device
结构体扮演着至关重要的角色,它是内核管理网络设备的核心数据结构,存储着网络设备的各种关键信息,如设备名称、MAC 地址、中断号等。为了让内核能够识别和管理这些网络设备,我们需要使用 register_netdevice
函数将设备注册到内核中,而当设备不再使用时,则使用 unregister_netdevice
函数进行注销操作。
这些基本的设备管理操作是网络设备正常工作的基础,随着网络技术的发展和网络负载的不断变化,传统的网络设备管理和数据处理方式也面临着挑战。接下来,我们将探讨在网络设备驱动中为了应对高负载情况而引入的 NAPI 技术,以及网络设备驱动在数据包收发过程中的具体任务,了解它们如何在 net_device
结构体和注册、注销机制的基础上进一步优化网络性能。
1. NAPI 技术解析
- 老式中断驱动模式缺陷:传统网络设备驱动采用 “中断驱动模式”,每接收一个数据包就触发一次中断。高负载时,频繁中断会导致 CPU 大量时间消耗在中断处理上,降低整体效率。
- NAPI 技术优势:NAPI(New API)是一种混合模式技术。在低负载时,设备仍通过中断响应数据;高负载时,驱动切换为 “轮询模式”,一次性处理多个数据包,减少中断开销,提升网络数据处理效率。
2. 网络设备驱动的数据包收发任务
- 接收方向:驱动程序负责接收目的地址为本主机的数据包,先传递给网络层(如 IP 层),再由网络层传递给传输层(如 TCP/UDP 层)。
- 发送方向:驱动程序处理本主机生成的外出数据包,或转换接收到的数据包。无论收发包,都需通过路由子系统执行查找操作,确定数据包的转发路径。
每个SKB都有一个dev成员(一个net_device结构实例),对于到来的数据包,这个成员表示接收它的网络设备;而对于外出地数据包,它表示发送它的网络设备。