Linux sk_buff 深度解析(二):非线性数据、引用计数与生命周期
写在前面
sk_buff 是 Linux 网络栈最核心、也最容易被误解的数据结构之一。它并不是“装着一个完整网络包的结构体”,而是贯穿驱动、协议栈、队列、Socket、Netfilter、tc、BPF 与硬件 offload 的控制对象和所有权载体。
深入 linear、frags、frag_list、users、dataref、page ref,串联 sk_buff 的分配、clone、copy、COW 与分层释放。
本文以本地 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?
本篇核心结论
len是逻辑总长度,data_len是非线性长度,二者不能与tail-data混为一谈。- skb shell、head buffer、frag page 分别由
users、dataref、page ref 保护。 - clone 复制控制对象但共享数据;copy 创建独立数据;修改共享 head 前必须 COW。
- 释放不是一次
kfree(),而是按外部引用、数据区和 shell 分层回收。
1. 非线性数据布局:linear、frags 与 frag_list
结论摘要
一个 skb 的逻辑数据可由 linear head、skb_shared_info.frags[] 和 frag_list 三部分构成。frags[] 描述 page 的片段,适合 scatter-gather、RX page、sendpage 和零拷贝;frag_list 链接完整子 skb,常见于重组、GRO fraglist 或大消息组织。len 统计全部逻辑数据,data_len 统计非线性部分,遍历与修改必须使用能够跨三种布局的 helper。
一、三种数据组织
1 | struct sk_buff shell |
逻辑顺序通常是:linear 数据在前,随后是 frags,再随后是 frag_list 中的 skb 数据。
二、skb_frag_t
本地 5.10 中:
1 | typedef struct bio_vec skb_frag_t; |
见 kernel-5.10/include/linux/skbuff.h:324。bio_vec 本质描述:
- page;
- page 内 offset;
- 片段长度。
访问应使用:
skb_frag_page();skb_frag_off();skb_frag_size();- 对应 set/add/sub helper。
不要直接依赖 bio_vec 内部字段名,这些 helper 同时表达 skb 层的抽象边界。
三、MAX_SKB_FRAGS
kernel-5.10/include/linux/skbuff.h:305-317 说明设计目标:允许一个约 64 KiB frame 由 page fragments 表达,同时至少保留 16 个 frag 供 GRO 使用。
1 |
nr_frags 必须不超过有效上限。驱动或 GSO 合并前需要检查 frag 数量,不能无限追加。
四、frags[] 的生命周期
skb_add_rx_frag() 声明于 kernel-5.10/include/linux/skbuff.h:2236-2237。它把 page/offset/size 写入指定 frag,并同步更新:
nr_frags;skb->len;skb->data_len;skb->truesize。
重要点:把 page 放入 frag 后,skb 释放路径会对该 page 执行 frag unref。驱动必须明确是否已经把 page 所有权转交给 skb,不能在转交后又直接回收同一引用。
clone 时多个 skb 可能共享 frag page,需要增加 page ref;最后一个引用释放时 page 才真正回收。Linux 6.1 还可结合 pp_recycle 把 RX page 返回 page_pool。
五、frag_list
skb_shared_info.frag_list 是一个 struct sk_buff * 链表,而不是 page 数组。每个成员本身又可以有 linear/frags/frag_list。
适用场景:
- IP 重组后用一个 head skb 组织后续分片 skb;
- 某些 GRO fraglist 路径;
- 大消息或已有 skb 链包装。
遍历使用:
1 | skb_walk_frags(skb, iter) |
释放时 skb_release_data() 调用 kfree_skb_list(shinfo->frag_list),见 kernel-5.10/net/core/skbuff.c:621-622。clone frag_list 时需要对每个子 skb skb_get()。
六、长度关系
1 | skb->len = linear + frags + frag_list |
skb_is_nonlinear() 直接检查 data_len,见 kernel-5.10/include/linux/skbuff.h:2159-2167。
skb_pagelen() 主要统计 linear + page frags;若还存在 frag_list,完整逻辑遍历需要继续进入子 skb,不能把 pagelen 与总 len 混为一谈。
七、truesize
追加 frag 时调用者传入 truesize 增量。它反映 skb 对内存系统和 socket accounting 的成本,不一定等于 frag size:
- 一个小片段可能独占较大 page;
- page_pool、page fragment 或复用策略会改变计量;
- frag_list 子 skb 自身也有 truesize。
错误的 truesize 会造成 socket 内存限制失真:过小可能让 socket 持有过多真实内存,过大则过早触发限额。
八、skb_copy_bits
skb_copy_bits() 是跨布局读取的通用 helper。它按逻辑 offset 依次处理:
- linear 区;
frags[]中的 page 片段;frag_list子 skb,必要时递归。
因此协议、加密、分段等代码不应自行假设数据连续。只要需要跨越不确定边界,就应使用能够处理非线性布局的 helper。
同类 helper 包括 skb_store_bits()、checksum/copy iterator 等。
九、linearize
1 | skb_linearize(skb) |
若 skb 已是线性,直接返回 0;否则调用 __skb_linearize(),见 kernel-5.10/include/linux/skbuff.h:3368-3371。
linearize 的目标是把逻辑数据整理到连续 head buffer。它可能:
- 分配更大的 head;
- 从 pages/frag_list 复制数据;
- 释放或减少原 frag 引用;
- 改变 head/data/tail 的绝对地址;
- 失败并返回
-ENOMEM。
代价与包长有关,热路径应尽量避免无条件 linearize。协议只需要前 N 字节连续时,优先 pskb_may_pull(N)。
十、frags 与 frag_list 对比
| 维度 | frags[] |
frag_list |
|---|---|---|
| 元素 | page 片段 | 完整 skb |
| 数量 | nr_frags、有上限 |
skb 链表 |
| 引用 | page ref | skb users |
| 常见来源 | RX page、sendpage、SG、zerocopy | 重组、GRO fraglist、大消息 |
| 遍历 | frag helper | skb_walk_frags() |
| 释放 | __skb_frag_unref() |
kfree_skb_list() |
| 每个元素可带元数据 | 否,只有 page/offset/size | 是,每个子 skb 有完整 metadata |
十一、数据布局不变量
nr_frags <= MAX_SKB_FRAGS。- 每个 frag 的 page 引用在 skb 持有期间有效。
len >= data_len。skb_headlen = len - data_len。- frag_list 成员不能形成非法循环。
- 修改 frag size/offset 时必须同步长度和 accounting。
- 需要写 page 数据前必须确认可写性和共享状态。
十二、常见误区
- 非线性不代表“数据不连续的顺序不确定”,逻辑字节顺序仍明确。
frags[]不是 IP fragments;前者是内存布局,后者是网络层分片协议。frag_list不是普通sk_buff_head,它是通过 skbnext串起来的子 skb 链。data_len不只统计frags[],也包含 frag_list 数据。- linearize 不是零成本,且可能让旧 header 指针失效。
- page 放入 skb frag 后不能继续按驱动私有所有权随意释放。
字段与所有权统一核对表
| 对象或字段 | 主要修改者/使用者 | 所有权与释放要点 |
|---|---|---|
data_len/nr_frags |
追加 page frag | skb 持有 page 引用,free 时归还 |
frag_list |
聚合/重组路径 | 父 skb 持有子 skb 链 |
len/truesize |
追加、分段、重组 | 必须同步逻辑长度和内存计量 |
最小调用链
1 | linear head |
2. 分配、clone、copy、COW 与释放
结论摘要
skb 生命周期至少包含三类对象:skb shell、共享 head buffer/shared info、frag page/frag_list。users 保护 shell,dataref 保护共享 head,page ref 或子 skb users 保护非线性数据。clone 创建新 shell 并共享数据;copy 创建新 shell 和新 head 并复制数据;COW 在真正写入前把需要修改的 header/head 变为独占。释放路径按 head state、data、shell 分层进行。
一、分配路径
__alloc_skb
入口:kernel-5.10/net/core/skbuff.c:183-263。
主要步骤:
- 根据
SKB_ALLOC_FCLONE选择skbuff_fclone_cache或skbuff_head_cache; - 从 slab 分配 skb shell;
- 对请求 size 做 cacheline 对齐,并增加 shared info 空间;
kmalloc_reserve()分配 head buffer;- 根据实际
ksize()把 shared info 放到分配区尾部; - 初始化
head=data=tail、end、truesize; - 设置
users=1、dataref=1; - 初始化 fclone 状态。
skb shell 与 head 分开分配,使 clone 可以只创建新 shell 而共享大块数据。
build_skb
build_skb() 位于 kernel-5.10/net/core/skbuff.c:331,用于把调用者提供的已有 buffer 包装成 skb。驱动可把 page fragment、RX buffer 或专用分配器得到的内存转为 skb,避免再次复制 payload。
调用者必须为尾部 shared info 留出空间,并明确 head buffer 的释放方式。head_frag 标志影响 skb_free_head() 使用 skb_free_frag() 还是 kfree()。
napi_alloc_skb
NAPI 场景使用 napi_alloc_skb(),本地 5.10 的实现会针对小 buffer/page fragment 和 NAPI 上下文优化分配。释放时 napi_consume_skb() 还能把 skb shell 延迟放入 per-CPU cache 路径,而不是立即 slab free。
二、三层引用关系
skb->users
保护 struct sk_buff shell。skb_get() 增加 users,kfree_skb()/consume_skb() 先通过 skb_unref() 减少引用,只有归零才进入真正释放。
skb_shared_info.dataref
保护共享 head/data 区。clone 增加 dataref。低 16 位表示完整数据引用,高 16 位表示 payload-only 引用,相关说明位于 kernel-5.10/include/linux/skbuff.h:543-550。
frag page / frag_list 引用
frags[]:page ref;frag_list:每个子 skb 的 users;- zerocopy:
ubuf_info等额外引用。
这解释了为什么 users 归零并不代表可以直接 kfree head/page。
三、skb_clone
__skb_clone() 位于 kernel-5.10/net/core/skbuff.c:991-1021。
它创建或取得一个新 skb shell,然后:
- 清空新 shell 的 queue 链接;
- 不复制旧
sk; - 复制协议/header/offload 元数据;
- 复制 len/data_len/tail/end/head/data/truesize;
- 新 shell
users=1; - 新 shell destructor 置 NULL;
dataref++;- 原 skb 与新 skb 都标记 cloned。
结果:
1 | skb A shell ─┐ |
两个 shell 的 next/prev、部分 metadata 可以独立变化,但直接修改共享 head 中的字节前必须 COW。
fclone
fclone slab 一次预留 original 和一个常用 clone shell,减少典型 TX clone 场景再次从 slab 分配的成本。它优化 shell 分配,不改变 head 数据共享语义。
四、skb_copy
skb_copy() 位于 kernel-5.10/net/core/skbuff.c:1519。它分配新的 skb 和足够 headroom,然后复制整个逻辑数据,包括非线性内容,形成独立 linear 数据副本,再复制 header metadata。
结果:
1 | skb A shell → head A |
一般不共享 payload,代价是按包长度复制和更大内存开销。需要完全独立修改、设备不支持 SG、或必须线性数据时使用。
五、pskb_copy
__pskb_copy_fclone() 位于 kernel-5.10/net/core/skbuff.c:1558。partial copy 的核心是:
- 复制 linear head;
- 共享或增加引用保存 frags/frag_list;
- 新 skb 可以独立修改 linear header;
- 非线性 payload 仍然共享。
因此可概括为:
| API | shell | linear head | frags/page |
|---|---|---|---|
skb_clone |
新 | 共享 | 共享 |
pskb_copy |
新 | 复制 | 共享 |
skb_copy |
新 | 复制 | 数据也复制/线性化为独立内容 |
具体实现还受 headroom、fclone、zerocopy、frag_list 等影响,不能只按名字判断所有细节。
六、headerless clone 与 nohdr
传输层可能只希望 clone payload,让下层为每个 clone 构造不同 header。nohdr 和 hdr_len 配合 dataref 高位区分 payload-only 引用。
基本目标:
- payload 可共享;
- 原 skb 保留可修改 header 的能力;
- clone 不应擅自修改共享 header。
这也是 skb_header_cloned() 与普通 skb_cloned() 分开的原因:数据区共享不必然意味着 header 当前不可写,要结合 full-data ref 与 payload-only ref 判断。
七、COW
clone 之后若要改 header:
1 | 检查 head 是否共享、headroom 是否足够 |
skb_cow_head() 是常见入口。成功后调用者可写 header;失败通常是 -ENOMEM,必须停止修改并走错误释放路径。
COW 的性能价值是推迟复制:只读转发或观察路径不复制,只有真正写入时付出成本。
八、释放入口语义
kfree_skb
kernel-5.10/net/core/skbuff.c:705-713。它表示丢弃或错误释放语义,并触发 trace_kfree_skb。先减少 users,归零后 __kfree_skb()。
consume_skb
kernel-5.10/net/core/skbuff.c:844。表示正常消费完成,例如成功 TX completion。内存释放结果可能相同,但 trace/可观测语义不同。
napi_consume_skb
kernel-5.10/net/core/skbuff.c:908-930。在有效 NAPI budget 场景,普通 skb shell 可进入延迟/per-CPU 回收;fclone 等特殊情况直接完整释放。budget 为 0 时回退通用上下文路径。
九、分层释放
1 | kfree_skb()/consume_skb() |
skb_release_head_state
kernel-5.10/net/core/skbuff.c:661-672:
skb_dst_drop();- 调用 destructor;
nf_conntrack_put();skb_ext_put()。
这是 skb shell 附带的外部状态。
skb_release_data
kernel-5.10/net/core/skbuff.c:608-626:
- 若 cloned,减少 dataref;仍有人共享则立即返回;
- unref 每个 frag page;
- 释放 frag_list 子 skb;
- 清理 zerocopy;
- 根据
head_frag释放 head。
kfree_skbmem
根据 fclone 状态释放普通 shell 或整个 fclone 对象。
十、所有权转移原则
阅读任何返回 struct sk_buff * 或接受 skb 的 API 时,都要问:
- 成功后调用者是否仍持有 skb?
- 失败时函数是否已经消费 skb?
- clone 是增加 shell 引用还是创建新 shell?
- 数据引用是否独立增加?
- destructor、dst、nfct、zerocopy 是否被转移或复制?
网络栈常见 bug 不是算法错误,而是 double free、漏 ref、错误 orphan 或 busy 返回后错误消费 skb。
十一、常见误区
- clone 不是浅拷贝一个 C 结构体;它有明确的引用和字段复制规则。
cloned=1是快速提示,最终共享状态还看 dataref。skb_copy()与pskb_copy()不同,后者仍可共享非线性 payload。kfree_skb()不等于kfree()。- destructor 主要处理 head state/owner,不直接替代 frag/head 的通用释放。
consume_skb()与kfree_skb()的差异对 drop 可观测性很重要。- users、dataref、page ref、socket ref、dst ref 是不同层次,不能用一个“skb 引用计数”笼统概括。
5.10 到 6.1 的预告
Linux 6.1 在这些路径上的重要演进包括:
napi_skb_cache_get/put()与 bulk slab 操作;pp_recycle让 frag/head page 在 skb free 路径回到 page_pool;kfree_skb_reason()改进丢包语义;- XDP multi-buffer 与 shared info 更新。
详细差异放在阶段 21。
字段与所有权统一核对表
| 对象或字段 | 主要修改者/使用者 | 所有权与释放要点 |
|---|---|---|
users |
skb_get/consume | 保护 skb shell |
dataref |
clone/COW/free | 保护共享 head 和 shared info |
| frag page ref | clone/copy/free | 保护 page frag;深拷贝与共享路径不同 |
最小调用链
1 | __alloc_skb()/build_skb() |
从局部机制回到完整路径
前面的局部机制只有放回完整生命周期才不容易误判:数据布局决定 helper 能否直接访问;引用计数决定能否原地修改;队列和驱动返回值决定谁仍然拥有 skb。遇到异常路径时,应按“外部引用 → 数据区 → shell”的逆序检查回收。
源码阅读路线
建议不要从 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。
本篇总结
len是逻辑总长度,data_len是非线性长度,二者不能与tail-data混为一谈。- skb shell、head buffer、frag page 分别由
users、dataref、page ref 保护。 - clone 复制控制对象但共享数据;copy 创建独立数据;修改共享 head 前必须 COW。
- 释放不是一次
kfree(),而是按外部引用、数据区和 shell 分层回收。
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 内存、零拷贝、丢包与版本演进
