写在前面

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 或开发板实验写成已验证结论。

Linux sk_buff 深度解析(二):非线性数据、引用计数与生命周期

阅读前先建立四个问题

阅读任何 skb 代码路径,都建议反复追问:

  1. 当前 skb->data 指向哪一层协议头,linear 与 non-linear 数据分别在哪里?
  2. 哪些字段刚被修改,下一层为什么依赖这些字段?
  3. 当前谁持有 skb shell、head、frag page 以及 socket/dst/conntrack 等外部引用?
  4. 成功、失败、重试、redirect 和丢弃分支分别由谁消费或释放 skb?

本篇核心结论

  • len 是逻辑总长度,data_len 是非线性长度,二者不能与 tail-data 混为一谈。
  • skb shell、head buffer、frag page 分别由 usersdataref、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
2
3
4
5
6
7
8
struct sk_buff shell

├── head buffer
│ └── [data, tail): linear data

└── skb_shared_info
├── frags[0..nr_frags-1] → page + offset + size
└── frag_list → skb → skb → ...

逻辑顺序通常是: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:324bio_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
2
3
4
5
#if (65536/PAGE_SIZE + 1) < 16
#define MAX_SKB_FRAGS 16UL
#else
#define MAX_SKB_FRAGS (65536/PAGE_SIZE + 1)
#endif

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
2
3
skb->len       = linear + frags + frag_list
skb->data_len = frags + frag_list
skb_headlen() = skb->len - skb->data_len

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 依次处理:

  1. linear 区;
  2. frags[] 中的 page 片段;
  3. 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

十一、数据布局不变量

  1. nr_frags <= MAX_SKB_FRAGS
  2. 每个 frag 的 page 引用在 skb 持有期间有效。
  3. len >= data_len
  4. skb_headlen = len - data_len
  5. frag_list 成员不能形成非法循环。
  6. 修改 frag size/offset 时必须同步长度和 accounting。
  7. 需要写 page 数据前必须确认可写性和共享状态。

十二、常见误区

  1. 非线性不代表“数据不连续的顺序不确定”,逻辑字节顺序仍明确。
  2. frags[] 不是 IP fragments;前者是内存布局,后者是网络层分片协议。
  3. frag_list 不是普通 sk_buff_head,它是通过 skb next 串起来的子 skb 链。
  4. data_len 不只统计 frags[],也包含 frag_list 数据。
  5. linearize 不是零成本,且可能让旧 header 指针失效。
  6. page 放入 skb frag 后不能继续按驱动私有所有权随意释放。

字段与所有权统一核对表

对象或字段 主要修改者/使用者 所有权与释放要点
data_len/nr_frags 追加 page frag skb 持有 page 引用,free 时归还
frag_list 聚合/重组路径 父 skb 持有子 skb 链
len/truesize 追加、分段、重组 必须同步逻辑长度和内存计量

最小调用链

1
2
3
4
5
linear head
→ 添加 frags[] 或 frag_list
→ 更新 len/data_len/truesize
→ 协议处理或分段
→ 逐层释放 child skb/page ref/head

2. 分配、clone、copy、COW 与释放

分配、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

主要步骤:

  1. 根据 SKB_ALLOC_FCLONE 选择 skbuff_fclone_cacheskbuff_head_cache
  2. 从 slab 分配 skb shell;
  3. 对请求 size 做 cacheline 对齐,并增加 shared info 空间;
  4. kmalloc_reserve() 分配 head buffer;
  5. 根据实际 ksize() 把 shared info 放到分配区尾部;
  6. 初始化 head=data=tail、end、truesize;
  7. 设置 users=1dataref=1
  8. 初始化 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
2
3
skb A shell ─┐
├── shared head buffer + shared_info + frags
skb B 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
2
skb A shell → head A
skb B shell → head B

