Linux 内核网络核心:sk_buff 深度解析
目录
本文档基于 Linux 内核开发者视角,深入剖析网络子系统最核心的数据结构 sk_buff (socket buffer)。我们将从内存布局、生命周期、软硬件交互以及性能优化机制(如 GRO, Zero Copy, BQL/TSQ)等多个维度进行全景式描述。
第一部分:sk_buff 的物理与逻辑解剖
1. 核心设计哲学:元数据与数据的分离
在 Linux 内核中,网络包并非存储在一个巨大的连续结构体中,而是被拆分为两部分,分别独立分配内存:
- 控制结构体 (
struct sk_buff):- 俗称: “壳”或“遥控器”。
- 位置: 分配在 Slab Cache (
skbuff_head_cache) 中。 - 作用: 包含管理数据包所需的元数据(如指针、长度、接口、协议、引用计数等)。它的大小固定(约 232~256 字节),随内核配置而变。
- 数据缓冲区 (Data Buffer):
- 俗称: “肉”或“电视机”。
- 位置: 通过
kmalloc分配的连续物理内存块(线性区)或物理页(非线性区)。 - 作用: 存放真实的协议头(MAC/IP/TCP)和用户负载。
2. 内存布局与指针系统
sk_buff 通过四个核心指针管理线性数据区:
[ struct sk_buff ]
|
+--- head ----------------------------------+
| |
+--- data ---------+ |
| | |
+--- tail -----------------------+ |
| | | |
+--- end ------------------------------------------------+
| | | |
[ Headroom ] [ Data ] [ Tailroom ] [ skb_shared_info ]
^ ^ ^ ^ ^
| | | | |
head data tail end end 之后
head:线性内存块的物理起始地址。data:当前协议层有效数据的起始位置。随协议层变化移动(push/pull)。tail:当前有效数据的结束位置。end:线性内存块的物理结束地址。skb_shared_info:位于end之后。它不占用线性区空间,但紧随其后。核心成员是frags[]数组,用于挂载非线性区(物理页数据)。
3. 线性区 vs 非线性区
- 线性区 (
head~end):- 必须存在。用于存放所有的协议头部(MAC, IP, TCP)。
- 原因:CPU 访问协议头时需要使用结构体指针强转(如
(struct iphdr *)skb->data),要求内存物理连续。 - 小包场景:Payload 也直接存放在这里。
- 非线性区 (
frags/ Page Based):- 可选。存放大数据负载(如文件传输、视频流)。
- 物理形态:
skb_shared_info->frags[]数组指向一组分散的物理页(Page)。 - 硬件要求:网卡必须支持 Scatter-Gather (SG) DMA 才能直接读取这些分散的页。
- 优势:实现 Zero Copy(零拷贝)和 Jumbo Frames(巨型帧)的基础。
第二部分:数据包的生命周期 (Flow)
1. 发送路径 (TX) —— “Push” 与 克隆
- 分配 (Alloc):
- 调用
send()时,内核根据 MSS 和 MAX_HEADER 计算大小,分配sk_buff和数据区。 - 调用
skb_reserve,移动data和tail指针,预留 Headroom。 - 用户数据被拷贝(或零拷贝映射)进来。
- 调用
- 克隆 (Clone):
- TCP 为了可靠传输(重传),在交给 IP 层前执行
skb_clone。 - Clone 特性:只复制
sk_buff结构体,不复制数据区。新旧 skb 指向同一块内存。 - Original skb:留在发送队列,
data指针指向 Payload 开头。 - Clone skb:用于传输,
data指针在 Headroom 中前移(skb_push)以填充协议头。
- TCP 为了可靠传输(重传),在交给 IP 层前执行
- 填头 (Header Filling):
- TCP 层:
skb_push腾出空间 -> 强转指针(struct tcphdr*)skb->data-> 填入 Seq/Port。 - IP 层:
skb_push-> 填入 IP 头。 - MAC 层:
skb_push-> 填入 MAC 头。
- TCP 层:
- 硬件交互 (Ring Buffer):
- 驱动将线性区和 Frags 的物理地址填入 TX Ring Buffer 的描述符。
- 按“门铃”(写寄存器),通知网卡 DMA 搬运。
- 发送完成:指 DMA 将数据搬入网卡 SRAM,内核释放
skb内存。
2. 接收路径 (RX) —— “Pull” 与 聚合
- 硬件接收:
- 数据到达,网卡 DMA 将其写入预先分配的 Ring Buffer 内存块(可能是 Page)。
- 触发 硬中断,随后调度 软中断 (NAPI)。
- GRO (Generic Receive Offload):
- 在 NAPI 层,将同属于一条流的多个小包合并。
- 保留第一个包的
skb壳,将后续包的物理页(Frags)挂到第一个包的shinfo上,丢弃后续包的壳。 - 效果:协议栈看到一个巨大的包(哪怕超过 MTU),带有
gso_size标记。
- 剥离头部 (Header Stripping):
- 进入协议栈,执行
skb_pull。 data指针向后移动,依次跳过 MAC、IP、TCP 头。被跳过的部分变成 Headroom。
- 进入协议栈,执行
- 接收缓冲:
- 数据包被挂入 Socket 的接收队列(有序链表或乱序红黑树)。
- 接收窗口:并非物理连续内存,而是通过
sk_rmem_alloc原子计数器限制的总字节数。
第三部分:核心机制与 Q&A
在此部分,我们将之前的讨论整理为问答形式,解答关键的技术细节。
Q1: sk_buff 中的成员都是对应数据的指针吗?真实数据存在哪里?
A: 不全是。head/data/tail/end 是指针。但像 len, protocol 是普通变量。还有一个特殊的 cb[48] (Control Buffer),它是一个直接存放在结构体内的 48 字节数组。
- 真实数据:存在于
head指向的另一块独立分配的内存(线性区)或frags指向的物理页(非线性区)。 - TCP Seq:在处理过程中,Seq 为了访问速度,被拷贝存放在
cb数组中,而不是每次都去读data里的 TCP 头。
Q2: 一个完整的数据包,是否就包含在 skb->head 和 skb->end 之间?
A: 这是一个误区。
- head~end:是内核分配的线性内存容器的总大小。
- data~tail:才是当前协议层看到的有效数据。
- 非线性数据:对于大包,大部分 Payload 存储在
end指针之后的skb_shared_info->frags指向的物理页中,根本不在head~end之间。 - 校验和:存放在
data~tail包含的协议头内部,绝不会在tail后面。
Q3: 为什么会有 skb_clone?它拷贝数据吗?
A:
- 目的:为了高效实现 TCP 重传、抓包(tcpdump)等功能。
- 机制:
skb_clone完全不拷贝数据区。它只申请一个新的sk_buff结构体(元数据),让其head指针指向原有的数据区。 - 副作用:Clone 出来的 skb 对数据区是 只读 (Read-Only) 的。如果需要修改数据,必须触发 CoW(写时复制)。
- 引用计数:
users:管sk_buff结构体。dataref:管数据区。Clone 时dataref++。
Q4: 环形缓冲区 (Ring Buffer) 在物理上是怎么理解的?
A:
- 它不是存放数据包的环,而是存放 描述符 (Descriptor) 的数组。
- 描述符:是一个很小的结构体,里面只有物理地址指针和状态位。
- 工作流:
- RX:驱动把空闲内存地址填入描述符 -> 网卡 DMA 填数据 -> CPU 读走。
- TX:驱动把数据地址填入描述符 -> 网卡 DMA 读数据 -> 网卡发送。
Q5: 硬中断和软中断具体分工是什么?
A:
- 硬中断:极短。只负责告诉 CPU “DMA 搬运完了”,然后关闭网卡中断,唤醒软中断。它不拷贝数据。
- 软中断 (NAPI):干重活。轮询 Ring Buffer,分配
sk_buff(如果需要),执行 GRO 合并,运行 TCP/IP 协议栈逻辑,直到数据放入 Socket 队列。
Q6: 零拷贝 (Zero Copy) 是如何运作的?
A: 核心在于利用 skb_shared_info->frags。
- Sendfile:内核直接把磁盘读取到的 Page Cache(物理页)的地址挂到
frags数组里,完全跳过 CPUmemcpy到线性区的步骤。 - Splice:在两个 Socket 或 Pipe 之间传递物理页的指针/引用,而不是搬运数据。
Q7: 发包时,当驱动说“发送完成”,数据真的发出去了吗?
A: 通常没有。
- 它只代表数据已经完整进入了 网卡的 SRAM (Tx FIFO)。
- 内核此时释放内存 (
kfree_skb)。 - 隐患:Bufferbloat(缓冲区膨胀)。数据可能堵在网卡硬件里,导致 Ping 值虚高且内核 QoS 失效。
Q8: 什么是 BQL 和 TSQ?它们解决什么问题?
A: 它们是为了解决上述的 Bufferbloat 问题。
- BQL (Byte Queue Limits):在驱动层。动态调整提交给网卡硬件队列的字节数,保证网卡不饿死但也不多留数据。
- TSQ (TCP Small Queues):在 TCP 层。限制每个 Socket 已发送但未释放(还在网卡或 QDisc 排队)的字节数。
- 效果:构建了从网卡到应用的背压 (Backpressure) 机制,将排队控制在内核可控的层级(QDisc),从而降低延迟。
Q9: 接收窗口 (Receive Window) 对应一块连续物理内存吗?
A: 不是。
- 接收缓冲区是一堆散落在内存各处的
sk_buff,通过链表(有序)和红黑树(乱序)组织。 - 窗口控制:通过原子变量
sk->sk_rmem_alloc统计所有 skb 的truesize总和来实现逻辑限制。
总结建议: 理解 sk_buff 的关键在于建立 “元数据(Controller) 与 数据(View) 分离” 以及 “线性(Linear) 与 非线性(Paged) 混合” 的立体视图。网络优化的本质,往往就是减少元数据的分配(GRO/XDP)和减少数据的拷贝(Zero Copy)。