你在用 bpftrace 追踪一个收包延迟问题,发现
__alloc_skb 在高 PPS(Packets Per
Second)场景下吃掉了 15% 的 CPU。你想优化,但连
sk_buff 的内存布局都没搞清楚——head
和 data
指针到底指向哪里?skb_pull()
移动的是哪个指针?skb_clone() 和
skb_copy()
的代价差在哪里?skb_shared_info 里的 fragment
数组是怎么回事?
这些问题不搞清楚,后面讲收包路径、发包路径、GRO/GSO、XDP 都是空中楼阁。
本文就干一件事:把 sk_buff
这个数据结构彻底拆开。从内存布局到指针操作,从
clone 机制到 fragment
机制,从分配路径到回收路径。每一个关键字段都追到内核源码,每一个关键操作都给出函数签名和行为描述。
本文基于 Linux 6.6 LTS 内核源码(
include/linux/skbuff.h)。6.8 中的差异会单独标注。
一、sk_buff 是什么:内核网络栈的通用货币
Linux
内核网络栈处理的每一个网络包——不管是从网卡收到的以太网帧,还是用户态调用
send() 发出的 TCP 段——在内核中都被封装在一个
struct sk_buff 里。
sk_buff
不直接包含包数据。它是一个元数据结构(metadata
structure),持有指向实际数据缓冲区的指针,以及大量协议栈各层需要的状态字段。这个设计的核心思想是:
- 一个包从网卡到用户态,经过驱动、L2、L3、L4、netfilter、socket
等多个层次,每个层次都需要读写不同的元数据。
sk_buff把这些元数据集中在一个结构体里,避免每层都分配自己的上下文。 - 不同层次需要访问包的不同部分(以太网头、IP
头、TCP
头、payload),但不需要每次都复制数据。
sk_buff通过移动指针来”剥洋葱”,每层只需要调整data指针的位置。 - 同一个包可能被多个路径同时引用(例如 tcpdump
抓包和正常协议栈处理),需要高效的共享机制。
sk_buff的 clone 机制允许只复制元数据、共享底层数据。
整个 struct sk_buff 在
include/linux/skbuff.h 中定义,约 220
行。下面逐块拆解。
二、内存布局:四大指针与数据区
head、data、tail、end
sk_buff
最核心的设计是四个指针,它们划分了数据缓冲区的不同区域:
head data tail end
| | | |
v v v v
+----------+----------+---------+------------------+
| headroom | data | tailroom| skb_shared_info |
+----------+----------+---------+------------------+
// include/linux/skbuff.h, struct sk_buff(末尾字段)
sk_buff_data_t tail;
sk_buff_data_t end;
unsigned char *head,
*data;
unsigned int truesize;
refcount_t users;四个指针的含义:
| 指针 | 含义 | 谁设置 |
|---|---|---|
head |
分配的数据缓冲区起始地址,一旦分配不再移动 | __alloc_skb() |
data |
当前协议层有效数据的起始位置,随层次移动 | skb_push()/skb_pull() |
tail |
当前有效数据的结尾位置 | skb_put() |
end |
数据缓冲区的结尾,skb_shared_info
紧随其后 |
__alloc_skb() |
关键细节:在 64
位系统上,tail 和 end
不是指针,而是相对于 head
的偏移量(unsigned int 类型)。这是为了节省
sk_buff 结构体的大小:
// include/linux/skbuff.h
#if BITS_PER_LONG > 32
#define NET_SKBUFF_DATA_USES_OFFSET 1
#endif
#ifdef NET_SKBUFF_DATA_USES_OFFSET
typedef unsigned int sk_buff_data_t;
#else
typedef unsigned char *sk_buff_data_t;
#endif在 64 位系统上,用 4 字节偏移代替 8 字节指针,每个
sk_buff 省 8
字节。在每秒处理百万包的场景下,这个节省不是小数。
headroom 和 tailroom
head 到 data 之间的区域叫
headroom,tail 到
end 之间叫 tailroom。
为什么需要 headroom?考虑发包路径:应用层调用
send() 写入 payload,然后 TCP 层要在 payload
前面加 TCP 头,IP 层要在 TCP 头前面加 IP 头,以太网层要在 IP
头前面加 MAC 头。如果没有 headroom,每加一层头部都得把整个
payload 往后移——这是 O(n) 的
memmove。
有了 headroom,加头部变成了移动 data
指针:
// skb_push: 向前移动 data 指针,扩大有效数据区
static inline void *__skb_push(struct sk_buff *skb, unsigned int len)
{
skb->data -= len;
skb->len += len;
return skb->data;
}这是 O(1) 操作。同样的思路,收包时用
skb_pull() 向后移动 data
指针来”剥掉”已处理的协议头:
// skb_pull: 向后移动 data 指针,缩小有效数据区
static inline void *__skb_pull(struct sk_buff *skb, unsigned int len)
{
skb->len -= len;
// ... 省略边界检查
skb->data += len;
return skb->data;
}驱动层在分配 sk_buff 时会预留足够的
headroom,通常调用 skb_reserve() 来设定:
// skb_reserve: 在空 skb 上设置 headroom
static inline void skb_reserve(struct sk_buff *skb, int len)
{
skb->data += len;
skb->tail += len;
}skb_reserve() 只能在 skb
还没有数据时调用。典型的驱动收包流程是:
skb = __alloc_skb(size, GFP_ATOMIC, 0, NUMA_NO_NODE);
skb_reserve(skb, NET_IP_ALIGN + NET_SKB_PAD);
// 然后 DMA 把网卡数据写到 data 指针位置
skb_put(skb, pkt_len); // 标记有效数据长度指针操作全景
分配后,skb_reserve() 之前:
head = data = tail end
| |
v v
+--------------------------------------+------------------+
| 全部是 tailroom | skb_shared_info |
+--------------------------------------+------------------+
skb_reserve(skb, headroom) 之后:
head data = tail end
| | |
v v v
+--------------+------------------------+------------------+
| headroom | tailroom | skb_shared_info |
+--------------+------------------------+------------------+
skb_put(skb, pkt_len) 之后(DMA 已写入数据):
head data tail end
| | | |
v v v v
+--------------+--------------+---------+------------------+
| headroom | packet data |tailroom | skb_shared_info |
+--------------+--------------+---------+------------------+
收包时 skb_pull(skb, eth_hdr_len) 剥掉以太网头:
head data tail end
| | | |
v v v v
+--------------+------+--------+--------+------------------+
| headroom |eth hd| IP+pay |tailrm | skb_shared_info |
+--------------+------+--------+--------+------------------+
^
mac_header 记录这里的偏移
发包时 skb_push(skb, ip_hdr_len) 加上 IP 头:
head data tail end
| | | |
v v v v
+----------+--------------------+------+------------------+
| headroom |IP hdr + TCP + pay |tailrm| skb_shared_info |
+----------+--------------------+------+------------------+
这四个操作——skb_reserve()、skb_put()、skb_push()、skb_pull()——是整个网络栈数据操作的基础。理解它们就理解了”为什么网络包处理不需要大量
memcpy“。
三、关键字段分类拆解
struct sk_buff
有几十个字段。按功能分成几组来看。
链表与队列管理
union {
struct {
struct sk_buff *next;
struct sk_buff *prev;
union {
struct net_device *dev;
unsigned long dev_scratch;
};
};
struct rb_node rbnode; // netem、IP 分片重组、TCP 重传队列用
struct list_head list;
struct llist_node ll_node;
};sk_buff
用链表串成队列(sk_buff_head)。next/prev
构成双向链表。同一个 union 里还有
rbnode(红黑树节点),TCP
协议栈用它来维护有序的重传队列和乱序队列。ll_node
是 lockless list 节点,用于 socket 的 defer_list
等无锁场景。
dev
字段指向收到这个包的网卡(struct net_device),或者即将发送这个包的网卡。在某些协议路径中
dev 会被暂时设为 NULL,这时
dev_scratch 可以复用这个空间存其他数据(UDP
收包路径就这么干的)。
所属 socket 与时间戳
struct sock *sk; // 关联的 socket,收包时可能为 NULL
union {
ktime_t tstamp; // 到达/离开时间
u64 skb_mstamp_ns; // EDT: 最早出发时间
};sk 指向拥有这个包的
socket。收包早期阶段(驱动到协议栈)sk 为
NULL;协议栈完成四元组匹配后才会设置。发包时 sk
从一开始就指向发送方的 socket。
tstamp
有双重含义:收包时记录到达时间,发包时可以是 EDT(Earliest
Departure Time)——TC qdisc 用这个字段实现 pacing。
控制缓冲区 cb
char cb[48] __aligned(8);48 字节的”万能口袋”。每个协议层可以把自己的私有数据塞进这
48 字节,例如 TCP 的 tcp_skb_cb:
// include/net/tcp.h
struct tcp_skb_cb {
__u32 seq; // 起始序号
__u32 end_seq; // 结束序号
// ... 其他 TCP 元数据
};
#define TCP_SKB_CB(__skb) ((struct tcp_skb_cb *)&((__skb)->cb[0]))cb 的规则是:谁持有 skb,谁就可以用
cb。 如果你想让 cb
里的数据跨层传递,必须先
skb_clone(),否则下一层会直接覆盖。
长度字段
unsigned int len, // 有效数据总长度(线性区 + fragment)
data_len; // fragment 中的数据长度
__u16 mac_len, // MAC 头长度
hdr_len; // clone skb 的可写头部长度len
是整个包的有效数据长度,包含线性区(head buffer
中的部分)和 fragment(skb_shared_info 中引用的
page)。
data_len 是 fragment 部分的长度。所以
线性区数据长度 = len -
data_len。如果
data_len == 0,说明所有数据都在线性区(没有使用
fragment)。
协议头偏移
__be16 protocol; // L3 协议类型(ETH_P_IP, ETH_P_IPV6 等)
__u16 transport_header; // L4 头相对于 head 的偏移
__u16 network_header; // L3 头相对于 head 的偏移
__u16 mac_header; // L2 头相对于 head 的偏移这三个偏移量记录了各层协议头在数据缓冲区中的位置。注意它们是相对于
head 的偏移,不是指针。这样在 data
指针被 skb_push/skb_pull
移动之后,协议头的位置仍然可以准确找到。
收包时,各层协议处理函数会设置对应的偏移:
// 以太网层设置 mac_header
skb_reset_mac_header(skb); // mac_header = skb->data - skb->head
// IP 层设置 network_header
skb_reset_network_header(skb);
// TCP 层设置 transport_header
skb_reset_transport_header(skb);然后用
skb_mac_header()、skb_network_header()、skb_transport_header()
取回对应位置的指针。
封装协议支持
__be16 inner_protocol;
__u16 inner_transport_header;
__u16 inner_network_header;
__u16 inner_mac_header;
__u8 encapsulation:1;用于隧道和封装场景(VXLAN、GRE、GENEVE
等)。外层头用普通的
protocol/network_header
等字段,内层头用 inner_*
系列字段。encapsulation
标志位表示内层头有效。
校验和卸载
__u8 ip_summed:2; // CHECKSUM_NONE / UNNECESSARY / COMPLETE / PARTIAL
__u8 csum_valid:1;
__u8 csum_complete_sw:1;
__u8 csum_level:2;
union {
__wsum csum; // 硬件/软件计算的校验和
struct {
__u16 csum_start; // 校验和计算的起始偏移
__u16 csum_offset; // 校验和字段在包中的偏移
};
};校验和卸载(Checksum Offload)是网卡和协议栈之间的重要优化接口:
ip_summed 值 |
含义 | 场景 |
|---|---|---|
CHECKSUM_NONE |
校验和未计算 | 需要软件计算 |
CHECKSUM_UNNECESSARY |
校验和已验证正确 | 网卡硬件已验证 |
CHECKSUM_COMPLETE |
网卡提供了原始校验和 | 存在 csum 字段中,协议栈做最终验证 |
CHECKSUM_PARTIAL |
协议栈只算了伪头部 | 发包时由网卡完成最终计算 |
位标志字段
sk_buff 使用大量单比特位标志来节省空间:
__u8 cloned:1, // 是否被 clone 过
nohdr:1, // 头部数据已释放给 clone
fclone:2, // fast clone 状态
peeked:1, // 已被 MSG_PEEK 读取过
head_frag:1, // head buffer 来自 page fragment
pfmemalloc:1, // 使用 PFMEMALLOC 保留内存分配
pp_recycle:1; // page_pool 回收标记__u8 pkt_type:3; // 包类型:PACKET_HOST / BROADCAST / MULTICAST / OTHERHOST
__u8 ignore_df:1; // 忽略 Don't Fragment 标志其中 pkt_type 在收包阶段由
eth_type_trans() 根据目的 MAC 地址设置:
PACKET_HOST:目的 MAC 是本机PACKET_BROADCAST:广播包PACKET_MULTICAST:组播包PACKET_OTHERHOST:目的 MAC 不是本机(混杂模式下收到)
流量控制与路由
__u32 priority; // QoS 优先级
__u32 hash; // 流 hash(RSS/RPS 用)
__u16 queue_mapping; // 网卡硬件队列编号
__u16 tc_index; // Traffic Control 索引
__u32 mark; // netfilter 标记(iptables -j MARK)
int skb_iif; // 收包接口索引
unsigned long _skb_refdst; // 路由缓存条目hash 字段在收包时由硬件 RSS 或软件 RPS
计算,用于将同一个流的包分发到同一个
CPU。queue_mapping
标识硬件多队列网卡的具体队列编号。mark 是
netfilter 标记,iptables -j MARK --set-mark
设置的值就存在这里,策略路由 ip rule fwmark
也读这个字段。
Netfilter 连接追踪
#if defined(CONFIG_NF_CONNTRACK) || defined(CONFIG_NF_CONNTRACK_MODULE)
unsigned long _nfct;
#endif_nfct 存储 conntrack 条目指针(低 3 位存
conntrack 状态)。这是 netfilter
连接追踪的核心字段,NAT、有状态防火墙都依赖它。
引用计数与销毁
refcount_t users; // skb 元数据的引用计数
void (*destructor)(struct sk_buff *skb); // 销毁回调users 控制 sk_buff
结构体本身的生命周期。当 users 降到 0
时,kfree_skb() 或 consume_skb()
释放这个 sk_buff。注意这和
skb_shared_info.dataref
不同——后者控制数据缓冲区的生命周期。
destructor 回调在 skb 被释放时调用,用于通知
socket 层释放发送缓冲区配额。
四、skb_shared_info:数据缓冲区的另一半
在数据缓冲区的末尾(end 指针之后)紧跟着一个
struct skb_shared_info。它不是
sk_buff 的字段,而是通过
skb_shinfo(skb) 宏访问的独立结构体:
#define skb_shinfo(SKB) ((struct skb_shared_info *)(skb_end_pointer(SKB)))// include/linux/skbuff.h
struct skb_shared_info {
__u8 flags;
__u8 meta_len;
__u8 nr_frags; // fragment 数量
__u8 tx_flags; // 发送时间戳标志
unsigned short gso_size; // GSO 分段大小
unsigned short gso_segs; // GSO 分段数量
struct sk_buff *frag_list; // 附加的 skb 链表
union {
struct skb_shared_hwtstamps hwtstamps; // 硬件时间戳
struct xsk_tx_metadata_compl xsk_meta; // AF_XDP 元数据
};
unsigned int gso_type; // GSO 类型标志
u32 tskey; // 时间戳 key
atomic_t dataref; // 数据缓冲区引用计数
unsigned int xdp_frags_size; // XDP multi-buffer fragment 大小
void * destructor_arg; // zero-copy 回调上下文
skb_frag_t frags[MAX_SKB_FRAGS]; // page fragment 数组
};fragment 机制
frags[] 数组是 sk_buff
支持非线性数据的关键。一个包的数据可以分布在多个不连续的内存页中:
sk_buff
|
+-- head buffer (线性区): [headroom][eth+ip+tcp header + 部分 payload]
|
+-- skb_shared_info
+-- frags[0] -> page A, offset, len (更多 payload)
+-- frags[1] -> page B, offset, len (更多 payload)
+-- nr_frags = 2
为什么需要 fragment?
- 大包拆分:GRO(Generic Receive
Offload)把多个小包合并成一个大包时,把后续包的数据作为
fragment 挂到第一个包上,避免
memcpy。 - sendfile/splice:零拷贝发送时,payload 直接引用 page cache 中的页面,不拷贝到线性区。
- 分片重组:IP 分片重组时,各分片的数据可以直接作为 fragment,不需要拷贝到一个大的连续缓冲区。
MAX_SKB_FRAGS 在大多数配置下是
17,意味着一个 sk_buff 最多可以引用 17
个不连续的内存页。
frag_list
除了 frags[]
数组,skb_shared_info 还有一个
frag_list 字段,指向一个 sk_buff
链表。这是另一种非线性数据组织方式:
sk_buff (主包)
+-- skb_shared_info
+-- frag_list -> sk_buff -> sk_buff -> NULL
frag_list 主要用于 IP
分片重组——把各个分片的完整 sk_buff 串成链表。与
frags[] 的区别是:frags[]
引用的是裸内存页,而 frag_list 引用的是完整的
sk_buff,每个都有自己的元数据。
dataref:数据缓冲区的引用计数
atomic_t dataref;dataref 控制数据缓冲区(从 head
到 end +
skb_shared_info)的生命周期。这和
sk_buff.users(控制 sk_buff
元数据的生命周期)是独立的两层引用计数。
dataref 的高 16 位和低 16 位有不同含义:
#define SKB_DATAREF_SHIFT 16
#define SKB_DATAREF_MASK ((1 << SKB_DATAREF_SHIFT) - 1)- 低 16 位:数据缓冲区的总引用数
- 高 16 位:payload-only 引用数(只引用数据、不修改头部的 clone)
这个设计允许 TCP 在发送克隆时,让 clone 可以独立修改头部空间,而原始 skb 保持 payload 不变。
GSO/TSO 字段
unsigned short gso_size;
unsigned short gso_segs;
unsigned int gso_type;GSO(Generic Segmentation Offload)让协议栈可以把大于 MTU
的包一直传到网卡驱动层附近才做分段。gso_size
是每个分段的大小(通常等于 MSS),gso_segs
是分段数。如果网卡支持 TSO(TCP Segmentation
Offload),分段完全由硬件完成;否则在
dev_queue_xmit() 路径中由软件 GSO 完成。
五、分配与释放:热路径上的性能关键
分配路径
sk_buff 的分配涉及两次内存分配:
sk_buff结构体本身——从 slab cacheskbuff_head_cache分配- 数据缓冲区——从
kmalloc或 page allocator 分配
// net/core/skbuff.c(简化)
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct sk_buff *skb;
// 第一次分配:sk_buff 结构体
if (flags & SKB_ALLOC_FCLONE)
skb = kmem_cache_alloc_node(skbuff_fclone_cache, gfp_mask, node);
else
skb = kmem_cache_alloc_node(skbuff_head_cache, gfp_mask, node);
// 第二次分配:数据缓冲区(包含 skb_shared_info 的空间)
size = SKB_DATA_ALIGN(size);
size += SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
data = kmalloc_reserve(&size, gfp_mask, node, &pfmemalloc);
// 初始化指针
skb->head = data;
skb->data = data;
skb_reset_tail_pointer(skb);
skb->end = skb->tail + size - SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
// 初始化 skb_shared_info
memset(skb_shinfo(skb), 0, offsetof(struct skb_shared_info, dataref));
atomic_set(&skb_shinfo(skb)->dataref, 1);
return skb;
}注意 SKB_DATA_ALIGN 确保数据缓冲区对齐到
cache line,避免 false sharing。
Fast Clone 机制
TCP 发包时经常需要 clone
sk_buff(原始的留在重传队列,clone
的交给网卡发送)。普通流程是两次 slab 分配(原始 +
clone)。Fast clone 优化是一次分配两个
sk_buff:
// include/linux/skbuff.h
struct sk_buff_fclones {
struct sk_buff skb1; // 原始 skb
struct sk_buff skb2; // 预分配的 clone 位置
refcount_t fclone_ref; // 共享引用计数
};当调用 alloc_skb_fclone() 时,分配一个
sk_buff_fclones 结构体,后续
skb_clone() 直接使用预分配的
skb2,省掉一次
kmem_cache_alloc。
NAPI 批量分配
在高 PPS 场景下,每收一个包就调用一次
__alloc_skb() 的开销很大。NAPI 驱动可以使用
napi_alloc_skb() 从 per-CPU 的 page frag cache
中分配,减少锁竞争和 slab 分配开销。
// 标志位
#define SKB_ALLOC_NAPI 0x04
// 驱动收包热路径
skb = napi_alloc_skb(napi, len);释放路径
释放 sk_buff 有两个入口:
// 正常消费完毕(协议栈处理完成)
void consume_skb(struct sk_buff *skb);
// 异常丢弃(队列满、校验和错误等)
void kfree_skb(struct sk_buff *skb);
void kfree_skb_reason(struct sk_buff *skb, enum skb_drop_reason reason);kfree_skb 会触发
trace_kfree_skb
tracepoint,这是追踪网络丢包的关键手段。consume_skb
触发 trace_consume_skb,不算丢包。6.x 内核的
kfree_skb_reason
还会携带丢包原因枚举(enum skb_drop_reason),drop_monitor
等工具用这个信息定位具体的丢包原因。
释放流程:
- 减少
users引用计数 - 如果降到 0,调用
destructor回调 - 减少
dataref - 如果
dataref也降到 0,释放数据缓冲区 - 如果有 fragment,释放各 page 引用
- 如果
pp_recycle标志位设置,通过 page_pool 回收而非直接释放
page_pool 回收
传统的 skb 释放路径需要把 page 还给伙伴系统(buddy allocator),这在高 PPS 场景下是瓶颈。page_pool 机制在驱动和网络栈之间建立了一个”回收池”:
// pp_recycle 标志位
__u8 pp_recycle:1; // 标记这个 skb 的 page 来自 page_pool当 pp_recycle 为 1 时,skb 释放时会把 page
还给驱动的 page_pool
而非伙伴系统,大幅减少分配/释放开销。这是 Linux 5.x
以后网络性能优化的关键特性之一。
六、clone 与 copy:共享 vs 深拷贝
skb_clone:只复制元数据
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask);skb_clone() 创建一个新的
sk_buff
结构体,但共享底层数据缓冲区:
原始 skb: [sk_buff A] ---> [head buffer + skb_shared_info]
^
clone skb: [sk_buff B] ----/ |
dataref = 2
clone 只需要一次 slab 分配(新的 sk_buff
结构体),不需要复制任何包数据。代价是:clone 和原始 skb
不能独立修改数据缓冲区(因为共享同一块内存)。
典型使用场景:
- tcpdump/AF_PACKET 抓包:把包 clone 一份给抓包进程,原始包继续走协议栈。因为抓包只读不写,clone 完全够用。
- TCP 重传:TCP 把 payload skb 标记为 nohdr,clone 出来的副本可以独立修改头部空间。
skb_copy:完整深拷贝
struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask);skb_copy() 创建一个完全独立的副本——新的
sk_buff 结构体 + 新的数据缓冲区 +
memcpy 所有数据。代价是:需要分配数据缓冲区 +
复制所有包数据。
pskb_copy:部分拷贝
struct sk_buff *__pskb_copy(struct sk_buff *skb, int headroom, gfp_t gfp_mask);pskb_copy()
是折中方案:复制线性区数据(头部),但共享 fragment
页面。适用于需要修改协议头但不需要修改 payload 的场景。
三者对比
| 操作 | 复制元数据 | 复制线性区 | 复制 fragment | 分配次数 | 典型用途 |
|---|---|---|---|---|---|
skb_clone |
是 | 否(共享) | 否(共享) | 1(slab) | 抓包、多路径分发 |
pskb_copy |
是 | 是 | 否(共享) | 2(slab + 数据) | 修改头部但不改 payload |
skb_copy |
是 | 是 | 是(深拷贝) | 2+(slab + 数据 + pages) | 需要完全独立修改 |
在高 PPS 场景下,skb_clone 和
skb_copy 的性能差距是数量级的。能用
skb_clone 的地方绝不用
skb_copy,这是内核网络栈的基本原则。
七、truesize:内存计费
unsigned int truesize;truesize 记录这个 sk_buff
实际消耗的总内存,包括 sk_buff
结构体本身 + 数据缓冲区 + fragment
页面。它的用途是内存计费——TCP/UDP 协议栈用
truesize 来计算 socket
接收/发送缓冲区的使用量,决定是否触发内存压力(sk_rmem_alloc
/ sk_wmem_alloc)。
truesize 的初始值通常是:
skb->truesize = SKB_TRUESIZE(size);
// 其中 SKB_TRUESIZE(X) = SKB_DATA_ALIGN(X) +
// SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) +
// sizeof(struct sk_buff)truesize 的准确性直接影响 TCP
的流控和内存管理。如果 truesize 偏大,socket
缓冲区会”虚满”,导致正常包被丢弃(sk_rcvbuf
报满)。如果偏小,可能导致内存使用失控。GRO 聚合后
truesize 的计算是一个容易出 bug
的地方——合并多个包时必须正确累加 truesize。
八、可观测性:用 bpftrace 追踪 sk_buff
追踪 skb 分配热点
# 统计 __alloc_skb 的调用者和调用频率(采样 5 秒)
bpftrace -e '
kprobe:__alloc_skb {
@alloc[kstack(3)] = count();
}
END { print(@alloc, 10); }
' -d 5追踪 skb 释放与丢包
# 追踪 kfree_skb_reason,统计丢包原因
bpftrace -e '
tracepoint:skb:kfree_skb {
@drop_reason[args->reason] = count();
@drop_location[ksym(args->location)] = count();
}
'在 6.x 内核中,kfree_skb_reason 的
reason 字段是
enum skb_drop_reason,可以精确到”TCP
校验和错误”“conntrack 表满”“netfilter
DROP”等具体原因。这比以前只知道”包被丢了”进步巨大。
观察 slab 缓存状态
# 查看 skbuff 相关 slab 缓存的使用情况
cat /proc/slabinfo | head -2
cat /proc/slabinfo | grep skbuff输出示例(以下数据为示意):
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
skbuff_head_cache 12845 13056 256 32 2
skbuff_fclone_cache 3280 3360 576 28 4
skbuff_head_cache 是普通
sk_buff 的 slab 缓存,对象大小约 256
字节(具体取决于内核配置和对齐)。skbuff_fclone_cache
是 fast clone 的缓存,大小约为普通缓存的 2 倍(因为包含两个
sk_buff + 引用计数)。
追踪 skb 在收包路径中的生命周期
# 追踪一个收包 skb 经过的关键函数
bpftrace -e '
kprobe:__netif_receive_skb_core { @recv = count(); }
kprobe:ip_rcv { @ip = count(); }
kprobe:tcp_v4_rcv { @tcp = count(); }
kprobe:__skb_recv_datagram { @deliver = count(); }
kprobe:__kfree_skb { @free = count(); }
interval:s:1 {
printf("recv=%d ip=%d tcp=%d deliver=%d free=%d\n",
@recv, @ip, @tcp, @deliver, @free);
clear(@recv); clear(@ip); clear(@tcp);
clear(@deliver); clear(@free);
}
'九、sk_buff 与 XDP 的关系
XDP(eXpress Data Path)的核心优势之一就是绕过
sk_buff 分配。在 XDP
处理路径中,包数据用更轻量的 struct xdp_buff
表示:
struct xdp_buff {
void *data;
void *data_end;
void *data_meta;
void *data_hard_start;
struct xdp_rxq_info *rxq;
struct xdp_txq_info *txq;
u32 frame_sz;
u32 flags;
};xdp_buff 没有协议头偏移、没有 conntrack
指针、没有 socket 引用——它只有”一段数据+长度”。XDP
程序直接操作这段数据,用 XDP_DROP /
XDP_TX / XDP_REDIRECT
做出转发决策。只有当包需要进入正常协议栈时(XDP_PASS),才会通过
xdp_build_skb_from_buff() 转换为
sk_buff。
这意味着在 XDP 的 DROP 和 TX 路径中,sk_buff
分配的开销被完全跳过。在 DDoS
防御场景下,这个差异是每秒丢弃百万包和千万包的区别。
关于 XDP 的内核实现细节,参见本系列 21-xdp-internals。关于 XDP 的应用层面,参见 eBPF 系列:XDP。
十、小结
sk_buff 是理解 Linux
内核网络栈的第一块拼图。总结几个关键判断:
sk_buff是元数据容器,不是数据容器。 它持有指向数据缓冲区的指针,而非数据本身。这个分离设计让 clone(共享数据)成为可能。四大指针操作(
skb_reserve/skb_put/skb_push/skb_pull)是整个协议栈避免memcpy的基础。 添加和剥离协议头不需要移动数据,只需要移动指针。两层引用计数(
users+dataref)支撑了 clone 机制。skb_clone只需一次 slab 分配,这是 tcpdump 零开销抓包的根本原因。truesize驱动了 TCP/UDP 的内存管理。 它的准确性直接影响 socket 缓冲区水位判断,错误的truesize会导致”缓冲区报满但实际没满”。在高 PPS 场景下,
sk_buff分配/释放本身就是性能瓶颈。 page_pool 回收、NAPI 批量分配、fast clone 都是针对这个瓶颈的优化。XDP 则直接绕过了sk_buff。
下一篇我们拆解 net_device
和网卡驱动模型——sk_buff
是怎么被创建出来的,取决于驱动怎么跟内核网络栈交互。
参考文献
- Linux 内核源码,
include/linux/skbuff.h,6.6 LTS / 6.8 - Linux 内核源码,
net/core/skbuff.c,6.6 LTS - Jonathan Corbet, “Network buffers and memory management”, LWN.net, 2022
- Memory management for network devices, Linux kernel
documentation,
Documentation/networking/page_pool.rst
下一篇:net_device 与网卡驱动模型:从硬件到内核的接口契约
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Kubernetes 网络深度系列】Linux 网络栈全景:一个包从网卡到用户态的完整旅程
从 NIC 驱动到用户态 read(),一个网络包在 Linux 内核中到底经历了什么?本文拆解 sk_buff、NAPI、softirq、netfilter 的完整收包路径,并用 bpftrace 实测追踪每一跳的延迟。
【Linux 网络子系统深度拆解】收包路径全解:从 NIC 中断到 socket 接收队列
一个网络包从网卡 DMA 到用户态 recvmsg(),要走过硬中断、NAPI 轮询、GRO 聚合、协议分发、IP 路由、Netfilter 钩子、TCP/UDP 处理、socket 队列八个阶段。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 RX 收包路径,量化每一跳的 CPU 开销,并用 bpftrace 实测各阶段延迟分布。
【Linux 网络子系统深度拆解】软中断与 ksoftirqd:网络包处理的调度引擎
网络包到达网卡后,真正消耗 CPU 的处理全部发生在软中断上下文。本文从 Linux 6.6 内核源码出发,拆解 softirq 10 向量优先级体系、__do_softirq() 主循环与 MAX_SOFTIRQ_RESTART 放弃策略、ksoftirqd 调度时机、Threaded NAPI 替代方案,以及 CONFIG_PREEMPT_RT 下的行为变化。最后用 bpftrace/perf 实测软中断延迟和 time_squeeze 饥饿。
【Linux 网络子系统深度拆解】net_device 与网卡驱动模型:从硬件到内核的接口契约
net_device 是 Linux 内核中一切网络设备的抽象——物理网卡、虚拟 veth、隧道设备都实现同一套接口。本文从 Linux 6.6 源码出发,拆解 net_device 的结构体布局、net_device_ops 驱动操作表、NAPI 轮询模型、多队列架构、DMA ring buffer 与中断机制。