欢迎各位参加本次关于 Linux 内核网络栈中核心数据结构 sk_buff 的深入探讨。今天,我们将聚焦于 sk_buff 如何在数据包穿越内核各个协议层时,巧妙地实现内存的封装与拆解,以及它在性能优化中扮演的关键角色。理解 sk_buff,就如同掌握了 Linux 网络数据流动的命脉。
1. sk_buff:网络数据包在内核中的身份证与载体
在 Linux 内核中,网络数据包的生命周期是一段复杂的旅程,它需要从网卡驱动层一路向上穿梭至应用层,或者反向从应用层向下传递至网卡。在这个过程中,数据包会经过链路层、网络层、传输层等多个协议栈的处理。每个协议层都会对数据包进行添加或移除协议头、计算校验和、路由决策等操作。
为了高效、灵活地管理这个动态变化的数据包,Linux 内核设计了一个精巧的数据结构:struct sk_buff,即 “socket buffer” 的缩写。sk_buff 不仅仅是一个内存区域,它是一个功能丰富的元数据容器,承载着数据包的所有信息,包括实际数据、协议头指针、长度、来源/目的网络设备、时间戳、校验和状态、以及各种与网络栈行为相关的标志和控制信息。
sk_buff 的核心设计目标是:
- 内存效率:最小化数据复制,尤其是在协议层之间传递时。
- 灵活性:支持各种协议头的动态添加和移除。
- 可扩展性:能够容纳各种自定义数据和控制信息。
- 性能优化:支持各种硬件卸载(offload)功能,如校验和卸载、TSO/GRO。
没有 sk_buff,Linux 网络栈的性能和复杂性都将难以想象。
2. sk_buff 核心结构深度解析
struct sk_buff 定义在 <linux/skbuff.h> 中,其字段数量庞大,反映了它在内核网络栈中的多功能性。以下是 sk_buff 结构中一些最关键的字段及其作用:
struct sk_buff {
/* These two are private to the sk_buff_head to which this skb belongs to. */
struct sk_buff *next; /* Next sk_buff in list */
struct sk_buff *prev; /* Previous sk_buff in list */
ktime_t tstamp; /* Timestamp of packet arrival/departure */
struct net_device *dev; /* Device we arrived on/are leaving by */
struct sock *sk; /* Socket that owns this sk_buff */
unsigned int len, /* Total length of data (payload + headers) */
data_len; /* Length of data not in linear part (fragmented) */
__u16 mac_len; /* Length of MAC header */
__u16 hdr_len; /* Length of headers (MAC + Network + Transport) */
__u8 __pkt_type_id; /* Packet type (UNICAST, BROADCAST, etc.) */
__u8 __sum_seq_id; /* Checksum state / sequence info */
__u16 protocol; /* Network layer protocol (e.g., ETH_P_IP) */
union {
unsigned int nfctinfo; /* Netfilter connection tracking info */
__u32 mark; /* Netfilter mark */
};
__u8 ip_summed; /* Checksum state: CHECKSUM_NONE, CHECKSUM_UNNECESSARY, CHECKSUM_PARTIAL */
/* Pointers to the data within the buffer */
unsigned char *head; /* Start of allocated memory */
unsigned char *data; /* Start of actual data (payload + headers) */
unsigned char *tail; /* End of actual data */
unsigned char *end; /* End of allocated memory */
/* Control buffer for private data, used by various protocols/subsystems */
char cb[SKB_DATA_ALIGN(sizeof(struct skb_shared_cb))];
/* Shared info for scatter-gather I/O and offload features */
struct skb_shared_info *shinfo;
};
2.1 内存指针:head, data, tail, end
这是 sk_buff 最核心的设计之一,它允许在不复制数据的情况下,动态地添加和移除协议头。
head: 指向sk_buff分配的整个内存区域的起始地址。这是底层物理内存的开始。data: 指向当前数据包的起始地址。在数据包封装(发送)过程中,data会向head方向移动,为上层协议头预留空间。在数据包拆解(接收)过程中,data会向tail方向移动,跳过已处理的协议头。tail: 指向当前数据包的结束地址。它随着数据(包括协议头和有效载荷)的添加而向end方向移动。end: 指向sk_buff分配的整个内存区域的结束地址。
这四个指针定义了 sk_buff 的内存布局:
<-- head end -->
| |
| |
+-------------------------------------------------------------------------+
| Free Headroom | Protocol Headers | Payload Data | Free Tailroom |
+-------------------------------------------------------------------------+
^ ^ ^
| | |
data (Net Hdr) tail
- Headroom:
data - head。这是在data指针之前预留的空闲空间,用于添加协议头。 - Tailroom:
end - tail。这是在tail指针之后预留的空闲空间,用于添加数据或协议尾部(如 FCS)。 - Linear data:
tail - data。这是sk_buff中连续存储的协议头和部分数据。 - Total allocated size:
end - head。
2.2 长度字段:len, data_len, mac_len, hdr_len
len:sk_buff中所有数据(包括线性部分和分片部分)的总长度。这是整个数据包的逻辑长度。data_len: 存储在sk_buff的skb_shared_info结构中的分片(fragments)数据的总长度。如果data_len为0,则所有数据都在线性部分。mac_len: 链路层(MAC)头的长度。hdr_len: 所有协议头(MAC、网络、传输层)的总长度。
2.3 状态与控制字段
tstamp: 数据包的时间戳,记录了数据包何时被接收或何时准备发送。dev: 与此数据包相关的网络设备(struct net_device)。对于接收到的数据包,它表示数据包从哪个设备到达;对于要发送的数据包,它表示数据包将从哪个设备发送。sk: 指向拥有此sk_buff的struct sock结构。主要用于接收路径,将数据包与特定的套接字关联起来。protocol: 标识上层协议的类型,例如ETH_P_IP(IPv4),ETH_P_IPV6(IPv6)。由链路层设置,供网络层使用。ip_summed: 校验和状态。指示校验和是否已被计算、是否需要计算或是否由硬件卸载。CHECKSUM_NONE: 没有校验和。CHECKSUM_UNNECESSARY: 校验和已被验证且正确,或不需要验证(例如,环回接口)。CHECKSUM_PARTIAL: 只有部分校验和(例如,IP头)被计算,传输层校验和需要由硬件计算。
nfctinfo,mark: Netfilter 相关的字段,用于连接跟踪和数据包标记。cb(Control Buffer): 一个通用字节数组,供各个协议层或子系统存储私有数据。这避免了为每个协议层在sk_buff中添加特定字段,保持了sk_buff结构的通用性。shinfo(Shared Info): 指向skb_shared_info结构,这是一个非常重要的优化机制,用于处理数据分片、零拷贝、TSO/GRO 等高级功能。我们将在后面详细讨论。
3. sk_buff 的内存管理
sk_buff 的生命周期涉及其分配、管理和释放。内核提供了多种函数来处理这些操作,以适应不同的使用场景。
3.1 sk_buff 的分配
-
alloc_skb(unsigned int size, gfp_t priority): 这是最通用的sk_buff分配函数。它分配一个sk_buff结构本身以及一个大小为size的线性数据缓冲区。priority参数指定内存分配的优先级(例如GFP_ATOMIC用于中断上下文,GFP_KERNEL用于进程上下文)。// 分配一个 sk_buff,包含 1500 字节的数据缓冲区 struct sk_buff *skb = alloc_skb(1500, GFP_KERNEL); if (!skb) { // 错误处理 return NULL; } // 此时 skb->head, skb->data, skb->tail 都指向缓冲区的起始 // skb->end 指向缓冲区的末尾 -
dev_alloc_skb(unsigned int size): 这是一个针对网络设备优化的分配函数,通常用于网卡驱动程序。它在alloc_skb的基础上,额外做了一些初始化,例如预留了一些headroom(通常是NET_SKB_PAD字节,用于保证协议头对齐)并设置了skb->data指针。// 分配一个用于网卡接收的 sk_buff struct sk_buff *skb = dev_alloc_skb(1536); // 稍微大一点,以容纳MTU+额外头 if (!skb) { // 错误处理 return NULL; } // dev_alloc_skb 会自动调用 skb_reserve(skb, NET_SKB_PAD) // 此时 skb->data 已经向后移动了 NET_SKB_PAD 字节 -
*`skb_clone(struct sk_buff skb, gfp_t priority)
**: 创建一个sk_buff的“浅拷贝”。新的sk_buff拥有独立的sk_buff结构体和元数据,但它与原始sk_buff**共享相同的线性数据缓冲区和分片数据**。这通过增加skb_shared_info中的引用计数 (users) 来实现。这在多播或需要将同一数据包传递给多个消费者(例如 Netfilter 某个TEE` 动作)时非常有用,可以避免昂贵的数据复制。struct sk_buff *original_skb = /* ... */; struct sk_buff *cloned_skb = skb_clone(original_skb, GFP_ATOMIC); if (cloned_skb) { // cloned_skb 和 original_skb 共享底层数据 // 对其中一个的 data/tail 指针的修改不会影响另一个 // 但对底层数据的修改(例如 skb_put/push 写入数据)会互相影响 } -
*`skb_copy(struct sk_buff skb, gfp_t priority)
**: 创建一个sk_buff的“深拷贝”。新的sk_buff拥有独立的sk_buff` 结构体和独立的数据缓冲区。所有数据都会被复制到新的缓冲区中。这在需要修改数据包内容而又不影响原始数据包时使用。struct sk_buff *original_skb = /* ... */; struct sk_buff *copied_skb = skb_copy(original_skb, GFP_KERNEL); if (copied_skb) { // copied_skb 拥有独立的数据副本,修改它不会影响 original_skb }
3.2 sk_buff 的释放
-
*`kfree_skb(struct sk_buff skb)
**: 这是通用的sk_buff释放函数。它会检查skb_shared_info的引用计数。如果skb是共享的(users > 1),则只递减引用计数;当引用计数降为 0 时,才会真正释放底层数据缓冲区和sk_buff` 结构本身。struct sk_buff *skb = /* ... */; kfree_skb(skb); // 释放 sk_buff -
dev_kfree_skb(struct sk_buff *skb),dev_kfree_skb_any(struct sk_buff *skb): 用于网卡驱动程序释放sk_buff。它们会根据当前的上下文(中断或进程)选择合适的释放机制,通常会调用kfree_skb。dev_kfree_skb_any可以在任何上下文安全调用。
3.3 skb_shared_info:分片与零拷贝的基石
skb_shared_info 结构是 sk_buff 实现高性能 I/O 的关键。它位于 sk_buff 结构之外,通过 skb->shinfo 指针连接。它主要用于:
- 数据分片 (Scatter-Gather I/O):当数据包的有效载荷不是连续存储在内存中时(例如,来自用户空间的多个页面),
shinfo->frags数组可以存储指向这些不连续内存页面的描述符 (skb_frag_struct)。这样,内核就不需要将这些分散的数据复制到一个连续的缓冲区中。 - 硬件卸载 (Offload):如 TSO/GSO (TCP Segmentation Offload / Generic Segmentation Offload) 和 GRO (Generic Receive Offload)。这些功能允许网卡处理大块数据,而不需要 CPU 进行分段或重组。
shinfo中包含了gso_size,gso_type等字段来描述这些卸载信息。 - 引用计数:
shinfo->users用于实现skb_clone时的共享数据机制。
struct skb_shared_info {
atomic_t users; /* Reference count for shared data */
unsigned int gso_size; /* Generic Segmentation Offload size */
unsigned int gso_segs; /* Generic Segmentation Offload segments */
__u16 gso_type; /* Generic Segmentation Offload type */
unsigned short nr_frags; /* Number of pages in frags[] array */
struct skb_frag_struct frags[MAX_SKB_FRAGS]; /* Array of page fragments */
// ... 其他字段 ...
};
// 访问 shinfo
#define skb_shinfo(SKB) ((struct skb_shared_info *)(SKB->end))
// 注意:shinfo 结构通常紧跟在 sk_buff 的线性数据缓冲区的末尾
// 这是为了内存布局的紧凑性和效率
skb_shinfo 的巧妙之处在于,它通常紧随在 sk_buff 的线性数据缓冲区之后分配。skb->end 指针实际上指向了 skb_shared_info 结构的起始地址(或者说,skb->end 之后就是 shinfo)。这种布局使得 sk_buff 及其所有相关元数据和线性数据都能在一次内存分配中获得,并具有良好的缓存局部性。
4. 数据包封装:发送路径中的 sk_buff
数据包封装是从应用层向下到物理层的过程,每个协议层都会在数据包前面添加自己的协议头。在 sk_buff 中,这主要通过 skb_push() 和 skb_reserve() 函数以及 skb->data 指针的移动来实现。
4.1 准备 sk_buff:预留头部空间
当一个应用程序通过 send() 或 sendto() 系统调用发送数据时,内核首先需要为数据分配一个 sk_buff。为了容纳所有可能的协议头(MAC、IP、TCP/UDP),通常会预留一些头部空间。
// 定义最大可能的头部空间(例如,以太网头 + IPv6头 + TCP头)
#define MAX_HEADER_SIZE (ETH_HLEN + IP6_HLEN + TCP_HLEN)
// 在套接字层分配 sk_buff
struct sk_buff *skb = alloc_skb(app_data_len + MAX_HEADER_SIZE, GFP_KERNEL);
if (!skb) { /* error */ }
// 预留头部空间,将 skb->data 和 skb->tail 向后移动
// 这样在填充应用数据时,实际数据会从预留空间之后开始
skb_reserve(skb, MAX_HEADER_SIZE);
// 此时:
// skb->head 指向分配内存的起始
// skb->data == skb->head + MAX_HEADER_SIZE
// skb->tail == skb->data
// skb->end 指向分配内存的末尾
skb_reserve(skb, len): 将skb->data和skb->tail都向前(向end方向)移动len字节,从而在skb->head和skb->data之间腾出len字节的headroom。
4.2 应用层数据到传输层
应用数据从用户空间复制到 sk_buff 的线性数据区。
// 复制应用数据到 sk_buff
// skb_put() 会增加 skb->tail 指针并更新 skb->len
void *data_ptr = skb_put(skb, app_data_len);
memcpy(data_ptr, user_buffer, app_data_len);
// 此时:
// skb->data 保持不变 (skb->head + MAX_HEADER_SIZE)
// skb->tail == skb->data + app_data_len
// skb->len == app_data_len
skb_put(skb, len): 在skb->tail处添加len字节的数据。它返回一个指向新添加数据起始位置的指针,并更新skb->tail和skb->len。
4.3 传输层到网络层
传输层(如 TCP 或 UDP)在应用数据前添加其协议头。
// 在 skb->data 之前添加 TCP 头
// skb_push() 会将 skb->data 向前(向 head 方向)移动 TCP_HLEN 字节
struct tcphdr *th = (struct tcphdr *)skb_push(skb, sizeof(struct tcphdr));
// 填充 TCP 头字段
th->source = htons(local_port);
th->dest = htons(remote_port);
// ...
// 设置 sk_buff 的传输层头部指针
// skb_transport_header(skb) 将返回 th
skb_set_transport_header(skb, (const unsigned char *)th - skb->head);
// 此时:
// skb->data == skb->head + MAX_HEADER_SIZE - TCP_HLEN
// skb->tail 保持不变
// skb->len == app_data_len + TCP_HLEN
skb_push(skb, len): 在skb->data之前添加len字节的空间。它返回一个指向新空间起始位置的指针,并将skb->data向head方向移动len字节,同时更新skb->len。
4.4 网络层到链路层
网络层(如 IP)在传输层头前添加其协议头。
// 在 skb->data 之前添加 IP 头
struct iphdr *iph = (struct iphdr *)skb_push(skb, sizeof(struct iphdr));
// 填充 IP 头字段
iph->version = 4;
iph->ihl = 5;
iph->saddr = local_ip;
iph->daddr = remote_ip;
// ...
// 设置 sk_buff 的网络层头部指针
// skb_network_header(skb) 将返回 iph
skb_set_network_header(skb, (const unsigned char *)iph - skb->head);
// 此时:
// skb->data == skb->head + MAX_HEADER_SIZE - TCP_HLEN - IP_HLEN
// skb->tail 保持不变
// skb->len == app_data_len + TCP_HLEN + IP_HLEN
4.5 链路层到网卡
链路层(如以太网)在网络层头前添加其协议头,并设置 skb->protocol。
// 在 skb->data 之前添加以太网头
struct ethhdr *eth = (struct ethhdr *)skb_push(skb, sizeof(struct ethhdr));
// 填充以太网头字段
memcpy(eth->h_source, local_mac, ETH_ALEN);
memcpy(eth->h_dest, remote_mac, ETH_ALEN);
eth->h_proto = htons(ETH_P_IP); // 设置上层协议为 IP
// 设置 sk_buff 的 MAC 层头部指针和长度
// skb_mac_header(skb) 将返回 eth
skb_set_mac_header(skb, (const unsigned char *)eth - skb->head);
skb->mac_len = sizeof(struct ethhdr);
// 此时:
// skb->data == skb->head + MAX_HEADER_SIZE - TCP_HLEN - IP_HLEN - ETH_HLEN
// skb->tail 保持不变
// skb->len == app_data_len + TCP_HLEN + IP_HLEN + ETH_HLEN
最终,skb->data 指向了整个数据包(包括所有协议头和有效载荷)的起始位置,skb->len 是整个数据包的实际长度。这个 sk_buff 就可以被传递给网卡驱动,由网卡发送出去。
发送路径中 sk_buff 指针和长度变化概览
| 操作阶段 | 关键函数/操作 | skb->data 指针变化 |
skb->len 变化 |
headroom 变化 |
备注 |
|---|---|---|---|---|---|
| 初始化 | alloc_skb() |
head |
0 | end - head |
分配原始缓冲区,data=tail=head |
| 预留头部空间 | skb_reserve(skb, H) |
head + H |
0 | H |
为所有协议头预留空间 |
| 添加应用数据 | skb_put(skb, AppLen) |
不变 | AppLen |
H |
复制数据,tail 向后移动 AppLen 字节 |
| 添加传输层头 | skb_push(skb, T_HLen) |
data - T_HLen |
AppLen + T_HLen |
H - T_HLen |
data 向前移动,tail 不变 |
| 添加网络层头 | skb_push(skb, N_HLen) |
data - N_HLen |
AppLen + T_HLen + N_HLen |
H - T_HLen - N_HLen |
data 向前移动,tail 不变 |
| 添加链路层头 | skb_push(skb, L_HLen) |
data - L_HLen |
AppLen + T_HLen + N_HLen + L_HLen |
H - T_HLen - N_HLen - L_HLen |
data 向前移动,tail 不变 |
| 最终状态 | 指向链路层头 | 总长度 | 剩余 headroom | skb->data 指向整个数据包的起始,准备发送 |
5. 数据包拆解:接收路径中的 sk_buff
数据包拆解是发送路径的逆过程,从物理层向上到应用层。每个协议层都会检查并移除自己的协议头。在 sk_buff 中,这主要通过 skb_pull() 函数以及 skb->data 指针的移动来实现。
5.1 网卡接收到链路层
网卡通过 DMA 将接收到的数据帧直接写入预先分配的 sk_buff 缓冲区。通常,网卡驱动会使用 dev_alloc_skb() 分配 sk_buff。
// 假设网卡驱动接收到一个以太网帧,并填充了 skb
struct sk_buff *skb = dev_alloc_skb(FRAME_LEN);
if (!skb) { /* error */ }
// 网卡驱动通过 DMA 将数据写入 skb->data 之后的区域
// 假设 FRAME_LEN 是整个以太网帧的长度
skb_put(skb, FRAME_LEN); // 更新 skb->tail 和 skb->len
// 此时:
// skb->head 指向分配内存的起始
// skb->data == skb->head + NET_SKB_PAD (dev_alloc_skb 预留的 headroom)
// skb->tail == skb->data + FRAME_LEN
// skb->len == FRAME_LEN
// skb->data 指向以太网头的起始
5.2 链路层到网络层
网卡驱动或 NAPI 层将 sk_buff 提交给网络核心。链路层会解析以太网头,确定上层协议,并移除以太网头。
// 在 eth_type_trans() 或类似函数中
struct ethhdr *eth = (struct ethhdr *)skb->data;
// 检查以太网头,例如获取目的MAC地址,判断是否是发给本机的包
// ...
// 设置 sk_buff 的 MAC 层头部指针和长度
skb_set_mac_header(skb, (const unsigned char *)eth - skb->head);
skb->mac_len = sizeof(struct ethhdr);
// 根据以太网类型设置 skb->protocol
if (ntohs(eth->h_proto) == ETH_P_IP) {
skb->protocol = htons(ETH_P_IP);
} else if (ntohs(eth->h_proto) == ETH_P_IPV6) {
skb->protocol = htons(ETH_P_IPV6);
}
// ...
// 移除以太网头
// skb_pull() 会将 skb->data 向后(向 tail 方向)移动 ETH_HLEN 字节
skb_pull(skb, sizeof(struct ethhdr));
// 设置 sk_buff 的网络层头部指针
skb_set_network_header(skb, (const unsigned char *)skb->data - skb->head);
// 此时:
// skb->data == (原始 skb->data) + ETH_HLEN
// skb->tail 保持不变
// skb->len == FRAME_LEN - ETH_HLEN (只剩下 IP/ARP/etc. 头和 payload)
// skb->data 指向 IP/ARP/etc. 头的起始
skb_pull(skb, len): 从skb->data处移除len字节的数据。它将skb->data向tail方向移动len字节,同时更新skb->len。
5.3 网络层到传输层
网络层(如 IP)接收到 sk_buff 后,会解析 IP 头,进行路由判断,然后移除 IP 头。
// 在 ip_rcv() 或类似函数中
struct iphdr *iph = (struct iphdr *)skb->data;
// 检查 IP 头,例如校验和、目的 IP 地址、TTL
// ...
// 根据 IP 协议字段设置 skb->protocol
skb->protocol = iph->protocol; // 例如 IPPROTO_TCP, IPPROTO_UDP
// 移除 IP 头
skb_pull(skb, iph->ihl * 4); // iph->ihl 是 IP 头长度(以 32 位字为单位)
// 设置 sk_buff 的传输层头部指针
skb_set_transport_header(skb, (const unsigned char *)skb->data - skb->head);
// 此时:
// skb->data == (原始 skb->data) + ETH_HLEN + IP_HLEN
// skb->tail 保持不变
// skb->len == FRAME_LEN - ETH_HLEN - IP_HLEN (只剩下 TCP/UDP/ICMP 头和 payload)
// skb->data 指向 TCP/UDP/ICMP 头的起始
5.4 传输层到应用层
传输层(如 TCP 或 UDP)接收到 sk_buff 后,会解析 TCP/UDP 头,根据端口号将数据包分发到相应的套接字。
// 在 tcp_v4_rcv() 或 udp_rcv() 等函数中
struct tcphdr *th = (struct tcphdr *)skb->data; // 或 struct udphdr *uh
// 检查 TCP/UDP 头,例如端口号、校验和、序列号
// ...
// 移除 TCP/UDP 头
skb_pull(skb, th->doff * 4); // TCP 头长度 (doff 是数据偏移量)
// 此时:
// skb->data == (原始 skb->data) + ETH_HLEN + IP_HLEN + TCP_HLEN
// skb->tail 保持不变
// skb->len == FRAME_LEN - ETH_HLEN - IP_HLEN - TCP_HLEN (只剩下应用数据)
// skb->data 指向应用数据的起始
最终,skb->data 指向了应用程序的实际有效载荷,skb->len 是有效载荷的长度。这些数据可以被复制到用户空间的应用程序缓冲区中,或者在支持零拷贝的情况下,直接将 sk_buff 中的页面映射到用户空间。当数据被处理完毕后,sk_buff 会被 kfree_skb() 释放。
接收路径中 sk_buff 指针和长度变化概览
| 操作阶段 | 关键函数/操作 | skb->data 指针变化 |
skb->len 变化 |
备注 |
|---|---|---|---|---|
| 网卡接收 | dev_alloc_skb(), DMA |
head + PAD |
FrameLen |
data 指向以太网头,tail 指向帧末尾 |
| 链路层处理 | skb_pull(skb, L_HLen) |
data + L_HLen |
FrameLen - L_HLen |
data 指向网络层头 |
| 网络层处理 | skb_pull(skb, N_HLen) |
data + N_HLen |
FrameLen - L_HLen - N_HLen |
data 指向传输层头 |
| 传输层处理 | skb_pull(skb, T_HLen) |
data + T_HLen |
FrameLen - L_HLen - N_HLen - T_HLen |
data 指向应用数据 |
| 最终状态 | 指向应用数据 | 应用数据长度 | 数据准备好被应用程序消费并最终释放 sk_buff |
6. 高级 sk_buff 操作与优化
sk_buff 的设计远不止简单的指针移动。为了应对现代网络的高性能需求,它集成了多种优化技术。
6.1 校验和卸载 (Checksum Offload)
skb->ip_summed 字段是校验和卸载的核心。
- 发送路径: 如果网卡支持校验和卸载,内核在发送时可以跳过计算传输层(如 TCP/UDP)校验和的步骤。它将
skb->ip_summed设置为CHECKSUM_PARTIAL,并只计算 IP 头部的校验和。网卡在发送数据前会填充缺失的校验和。 - 接收路径: 如果网卡支持校验和卸载并报告数据包的校验和已验证通过,内核会将
skb->ip_summed设置为CHECKSUM_UNNECESSARY。传输层协议可以直接跳过校验和验证,从而节省 CPU 周期。
6.2 TSO/GSO (TCP Segmentation Offload / Generic Segmentation Offload)
TSO/GSO 允许内核将一个非常大的 TCP/UDP 数据流(例如,一个 send() 调用发送了 64KB 数据)作为单个 sk_buff 提交给网卡。
skb_shinfo(skb)->gso_size: 表示原始大数据包的每个分段的最大大小(MSS)。skb_shinfo(skb)->gso_type: 表示分段的类型(例如SKB_GSO_TCPV4,SKB_GSO_UDP_L4)。
网卡会根据 gso_size 和 gso_type 在硬件中将这个大 sk_buff 分割成多个符合 MSS 的小帧,并为每个小帧添加独立的 TCP/IP 头和校验和。这大大减少了 CPU 在分段和头部处理上的开销。
// 假设要发送一个大文件,数据在用户空间的一个大缓冲区中
// skb 的线性部分可能只包含头部,数据通过 frags 指向用户空间的页面
struct sk_buff *skb = alloc_skb(ETH_HLEN + IP_HLEN + TCP_HLEN, GFP_KERNEL);
skb_reserve(skb, ETH_HLEN + IP_HLEN + TCP_HLEN);
// ... 填充以太网、IP、TCP 头 ...
// 将用户数据页附加到 skb 的 frags 数组
// 假设 data_pages 是一个页数组,total_data_len 是总长度
for (int i = 0; i < num_pages; i++) {
skb_fill_page_desc(skb, i, data_pages[i], 0, PAGE_SIZE);
}
skb_shinfo(skb)->nr_frags = num_pages;
skb->data_len = total_data_len;
skb->len += total_data_len;
// 设置 GSO 信息
skb_shinfo(skb)->gso_size = MSS; // 例如 1460
skb_shinfo(skb)->gso_type = SKB_GSO_TCPV4;
// 将 skb 提交给网络栈,网卡会处理分段
dev_queue_xmit(skb);
6.3 GRO (Generic Receive Offload)
GRO 是 TSO 的逆过程。它允许网卡或驱动程序将多个接收到的、属于同一流的小数据包(例如,多个 TCP 段)在内核中合并成一个大的 sk_buff,然后向上层协议栈提交。
- GRO 减少了每次处理一个数据包所需的开销(如中断、协议头解析),提高了吞吐量。
- 合并后的
sk_buff在skb_shinfo中仍然带有gso_type和gso_size信息,指示它是一个通过 GRO 聚合的大包。
6.4 零拷贝技术
sk_buff 是实现零拷贝(Zero-Copy)网络 I/O 的核心。零拷贝旨在减少或消除数据在内核空间和用户空间之间的复制。
sendfile()/splice(): 这些系统调用允许直接将文件数据发送到 socket,或者在两个文件描述符之间移动数据,而无需经过用户空间缓冲区。sk_buff在此过程中可以引用文件系统的页面,并通过skb_shared_info->frags将这些页面直接传递给网卡进行 DMA。vmsplice(): 允许将用户空间内存直接splice到管道(pipe)中,或从管道splice到用户空间内存。结合splice(),可以实现用户空间缓冲区到 socket 的零拷贝发送。
通过将用户页映射到 skb_shared_info->frags 数组中,sk_buff 避免了将数据从用户空间复制到内核空间的开销。
// 将用户空间的一页数据附加到 skb
// page 是用户空间内存对应的 struct page *
// offset 是页内偏移,len 是数据长度
skb_fill_page_desc(skb, skb_shinfo(skb)->nr_frags, page, offset, len);
skb_shinfo(skb)->nr_frags++;
skb->data_len += len;
skb->len += len;
6.5 skb 克隆与共享
如前所述,skb_clone() 创建一个浅拷贝,共享底层数据。这个机制在多种场景下非常有用:
- 多播/广播: 同一个数据包需要发送到多个目标,只需克隆
sk_buff的元数据,共享数据。 - Netfilter
TEE目标: Netfilter 可以将数据包复制一份到另一个网络设备或本地处理,而原始数据包继续其正常路径。克隆避免了全量复制。 - 协议处理分支: 在某些情况下,一个数据包可能需要被多个协议模块独立处理,克隆允许它们拥有自己的
sk_buff副本进行操作,同时共享底层数据以节省内存。
当所有 sk_buff 克隆都被释放时,底层数据缓冲区才会被真正释放。
7. sk_buff 在 Netfilter 中的应用
Netfilter (Linux 防火墙框架) 是 sk_buff 的重度用户。Netfilter 钩子函数 (NF_HOOK) 在数据包穿越协议栈的特定点被调用,并接收一个 sk_buff 指针作为参数。
Netfilter 模块可以:
- 检查
sk_buff: 读取skb->data,skb->len以及各种元数据(如skb->mark,skb->nfct)。 - 修改
sk_buff内容: 例如,修改数据包的有效载荷或协议头。 - 修改
sk_buff大小: 使用pskb_expand_head()增加 headroom,或使用skb_trim()减小 tailroom。 - 丢弃
sk_buff: 返回NF_DROP,sk_buff会被释放。 - 接受
sk_buff: 返回NF_ACCEPT,sk_buff继续在协议栈中流动。 - 克隆
sk_buff: 使用skb_clone()进行TEE操作。
skb->mark 字段尤其重要,它允许 Netfilter 给数据包打上标记,这些标记可以在后续的防火墙规则、路由策略或流量控制中被利用。skb->nfct 则指向 Netfilter 连接跟踪 (conntrack) 结构,用于维护连接状态。
// 示例:一个简化的 Netfilter 钩子函数
unsigned int my_nf_hook(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
// 检查数据包长度
if (skb->len < SOME_MIN_LEN) {
printk(KERN_INFO "Dropping too short packet.n");
return NF_DROP; // 丢弃数据包
}
// 检查 skb->mark
if (skb->mark == MY_SPECIAL_MARK) {
printk(KERN_INFO "Packet with special mark detected.n");
// 进行特殊处理,例如重路由
}
// 获取 IP 头
struct iphdr *iph = ip_hdr(skb);
if (iph && iph->protocol == IPPROTO_TCP) {
// 获取 TCP 头 (需要先确保有足够的 headroom)
struct tcphdr *th = tcp_hdr(skb);
if (th && ntohs(th->dest) == 80) {
printk(KERN_INFO "HTTP packet detected.n");
// 可以修改数据,例如插入广告
// pskb_expand_head(skb, new_headroom, 0, GFP_ATOMIC);
// skb_push(skb, new_header_size);
// ...
}
}
return NF_ACCEPT; // 接受数据包,继续处理
}
8. sk_buff 的性能考量与最佳实践
sk_buff 的设计哲学就是性能。为了充分利用其优势,开发者需要注意以下几点:
- 最小化数据复制: 这是
sk_buff的核心优势。尽可能使用skb_clone()而非skb_copy()。利用零拷贝机制,如sendfile()和splice(),避免用户空间和内核空间之间的数据复制。 - 合理预留
headroom: 在alloc_skb()或dev_alloc_skb()之后,如果知道协议栈将添加多少头部,应使用skb_reserve()预留足够的headroom。这可以避免后续在skb_push()时因空间不足而导致的pskb_expand_head()调用(它可能涉及内存重新分配和数据移动)。 - 利用硬件卸载: 确保网卡驱动和内核配置支持并启用了校验和卸载、TSO/GSO 等功能。合理设置
skb->ip_summed和skb_shinfo()->gso_type/gso_size。 - 高效的
sk_buff队列操作:sk_buff通常存储在各种链表(如设备发送队列、socket 接收队列)中。使用skb_queue_head_init(),skb_queue_tail(),skb_dequeue()等内核提供的 API 进行队列操作,它们是经过优化的。 - 及时释放: 当
sk_buff不再需要时,立即调用kfree_skb()或dev_kfree_skb_any()释放它。延迟释放可能导致内存泄漏或不必要的内存占用。
9. 结语
sk_buff 是 Linux 内核网络栈的灵魂,它以其精巧的内存管理和丰富的功能,支撑着 Linux 强大而高效的网络能力。通过对 sk_buff 结构、其内存封装与拆解机制,以及各种优化技术的深入理解,我们能够更好地掌握 Linux 网络数据流动的本质,并在开发和调试网络相关功能时做出更明智的决策。对 sk_buff 的透彻理解,是成为 Linux 网络领域专家的必由之路。