一般不共享 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。nohdrhdr_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
2
3
检查 head 是否共享、headroom 是否足够
├─ 已独占且空间足:直接写
└─ 共享或空间不足:pskb_expand_head() 创建新 head

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
2
3
4
5
6
7
kfree_skb()/consume_skb()
→ skb_unref(users)
→ __kfree_skb()
→ skb_release_all()
→ skb_release_head_state()
→ skb_release_data()
→ kfree_skbmem()

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

  1. 若 cloned,减少 dataref;仍有人共享则立即返回;
  2. unref 每个 frag page;
  3. 释放 frag_list 子 skb;
  4. 清理 zerocopy;
  5. 根据 head_frag 释放 head。

kfree_skbmem

根据 fclone 状态释放普通 shell 或整个 fclone 对象。

十、所有权转移原则

阅读任何返回 struct sk_buff * 或接受 skb 的 API 时,都要问:

  1. 成功后调用者是否仍持有 skb?
  2. 失败时函数是否已经消费 skb?
  3. clone 是增加 shell 引用还是创建新 shell?
  4. 数据引用是否独立增加?
  5. destructor、dst、nfct、zerocopy 是否被转移或复制?

网络栈常见 bug 不是算法错误,而是 double free、漏 ref、错误 orphan 或 busy 返回后错误消费 skb。

十一、常见误区

  1. clone 不是浅拷贝一个 C 结构体;它有明确的引用和字段复制规则。
  2. cloned=1 是快速提示,最终共享状态还看 dataref。
  3. skb_copy()pskb_copy() 不同,后者仍可共享非线性 payload。
  4. kfree_skb() 不等于 kfree()
  5. destructor 主要处理 head state/owner,不直接替代 frag/head 的通用释放。
  6. consume_skb()kfree_skb() 的差异对 drop 可观测性很重要。
  7. 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
2
3
4
5
__alloc_skb()/build_skb()
→ skb_clone() 共享数据
→ skb_copy()/COW 获得私有数据
→ consume/kfree
→ users/dataref/page ref 分层归零

从局部机制回到完整路径

完整路径关系

前面的局部机制只有放回完整生命周期才不容易误判:数据布局决定 helper 能否直接访问;引用计数决定能否原地修改;队列和驱动返回值决定谁仍然拥有 skb。遇到异常路径时,应按“外部引用 → 数据区 → shell”的逆序检查回收。

源码阅读路线

建议不要从 struct sk_buff 声明开始逐字段背诵,而应使用下面的阅读顺序:

  1. 先在入口函数确认 skb 是新分配、clone、队列取出还是从驱动 buffer 构造;
  2. 沿成功主线记录 data/len/data_len、header offset、checksum、hash、queue mapping 等字段变化;
  3. 再回到每个失败分支,确认 skb、page、DMA、socket 和记账引用是否回滚;
  4. 最后对照 5.10、6.1 或 BSP 的同名 symbol,区分语义变化、性能优化和 vendor backport。

本篇总结

  1. len 是逻辑总长度,data_len 是非线性长度,二者不能与 tail-data 混为一谈。
  2. skb shell、head buffer、frag page 分别由 usersdataref、page ref 保护。
  3. clone 复制控制对象但共享数据;copy 创建独立数据;修改共享 head 前必须 COW。
  4. 释放不是一次 kfree(),而是按外部引用、数据区和 shell 分层回收。

sk_buff 的难点从来不只是字段多,而是同一个对象不断改变数据视图、元数据状态和持有者。只要把“布局、字段、所有权、释放”四条线同时画出来,绝大多数网络栈源码都会从零散函数变成可推导的生命周期。

系列目录

  1. Linux sk_buff 深度解析(一):数据结构、四指针与核心字段
  2. Linux sk_buff 深度解析(二):非线性数据、引用计数与生命周期(本文)
  3. Linux sk_buff 深度解析(三):从网卡 DMA 到 Socket 的 RX 路径
  4. Linux sk_buff 深度解析(四):从 sendmsg 到 TX completion
  5. Linux sk_buff 深度解析(五):GRO、GSO、Checksum 与多核流调度
  6. Linux sk_buff 深度解析(六):Netfilter、tc、eBPF 与 XDP
  7. Linux sk_buff 深度解析(七):VLAN、隧道、分片与重组
  8. Linux sk_buff 深度解析(八):Socket 内存、零拷贝、丢包与版本演进