Linux sk_buff 深度解析(一):数据结构、四指针与核心字段
写在前面
sk_buff 是 Linux 网络栈最核心、也最容易被误解的数据结构之一。它并不是“装着一个完整网络包的结构体”,而是贯穿驱动、协议栈、队列、Socket、Netfilter、tc、BPF 与硬件 offload 的控制对象和所有权载体。
从 sk_buff 为什么不是一个简单 buffer 出发,系统解释 skb shell、linear head、shared info、四指针、header offset、核心字段与数据区操作。
本文以本地 Linux 5.10.209 源码为主线;涉及演进时对照 Linux 6.1.99,涉及网卡驱动时结合 Firefly RK3588 SDK Linux 5.10.198。本文是源码分析,不把尚未执行的 QEMU、tracepoint 或开发板实验写成已验证结论。
阅读前先建立四个问题
阅读任何 skb 代码路径,都建议反复追问:
- 当前
skb->data指向哪一层协议头,linear 与 non-linear 数据分别在哪里? - 哪些字段刚被修改,下一层为什么依赖这些字段?
- 当前谁持有 skb shell、head、frag page 以及 socket/dst/conntrack 等外部引用?
- 成功、失败、重试、redirect 和丢弃分支分别由谁消费或释放 skb?
本篇核心结论
- skb 是控制对象,包数据可能分散在 linear head、page frags 和子 skb 中。
head <= data <= tail <= end描述 linear head,不等于整个逻辑包。- 协议头使用相对
head的 offset,因而能够承受 push/pull 和 head 重分配。 - 读字段时要同时问:谁写入、谁读取、当前谁持有 skb。
1. 为什么 Linux 网络栈需要 sk_buff
问题
- Linux 网络栈为什么不只传递一个普通 buffer 指针?
head/data/tail/end分别表示什么?- headroom、linear data、tailroom 和
skb_shared_info如何布局? - skb 本体、head buffer、page frag 为什么有不同生命周期?
- 一个新 skb 从分配、构造到释放的基本过程是什么?
结论摘要
struct sk_buff 不是“网络包数据本身”,而是网络包的控制对象和元数据容器。真正的线性数据通常位于独立的 head buffer 中,非线性 payload 还可能位于 page fragments 或其它 skb 组成的 frag_list 中。
贯穿本阶段的长度恒等式是:skb->len = skb_headlen(skb) + skb->data_len。
四指针模型描述 head buffer 的线性部分:
1 | head <= data <= tail <= end |
head:已分配 head buffer 的起始地址;data:当前协议层看到的线性数据起点;tail:当前线性数据末尾;end:线性可用区末尾,也是skb_shared_info的起始位置;[head, data):headroom;[data, tail):当前线性数据;[tail, end):tailroom,仅在线性 skb 语义下可直接追加。
需要特别注意:在 64 位内核中,本地源码定义了 NET_SKBUFF_DATA_USES_OFFSET,所以 tail 和 end 在结构体里实际是相对 head 的无符号 offset,而不是 C 指针。所谓“四指针模型”是概念模型;访问实际地址应使用 skb_tail_pointer()、skb_end_pointer() 等 helper。
一、为什么不能只使用普通 buffer 指针
网络包经过驱动、二层、三层、四层、Netfilter、qdisc、socket 等多个子系统。仅传递 void *data 无法同时解决以下问题。
1. 协议头需要频繁增加和移除
收包时,协议栈会逐层解析并“移过”当前视图;发包时,会从 payload 开始逐层向前添加 TCP/UDP、IP 和 Ethernet header。
使用 data 指针和预留 headroom 后,可以通过移动指针表达当前协议层,不必每经过一层就复制整个包。
2. 网络包需要排队和调度
skb 自带 next/prev,可以进入 socket queue、backlog、qdisc、TCP 重传队列等。包数据不需要为了排队而改变布局。
3. 网络栈需要携带大量元数据
例如:
- 输入/输出设备;
- socket 所有者;
- 路由
dst; - MAC、network、transport header offset;
- checksum offload 状态;
- hash、RX/TX queue;
- VLAN、GSO、timestamp、mark、priority;
- destructor 和引用计数。
这些内容不是线速报文的一部分,不能直接塞入报文字节流。
4. 数据可能不是一段连续内存
高性能收发包会使用 page fragment、scatter-gather、GRO/GSO、零拷贝。skb 允许“一个逻辑网络包”由 linear data、frags[] 和 frag_list 共同组成。
5. 元数据与数据需要独立共享
clone 可以创建新的 skb shell,同时共享 head buffer 或 page 数据。这样不同路径可以拥有独立的队列节点和协议元数据,而不复制完整 payload。
因此可以把 skb 理解为:
1 | sk_buff = 包的控制块 + 数据布局描述 + 协议元数据 + 生命周期管理 |
二、三个不同的对象
初学时最重要的是不要把下面三个对象混在一起。
1 | 对象 1:struct sk_buff |
它们不是一次分配,也不是靠同一个引用计数保护。
三、linear head buffer 的内存布局
典型布局如下:
1 | 低地址 高地址 |
skb_shinfo(skb) 的定义直接把 skb_end_pointer(skb) 转换为 struct skb_shared_info *:
1 |
位置见 kernel-5.10/include/linux/skbuff.h:1464。
因此:
end不是整块 kmalloc 内存的真正末尾;end是 linear 可用区的末尾;- 从
end开始存放skb_shared_info; - 分配 head buffer 时必须额外给 shared info 留空间。
四、四指针分别代表什么
1. head
head 是 head buffer 的基址。正常的 push/pull/put/reserve 不改变它,只有重新分配 head 的操作才可能改变它。
2. data
data 是当前线性数据起点,也是协议栈当前视图的起点。
skb_push():data向低地址移动,通常用于添加协议头;skb_pull():data向高地址移动,通常用于移除或越过协议头。
data 不一定始终指向 Ethernet header、IP header 或 payload;它的含义取决于当前执行阶段。
3. tail
tail 表示 linear data 的结束位置,也就是下一个可追加字节的位置。skb_put() 会向后推进 tail 并增加 len。
对于非线性 skb,tail 仍只描述 linear 区结束位置,不描述 page fragment 的末尾。
4. end
end 表示 linear 可用区的末尾,skb_shared_info 从这里开始。
在 64 位配置下:
1 | skb_end_pointer(skb) = skb->head + skb->end; |
对应:
kernel-5.10/include/linux/skbuff.h:606-614kernel-5.10/include/linux/skbuff.h:1432-1439kernel-5.10/include/linux/skbuff.h:2244-2258
五、必须记住的长度关系
1. headroom
1 | headroom = data - head |
实现位于 kernel-5.10/include/linux/skbuff.h:2401-2404。
2. linear data 长度
1 | skb_headlen(skb) = skb->len - skb->data_len |
实现位于 kernel-5.10/include/linux/skbuff.h:2164-2167。
3. 总长度
1 | skb->len = linear data length + non-linear data length |
data_len == 0 表示 skb 是纯线性的;skb_is_nonlinear() 直接检查 data_len,见 kernel-5.10/include/linux/skbuff.h:2159-2162。
4. tailroom
概念上:
1 | tailroom = end - tail |
但本地 5.10 的 skb_tailroom() 对非线性 skb 直接返回 0:
1 | return skb_is_nonlinear(skb) ? 0 : skb->end - skb->tail; |
见 kernel-5.10/include/linux/skbuff.h:2412-2415。
这是一条重要语义:即使 head buffer 中从 tail 到 end 可能仍有物理空间,也不能简单假定普通 skb_put() 能安全用于非线性 skb。
5. truesize
truesize 不是报文长度,而是用于内存计量的近似实际占用。宏 SKB_TRUESIZE() 把以下内容计入:
- 数据区容量;
- 对齐后的
struct sk_buff; - 对齐后的
struct skb_shared_info。
定义见 kernel-5.10/include/linux/skbuff.h:238-241。
因此:
1 | truesize 通常大于 len |
后续 socket 内存限制使用的是 truesize,而不是线速包长。
六、一个新 skb 的初始状态
__alloc_skb() 的关键步骤位于 kernel-5.10/net/core/skbuff.c:183-263。
1. 分配 skb shell
1 | skb = kmem_cache_alloc_node(cache, ...); |
2. 为数据区和 shared info 分配连续内存
1 | size = SKB_DATA_ALIGN(size); |
然后用 SKB_WITH_OVERHEAD() 把 shared info 占用从 linear 可用容量中扣除。
3. 初始化四指针/offset
1 | skb->head = data; |
所以刚分配完成时:
1 | head == data == tail |
4. 初始化两个不同的引用计数
1 | refcount_set(&skb->users, 1); |
users保护 skb shell;dataref保护共享 head/data 区。
当前阶段只记住两者不是同一个引用计数;clone 细节放到阶段 4。
七、reserve、put、push、pull 如何改变模型
假设刚分配的 skb 状态为:
1 | head = data = tail |
1. skb_reserve(skb, R)
1 | data += R |
结果是预留 R 字节 headroom。源码明确说明只允许用于空 skb,见 kernel-5.10/include/linux/skbuff.h:2433-2444。
2. skb_put(skb, N)
1 | tail += N |
它在尾部扩展 linear data,并返回原 tail 地址供调用者写入,见 kernel-5.10/include/linux/skbuff.h:2291-2299。
3. skb_push(skb, H)
1 | data -= H |
它消耗 headroom,在当前数据前增加 H 字节,常用于添加协议头,见 kernel-5.10/include/linux/skbuff.h:2347-2353。
4. skb_pull(skb, H)
1 | data += H |
它越过前 H 字节,使当前视图进入下一层协议,见 kernel-5.10/include/linux/skbuff.h:2355-2361。
示例
1 | 初始: |
整个过程中没有搬移原有 payload,只修改地址/offset 和长度。
八、为什么 skb_shared_info 放在 end 后面
源码注释说明 shared info 是跨 clone 共享的不变量,位于 header data 的末尾,见 kernel-5.10/include/linux/skbuff.h:512-515。
这种布局有几个好处:
- skb shell 与数据 buffer 可以独立分配和共享;
- frag/GSO/dataref 元数据天然跟随共享数据区;
- linear 数据可以从前向后增长,直到
end; skb_shinfo(skb)可以由 end 直接计算,不需要 skb 额外保存一个 shared-info 指针;- 分配器可能给出比请求更大的 kmalloc 块,代码可以把 shared info 放到实际分配区尾部,最大化 linear 可用空间,见
kernel-5.10/net/core/skbuff.c:204-218。
九、生命周期总览
当前只建立高层流程:
1 | 分配 skb shell + head buffer |
本地释放路径:
kernel-5.10/net/core/skbuff.c:598-626— head/data 与 frags;kernel-5.10/net/core/skbuff.c:661-680— 外部状态和整体释放;kernel-5.10/net/core/skbuff.c:691-713—__kfree_skb()/kfree_skb()。
这一流程再次证明:skb shell、外部状态、head buffer 和 frag page 是分层释放的。
十、阶段 0 的关键不变量
不变量 1:四指针顺序
1 | head <= data <= tail <= end |
如果 push 超过 headroom,可能破坏 head <= data;如果 put 超过 tailroom,可能越过 end。checked API 会做更多检查,但不能依赖非法调用自动恢复。
不变量 2:长度关系
1 | len >= data_len |
__skb_pull() 中存在:
1 | BUG_ON(skb->len < skb->data_len); |
因为 pull 只能从当前 linear 头部移除数据,不能让总长度小于仍然挂载的非线性长度。
不变量 3:end 指向 shared info 起始位置
1 | skb_shinfo(skb) == (struct skb_shared_info *)skb_end_pointer(skb) |
任何错误越过 end 的写操作都会破坏 frag、GSO 或引用计数元数据,后果通常不是简单丢一个包,而是内存破坏。
不变量 4:data 是动态视图,不是固定协议头
不能看到 skb->data 就假定它永远指向 Ethernet/IP/TCP。必须结合当前调用点以及 MAC/network/transport header offset 判断。
不变量 5:逻辑包长度不等于 linear 长度
1 | len != tail - data // 非线性 skb 时通常不相等 |
更通用的关系是:
1 | linear length = skb_headlen(skb) = len - data_len |
十一、常见误区
误区 1:“struct sk_buff 里面装着整个网络包”
错误。skb 主要保存元数据,linear 数据位于独立 head buffer,payload 还可能在 page 中。
误区 2:“四个成员在所有架构上都是指针”
错误。64 位本地内核中 tail/end 是 offset;四指针是概念模型。
误区 3:“end 是整块分配内存的最后一个字节”
错误。end 是 linear 可用区末尾,后面还有 skb_shared_info。
误区 4:“tail 是整个包的末尾”
错误。对非线性 skb,tail 只是 linear data 的末尾。
误区 5:“len 等于 tail - data”
只对纯线性 skb 成立。非线性 skb 还要包含 data_len。
误区 6:“kfree_skb 就是 kfree(skb)”
错误。它需要先处理 users,释放 dst、destructor、conntrack、extensions、frags、frag_list 和 head buffer,最后才释放 skb shell。
相关知识横向扩展
struct sk_buff_head如何把 skb 组织为带锁队列;- header offset 为什么比裸指针更适合 head reallocation;
- clone 时
dataref如何拆分 full-data ref 与 payload-only ref; - page_pool 如何让 frag page 返回 RX 内存池;
- GRO/GSO 为什么依赖
skb_shared_info; - socket 为什么使用
truesize做收发内存记账。
最小生命周期调用链
1 | alloc_skb()/napi_alloc_skb() |
字段与所有权统一核对表
| 对象或字段 | 表达内容 | 所有权与释放要点 |
|---|---|---|
skb shell / users |
控制对象及其共享次数 | 最后一个 shell 引用归零后才释放结构体 |
head / dataref |
linear buffer 与尾部 shared info | clone 可共享;最后一个 dataref 释放 head |
frags[] / page ref |
非线性 page payload | shared info 记录片段,free 时逐页归还引用 |
frag_list |
子 skb 链 | 父 skb 持有并在释放路径递归消费 |
sk、dst、nfct |
外部子系统状态 | 各自有独立引用或 destructor,不能由 users 代替 |
2. struct sk_buff 字段地图
结论摘要
struct sk_buff 不应按声明顺序死记。更有效的方式是按“谁在使用字段”分为:队列与调度、设备与 socket、外部引用、长度与数据布局、协议头位置、checksum/offload、流与队列选择、VLAN/隧道、临时控制块、引用计数与扩展九组。字段布局同时承担三类工程目标:热路径访问、结构体空洞填充和 clone 时批量复制。
一、队列与节点字段
struct sk_buff 的开头是:
1 | struct sk_buff *next; |
见 kernel-5.10/include/linux/skbuff.h:720-738。它们必须位于最前面,因为 struct sk_buff_head 的前两个成员同样是 next/prev,见 kernel-5.10/include/linux/skbuff.h:294-301。队列 helper 把 queue head 当作循环双链表哨兵,例如 __skb_queue_head() 会把 (struct sk_buff *)list 传给插入逻辑。
开头的 union 还允许同一块空间作为:
- 普通 skb 双链表节点;
struct rb_node,供 netem、IPv4 defrag、TCP 等红黑树场景使用;struct list_head。
关键约束是一个 skb 在同一时刻不能被无约束地同时挂入多种容器;当前持有者必须清楚节点空间的用途。
二、设备、socket 与外部状态
| 字段 | 主要语义 | 生命周期关注点 |
|---|---|---|
dev |
当前输入或输出网络设备 | redirect、routing 后可能变化 |
sk |
关联 socket | clone 默认不复制 owner;由 destructor 配合记账 |
_skb_refdst |
路由/dst 缓存及低位标志 | clone 需增加引用,free 时 skb_dst_drop() |
destructor |
skb head state 的释放回调 | 常见为 socket 收发内存回调 |
_nfct |
conntrack 引用与状态位 | clone/copy/free 必须维护引用 |
extensions |
可选 skb 扩展 | 先检查 active_extensions |
__copy_skb_header() 明确不复制旧 sk,但会复制 dev、控制块、dst、extensions 和 Netfilter 状态,见 kernel-5.10/net/core/skbuff.c:939-985。因此“复制 skb header”并不等于机械复制整个结构体。
三、长度字段
1 | unsigned int len; |
len:逻辑包总长度,包含 linear、frags 和 frag_list;data_len:非线性部分长度;mac_len:MAC header 长度,通常由 network header 与 MAC header offset 之差得到;hdr_len:headerless clone 场景记录可写 header 空间;truesize:内存计量值,不是线速包长。
核心关系:
1 | skb_headlen(skb) = len - data_len |
四、协议头字段为什么是 offset
主要字段:
1 | transport_header |
见 kernel-5.10/include/linux/skbuff.h:900-912。访问 helper 通过 head + offset 得到地址,例如:
1 | skb_network_header(skb) = skb->head + skb->network_header; |
见 kernel-5.10/include/linux/skbuff.h:2555-2626。
使用 offset 而不是裸指针的主要价值:
data会被 push/pull,header 位置仍可独立保存;pskb_expand_head()可能更换 head buffer,复制 offset 后无需逐个修正绝对指针;- 16 位 offset 比 64 位指针节省结构体空间;
- inner/outer header 可以同时表达隧道封装。
需要区分:
skb_reset_network_header():把 network header 设为当前data;skb_set_network_header(skb, n):设为当前data + n;skb_network_header():从head + offset取地址。
五、临时控制块 cb[48]
cb[48] 定义于 kernel-5.10/include/linux/skbuff.h:749-755。源码注释给出的所有权规则是:
当前把 skb 排队的层拥有控制块;如果要跨层保留,必须明确复制或保存。
典型 overlay:
- IPv4:
IPCB(skb)→struct inet_skb_parm,kernel-5.10/include/net/ip.h:47-63,104; - IPv6:
IP6CB(skb)→struct inet6_skb_parm; - TCP:
TCP_SKB_CB(skb)→struct tcp_skb_cb,kernel-5.10/include/net/tcp.h:838-899; - GRO:
NAPI_GRO_CB(skb)→struct napi_gro_cb,kernel-5.10/include/linux/netdevice.h:2487-2554; - qdisc:
struct qdisc_skb_cb,kernel-5.10/include/net/sch_generic.h:420-429。
这些结构共享同一 48 字节空间,所以不能同时假设多个 overlay 都有效。进入新子系统时,旧层 cb 内容可能被覆盖。
__copy_skb_header() 使用 memcpy(new->cb, old->cb, sizeof(old->cb)) 复制控制块,但这只保证字节被复制,不保证其跨层语义仍然有效。
六、checksum 与 offload 字段
主要字段包括:
ip_summed;csum,或 union 中的csum_start/csum_offset;csum_valid、csum_level、csum_complete_sw、csum_not_inet;- shared info 中的
gso_size/gso_segs/gso_type。
ip_summed 只有两位,编码 CHECKSUM_NONE/UNNECESSARY/COMPLETE/PARTIAL。它表达的是“checksum 责任和状态”,不是简单的布尔值。GSO 元数据放在 shared info,是因为它属于共享数据布局而不是某个独立 skb shell 的私有队列状态。
七、hash、CPU 与队列字段
| 字段 | 用途 |
|---|---|
hash |
保存硬件 RSS 或软件 flow dissector hash |
l4_hash |
hash 是否为规范的四元组/L4 hash |
sw_hash |
hash 是否由软件计算 |
queue_mapping |
RX/TX 队列映射;TX 时影响 qdisc/设备队列 |
napi_id |
RX 来源 NAPI 标识 |
sender_cpu |
与 napi_id 复用空间,供 XPS 使用 |
skb_iif |
输入接口 ifindex |
这些字段把“包内容”转换为调度决策:在哪个 CPU 处理、进入哪个 backlog、选择哪个 TX queue。
八、VLAN、隧道和策略字段
protocol:驱动/二层解析得到的上层协议;vlan_proto/vlan_tci/vlan_present:硬件剥离或 metadata 形式的 VLAN tag;inner_protocol与 inner header offset:隧道内层协议;encapsulation:inner header 是否有效;mark:策略路由、Netfilter、tc 等通用标记;priority:排队优先级;tc_index、tc_skip_classify、tc_at_ingress、redirected:tc 数据路径状态。
注意 union 复用:mark 与 reserved_tailroom 共享空间,napi_id 与 sender_cpu 共享空间。字段当前语义取决于所在路径。
九、headers_start/headers_end 的意义
headers_start 和 headers_end 是零长度数组标记,不保存数据。两者之间的字段在 clone/copy header 时由一次 memcpy() 批量复制,见:
- 标记:
kernel-5.10/include/linux/skbuff.h:798-919; - 复制:
kernel-5.10/net/core/skbuff.c:954-983。
CHECK_SKB_FIELD() 用编译期断言确保 protocol、checksum、hash、priority、header offset、VLAN、mark 等字段位于批量复制范围内。
这说明字段排列不只是可读性问题,还与复制热路径、空洞和 ABI/KABI 约束相关。queue_mapping 因避免 16 位空洞而留在该区间外,单独复制。
十、结构尾部字段
结构尾部为:
1 | tail, end, head, data, truesize, users, extensions |
见 kernel-5.10/include/linux/skbuff.h:939-950。__alloc_skb() 只清零到 tail 之前,然后显式初始化尾部,源码还警告不要随意在 tail 后增加字段,见 kernel-5.10/net/core/skbuff.c:221-241。
在 64 位内核中 tail/end 是 offset,head/data 是指针。users 保护 skb shell;共享 head buffer 的引用计数位于 skb_shared_info.dataref。
十一、字段修改者索引
| 字段组 | 典型写入者 | 典型读取者 |
|---|---|---|
dev/protocol |
驱动、eth_type_trans()、routing/redirect |
RX core、L3、qdisc、driver |
| header offsets | Ethernet/IP/TCP/隧道 helper | 协议解析、checksum、BPF |
hash |
驱动 RSS、flow dissector | GRO、RPS/RFS、socket reuseport |
queue_mapping |
RX 驱动、TX queue selection | qdisc、ndo_start_xmit |
| checksum | RX driver、协议栈、GSO helper | L3/L4、TX offload |
cb |
当前协议或队列层 | 同一层后续函数 |
sk/destructor |
socket ownership helper | memory accounting/free |
| dst/nfct/extensions | routing/Netfilter/扩展子系统 | output、clone、free |
十二、常见误区
skb->data不是固定协议头;header offset 才保存各层位置。cb不是全局稳定元数据,不同层会覆盖它。queue_mapping不是 CPU 编号,而是设备 RX/TX queue 语义。hash不保证来自硬件,也不保证一定包含 L4 端口。mark、priority、tc_index语义不同,不能互换。headers_start/end不是缓存行边界,而是批量复制边界。- 条件编译会改变结构体实际大小和字段存在性,不能只凭某个配置的
sizeof推广到所有内核。
字段与所有权统一核对表
| 对象或字段 | 主要修改者/使用者 | 所有权与释放要点 |
|---|---|---|
data/tail/end |
分配和 data helper | linear head 布局;shell 私有 |
_skb_refdst/_nfct |
路由、Netfilter | 引用随 clone/copy/free 转移 |
sk/destructor/truesize |
socket owner helper | destructor 归还 socket 记账 |
最小调用链
1 | alloc/init |
3. 四指针、数据区与 Header 操作
结论摘要
skb 数据操作分为三类:移动当前视图的 push/pull,扩展有效数据的 put,预留未来头部空间的 reserve。header helper 用相对 head 的 offset 保存协议头位置。只要操作不重新分配 head,已保存的 header offset 仍有效;pskb_expand_head()、COW、linearize 等可能更换或重排数据,调用者必须遵守 helper 的返回和指针失效规则。
一、基础操作的状态转换
skb_reserve
kernel-5.10/include/linux/skbuff.h:2433-2444:
1 | skb->data += len; |
前置条件是空 skb。它不改变 len,只把 headroom 增大、tailroom 减小。典型用途是在构造包之前为 L2/L3/L4 header 或对齐保留空间。
skb_put
checked 版本声明于 kernel-5.10/include/linux/skbuff.h:2291,unchecked __skb_put() 位于 2292-2299:
1 | void *tmp = skb_tail_pointer(skb); |
它把原 tail 之后的空间纳入 linear data,返回新区域起点。__skb_put() 要求调用者已经证明空间足够,并用 SKB_LINEAR_ASSERT 拒绝非线性 skb。
skb_push
kernel-5.10/include/linux/skbuff.h:2347-2353:
1 | skb->data -= len; |
它消耗 headroom,用于向前添加 header。调用前必须保证 headroom 足够。
skb_pull
kernel-5.10/include/linux/skbuff.h:2355-2365:
1 | skb->len -= len; |
它只能从 linear 前部移除数据。若 pull 后总长度小于非线性长度,说明试图跨过不存在的 linear 数据,违反不变量。
二、checked 与 unchecked API
带双下划线的 helper 通常省略部分边界检查,适用于上层已证明不变量的热路径。普通 skb_put/push/pull 更适合作为外部 API,但也不能代替调用者理解前置条件。
典型规则:
__skb_put():必须有足够 tailroom,且 skb 为线性;__skb_push():必须有足够 headroom;__skb_pull():必须保证 len 合法;skb_pull_inline():len > skb->len时返回 NULL。
错误调用可能不是“返回失败”,而是破坏 shared info 或触发 BUG。
三、pskb_may_pull 与非线性 header
协议解析经常需要保证前 N 字节连续。例如读取完整 IPv4 header 前,不能假定它全部位于 linear 区。
pskb_may_pull() 位于 kernel-5.10/include/linux/skbuff.h:2384-2391:
- 若
len <= skb_headlen(skb),直接成功; - 若
len > skb->len,失败; - 否则调用
__pskb_pull_tail(),从 frags/frag_list 把需要的数据复制或整理进 linear 区。
注意名字中的 pull 不代表它会像 skb_pull() 一样推进 data。它的目标是“保证从当前 data 开始的前 len 字节线性可读”。成功后 skb 布局可能改变,所以调用前缓存的 header 指针应重新获取。
pskb_pull() 则同时保证线性并真正推进 data、减少 len。
四、header offset helper
以 network header 为例:
1 | skb_reset_network_header(skb) |
见 kernel-5.10/include/linux/skbuff.h:2577-2590。MAC、transport、inner header 使用同样模式。
reset 与 set 的区别
- reset:header 就在当前 data;
- set(offset):header 在当前 data 之后 offset;
- accessor:从 head 基址计算绝对地址。
push/pull 后 header offset 是否变化
普通 push/pull 只改 data,不会自动改已保存的 header offset。因此:
- 已保存 header 仍指向原来的字节位置;
- 当前 data 与 header 的相对距离发生变化;
- 协议层需要在正确时机 reset/set 对应 header。
这正是 offset 独立于 data 的价值。
五、header 指针失效规则
以下操作通常不更换 head,仅移动 data/tail:
- reserve;
- put;
- push;
- pull。
它们不会使 skb_network_header(skb) 计算出的地址因 head 更换而失效,但调用者缓存的“相对 data 指针”和当前视图语义可能改变。
以下操作可能更换 head 或重排数据:
pskb_expand_head();skb_cow_head();skb_unclone();__pskb_pull_tail();skb_linearize();- BPF change head/adjust room 等。
调用这些函数后,应重新通过 ip_hdr()、tcp_hdr()、skb_network_header() 等 helper 获取指针,不保留旧的 struct iphdr *。
六、pskb_expand_head
实现位于 kernel-5.10/net/core/skbuff.c:1622。它为 head buffer 增加 nhead/ntail,核心步骤包括:
- 检查 skb shell 不被多个
users共享; - 计算新 head 长度并分配新 buffer;
- 复制 linear 数据和 shared info;
- 对 frags、frag_list、zerocopy 和 clone 引用进行必要处理;
- 根据新 headroom 调整 data、tail、end 和各 header offset;
- 释放旧数据引用。
它可能改变 skb->head 和 skb->data 的绝对地址,但保持逻辑包内容和 header offset 语义。
不能把它理解成单纯的 realloc():旧 head 可能被 clone,共享信息和 page refs 必须正确转移。
七、COW:写之前先获得独占数据
skb_cloned
cloned 位只表示“可能共享”,真正判断还需要查看 shared info 的 dataref。这是热路径先看位、再看原子计数的优化。
skb_cow
目标是确保:
- 需要修改的数据区域可写;
- 必要 headroom 足够;
- 若数据被 clone,则扩展/复制得到独占 head。
skb_cow_head
它针对 header 写入场景,常用于 Netfilter NAT、隧道封装、tc/BPF 等将要修改或新增 header 的路径。若 head 已独占且 headroom 足够,返回 0;否则通过 pskb_expand_head() 创建可写 head。
COW 不意味着一定复制全部 payload。非线性 page 可能继续共享,关键是保证要写的 head/header 区域独占。
八、数据操作前置/后置条件表
| API | 前置条件 | 主要变化 | 可能失败 | 旧指针风险 |
|---|---|---|---|---|
skb_reserve |
空 skb、空间足够 | data/tail 同时前移 | 无返回值 | data 相关指针变化 |
skb_put |
线性、tailroom 足够 | tail/len 增加 | checked 路径可报错/BUG | 原 tail 之后可写 |
skb_push |
headroom 足够 | data 前移、len 增加 | 越界会破坏内存 | data 变化 |
skb_pull |
len 足够、不能越过 linear | data 前移、len 减少 | NULL 或 BUG | data 变化 |
pskb_may_pull |
请求不超过总 len | 必要数据线性化 | 内存不足返回 false | head/header 指针可能失效 |
pskb_expand_head |
skb shell 独占等 | 更换/扩展 head | -ENOMEM |
所有绝对数据指针失效 |
skb_cow_head |
指定所需 headroom | 确保 header 可写 | -ENOMEM |
发生复制时失效 |
九、典型 RX/TX 使用模式
RX
1 | 驱动构造 linear Ethernet frame |
TX
1 | 先 reserve 足够 headroom |
真实代码可能使用专用 helper,但四指针变化遵循同一模型。
十、常见误区
pskb_may_pull()不是普通skb_pull(),它主要保证连续可读。- push/pull 不会自动替调用者重设所有协议 header offset。
- 有
cloned位不等于一定仍共享,必须结合datarefhelper。 - COW 不一定复制 frags 中的全部 payload。
pskb_expand_head()后旧的iphdr *不能继续使用。skb_put()不是 memcpy;它只把空间纳入有效数据,调用者还需写入内容。- 非线性 skb 不能直接按普通 tailroom 追加。
字段与所有权统一核对表
| 对象或字段 | 主要修改者/使用者 | 所有权与释放要点 |
|---|---|---|
data/tail/len |
push/pull/put/trim | 调用者持有 skb,操作前必须满足空间不变量 |
| header offsets | reset/set helper | head 重分配后 offset 保持可重定位 |
dataref |
COW/expand | 共享 head 只有在获得独占可写副本后才能改写 |
最小调用链
1 | reserve 建立 headroom |
源码阅读路线
建议不要从 struct sk_buff 声明开始逐字段背诵,而应使用下面的阅读顺序:
- 先在入口函数确认 skb 是新分配、clone、队列取出还是从驱动 buffer 构造;
- 沿成功主线记录
data/len/data_len、header offset、checksum、hash、queue mapping 等字段变化; - 再回到每个失败分支,确认 skb、page、DMA、socket 和记账引用是否回滚;
- 最后对照 5.10、6.1 或 BSP 的同名 symbol,区分语义变化、性能优化和 vendor backport。
本篇总结
- skb 是控制对象,包数据可能分散在 linear head、page frags 和子 skb 中。
head <= data <= tail <= end描述 linear head,不等于整个逻辑包。- 协议头使用相对
head的 offset,因而能够承受 push/pull 和 head 重分配。 - 读字段时要同时问:谁写入、谁读取、当前谁持有 skb。
sk_buff 的难点从来不只是字段多,而是同一个对象不断改变数据视图、元数据状态和持有者。只要把“布局、字段、所有权、释放”四条线同时画出来,绝大多数网络栈源码都会从零散函数变成可推导的生命周期。
系列目录
- Linux sk_buff 深度解析(一):数据结构、四指针与核心字段(本文)
- Linux sk_buff 深度解析(二):非线性数据、引用计数与生命周期
- Linux sk_buff 深度解析(三):从网卡 DMA 到 Socket 的 RX 路径
- Linux sk_buff 深度解析(四):从 sendmsg 到 TX completion
- Linux sk_buff 深度解析(五):GRO、GSO、Checksum 与多核流调度
- Linux sk_buff 深度解析(六):Netfilter、tc、eBPF 与 XDP
- Linux sk_buff 深度解析(七):VLAN、隧道、分片与重组
- Linux sk_buff 深度解析(八):Socket 内存、零拷贝、丢包与版本演进
