☰
数据封装揭秘:每一层只关心自己的头部
2026/10/7 9:24:36 网站建设 项目流程

核心机制一句话:每一层把上层交来的东西整体当作"数据",前面加上自己的头部,再交给下一层。接收端反向逐层剥离。

这个设计让每一层只需要认识自己的头部,不用关心上层在传什么,也不用关心下层怎么送——这是分层模型能成立的全部基础。


一、整体过程图

发送端 接收端 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 应用层 ┌──────────────┐ ┌──────────────┐ │ 用户数据 │ Message / Data │ 用户数据 │ └──────┬───────┘ └──────▲───────┘ │ 加 TCP/UDP 头 │ 剥 TCP/UDP 头 传输层 ┌──────▼───────────────┐ ┌──────────┴───────┐ │TCP头│ 用户数据 │ Segment │TCP头│ 用户数据 │ └──────┬───────────────┘ └──────▲───────────┘ │ 加 IP 头 │ 剥 IP 头 网络层 ┌──────▼──────────────────────┐ ┌───────────┴──────────┐ │IP头│TCP头│ 用户数据 │ Packet │IP头│TCP头│ 用户数据 │ └──────┬──────────────────────┘ └───────────▲──────────┘ │ 加帧头 + 帧尾(FCS) │ 剥帧头帧尾 链路层 ┌──────▼─────────────────────────────┐ ┌──────────┴──────────┐ │帧头│IP头│TCP头│ 用户数据 │FCS│ Frame │帧头│IP头│...│FCS│ │ └──────┬─────────────────────────────┘ └──────────▲──────────┘ │ 编码为信号 │ 信号还原 物理层 └──► 1010110100101110101001011101010 ────────┘ Bits

每层的名称和产物:

层产物名称(PDU)加的头部关心什么
应用层Message / Data应用自定义业务语义
传输层Segment(TCP)/ Datagram(UDP)端口、序列号进程到进程
网络层Packet / Datagram源/目的 IP主机到主机,跨网路由
链路层Frame源/目的 MAC同一网段内相邻节点
物理层Bits / Symbols无比特如何变成信号

二、每层头部的具体内容

2.1 以太网帧(链路层)

0 14 末尾 ├──────────┬──────────┬──────┬────────────────────────────┬──────┤ │ 目的 MAC │ 源 MAC │ 类型 │ Payload │ FCS │ │ 6 字节 │ 6 字节 │2 字节│ 46 ~ 1500 字节 │4 字节│ └──────────┴──────────┴──────┴────────────────────────────┴──────┘ 类型 (EtherType): 0x0800 IPv4 0x86DD IPv6 0x0806 ARP 0x8100 802.1Q VLAN 标签

要点:

  • 最小帧长 64 字节(含 FCS,不含前导码)。Payload 不足 46 字节会被填充(Padding),这是 CSMA/CD 冲突检测的历史要求。
  • FCS 是 CRC-32,只做检错不做纠错。错了直接丢弃,不通知上层——重传是传输层的事。
  • 帧头前面物理层还会加7 字节前导码 + 1 字节 SFD,用于时钟同步,通常不算进帧长。

2.2 IPv4 头部(网络层)

0 8 16 31 ┌───────┬───────┬───────────────┬───────────────────────────────────────┐ │Version│ IHL │ TOS │ Total Length │ │ (4) │ (4) │ (8) │ (16) │ ├───────┴───────┴───────────────┼─────┬─────────────────────────────────┤ │ Identification │Flags│ Fragment Offset │ │ (16) │ (3) │ (13) │ ├───────────────┬───────────────┼─────┴─────────────────────────────────┤ │ TTL │ Protocol │ Header Checksum │ │ (8) │ (8) │ (16) │ ├───────────────┴───────────────┴───────────────────────────────────────┤ │ Source IP Address (32) │ ├───────────────────────────────────────────────────────────────────────┤ │ Destination IP Address (32) │ ├───────────────────────────────────────────────────────────────────────┤ │ Options (可选,0 ~ 40 字节) │ └───────────────────────────────────────────────────────────────────────┘ 最小 20 字节,最大 60 字节

关键字段:

字段作用
IHL头部长度,单位是 4 字节。值为 5 表示 20 字节(无 Options)
Total Length头部 + 数据的总长,所以 IP 包理论上限 65535 字节
Protocol上层是谁:6=TCP,17=UDP,1=ICMP,41=IPv6-in-IPv4
TTL每过一个路由器减 1,到 0 丢弃并回 ICMP 超时。traceroute就靠它工作
Identification / Flags / Offset分片用,见下文
Header Checksum只校验头部,不校验数据。因为 TTL 每跳都变,每个路由器都得重算

IPv6 把头部固定为 40 字节,去掉了校验和与分片字段(分片改由扩展头处理),目的是让路由器转发更快。

2.3 TCP 头部(传输层)

0 8 16 31 ┌───────────────────────────────┬───────────────────────────────────────┐ │ Source Port (16) │ Destination Port (16) │ ├───────────────────────────────┴───────────────────────────────────────┤ │ Sequence Number (32) │ ├───────────────────────────────────────────────────────────────────────┤ │ Acknowledgment Number (32) │ ├───────┬───────────┬───────────┬───────────────────────────────────────┤ │Offset │ Reserved │ Flags │ Window Size (16) │ │ (4) │ (4) │ (8) │ │ ├───────┴───────────┴───────────┼───────────────────────────────────────┤ │ Checksum (16) │ Urgent Pointer (16) │ ├───────────────────────────────┴───────────────────────────────────────┤ │ Options (0 ~ 40 字节) │ └───────────────────────────────────────────────────────────────────────┘ 最小 20 字节,最大 60 字节 Flags: CWR ECE URG ACK PSH RST SYN FIN

常见 Options:MSS(最大段大小)、Window Scale(窗口扩大因子)、SACK(选择性确认)、Timestamps。

TCP Checksum 覆盖:伪头部 + TCP 头 + 数据。伪头部包含源/目的 IP 和协议号,这样能顺便验证"这个包确实是发给我的"。这是层间少见的一处耦合。

2.4 UDP 头部(传输层)

0 8 16 31 ┌───────────────────────────────┬───────────────────────────────────────┐ │ Source Port (16) │ Destination Port (16) │ ├───────────────────────────────┼───────────────────────────────────────┤ │ Length (16) │ Checksum (16) │ └───────────────────────────────┴───────────────────────────────────────┘ 固定 8 字节

只有 8 字节。没有序列号、没有确认、没有窗口——所以可靠性、顺序、拥塞控制全部要应用层自己做。实时游戏选 UDP 就是为了这个"什么都不做",从而避免 TCP 的队头阻塞和重传延迟。


三、开销计算:你的 1 字节实际跑了多少

发送 1 字节应用数据,走 TCP over Ethernet: 以太网前导码 + SFD 8 字节 (物理层,通常不计入帧) 以太网帧头 14 字节 IP 头 20 字节 TCP 头 20 字节 应用数据 1 字节 以太网填充 (补到最小 46) 45 字节 ← 46-1=45 FCS 4 字节 帧间隔 IFG 12 字节 (物理层) ────────────────────────────────── 线路实际占用 124 字节 有效载荷比 = 1 / 124 ≈ 0.8%

对比满载的情况:

以太网帧头 + IP + TCP + FCS = 58 字节 应用数据 = 1460 字节 ────────────────────────────────── 有效载荷比 ≈ 96%

这就是为什么高频小包是网络设计的大忌。实时游戏每帧发位置更新,如果每个实体一个包,带宽几乎全被头部吃掉。解法是包合并:

❌ 10 个实体 → 10 个包 → 580 字节头部开销 ✅ 10 个实体 → 1 个包 → 58 字节头部开销

四、MTU 与分片

4.1 MTU 的层级

应用层可用 ←─ MSS = MTU - IP头 - TCP头 = 1500 - 20 - 20 = 1460 │ 链路层 MTU ←─ 1500 字节(以太网标准) │ 实际路径 ←─ Path MTU = 路径上所有链路 MTU 的最小值

常见 MTU 值:

链路MTU
以太网1500
PPPoE(很多家宽)1492
IPv6 最小要求1280
VPN / IPsec 隧道典型 1400 左右
Jumbo Frame(数据中心)9000

4.2 IP 分片过程

一个 4000 字节的 IP 包要过 MTU=1500 的链路:

原始包: [IP头 20][ 数据 3980 ] Total Length = 4000 ↓ 分片 片 1: [IP头 20][ 数据 1480 ] ID=x Offset=0 MF=1 片 2: [IP头 20][ 数据 1480 ] ID=x Offset=185 MF=1 片 3: [IP头 20][ 数据 1020 ] ID=x Offset=370 MF=0 ← 最后一片 ↑ Offset 单位是 8 字节: 1480/8 = 185 重组条件:同一 Identification + 同一源/目的 IP + 同一协议号

分片的问题:

⚠️ 任何一片丢失 → 整个原始包都要重传 ⚠️ 重组消耗接收端内存和 CPU ⚠️ 很多防火墙/NAT 直接丢弃非首片(没有端口信息,无法做策略) ⚠️ IPv6 中间路由器不允许分片,只能由源端做

所以实践中要避免分片。TCP 通过 MSS 协商天然避免;UDP 则要应用层自己控制包大小。

4.3 Path MTU Discovery

发送端设置 IP 头的 DF (Don't Fragment) = 1 ↓ 路径上某路由器发现包太大且不能分片 ↓ 丢弃该包,回送 ICMP Type 3 Code 4 "Fragmentation Needed",并告知自己的 MTU ↓ 发送端调小包大小重试

现实中的陷阱:大量网络会过滤 ICMP,导致 PMTUD 失效。症状非常典型:

小包能通(ping 正常、TCP 握手成功) 大包全丢(网页打开一半卡住、下载卡死)

这就是所谓的ICMP 黑洞。所以 UDP 游戏协议通常把包大小保守地定在1200 字节以内,直接绕开整个问题。


五、关键认知:端口号不在 IP 层

初学最容易混的一点:

┌─────────────────────────────────────────────────────┐ │ MAC 地址 → 链路层 → 同一网段内找到相邻网卡 │ │ IP 地址 → 网络层 → 跨网络找到目标主机 │ │ 端口号 → 传输层 → 在主机内找到具体进程 │ └─────────────────────────────────────────────────────┘
一次完整的定位: 192.168.1.5 : 54321 ──► 203.0.113.8 : 443 └─ 哪台机 ─┘ └ 哪个进程┘ └─ 哪台机 ─┘ └ 哪个进程┘ IP 层 TCP 层 IP 层 TCP 层

这个四元组(加协议号成五元组)唯一标识一条连接,也是 NAT 和防火墙做策略的依据。端口是传输层的概念,IP 头里没有端口字段——这也是为什么 IP 分片的非首片会被防火墙丢弃:端口信息只在第一片里。


六、MAC 地址逐跳改变,IP 地址端到端不变

这是封装机制里最容易被忽略、但面试和排障时最关键的一点。

主机 A 路由器 R1 路由器 R2 主机 B 10.0.0.2 10.0.0.1 192.168.5.9 MAC: AA MAC: BB MAC: CC MAC: DD │ │ │ │ │ ① │ ② │ ③ │ ├──────────────────────┤───────────────────────┤────────────────────┤ ① A → R1 [帧: 目的=BB 源=AA][IP: 源=10.0.0.2 目的=192.168.5.9][TCP][数据] ② R1 → R2 [帧: 目的=CC 源=BB][IP: 源=10.0.0.2 目的=192.168.5.9][TCP][数据] ↑ MAC 换了 ↑ IP 完全没变 TTL 减 1 ③ R2 → B [帧: 目的=DD 源=CC][IP: 源=10.0.0.2 目的=192.168.5.9][TCP][数据] ↑ MAC 又换了 ↑ IP 还是没变 TTL 再减 1
┌───────────────────────────────────────────────────────┐ │ IP 地址 = 端到端不变(除非经过 NAT) │ │ MAC 地址 = 每一跳都重写 │ │ TTL = 每一跳减 1 │ │ IP 校验和 = 每一跳重算(因为 TTL 变了) │ └───────────────────────────────────────────────────────┘

路由器的工作就是:剥掉旧帧头 → 查路由表 → 用 ARP 找下一跳 MAC → 封装新帧头 → 转发。它从不修改 IP 层以上的内容(NAT 除外,NAT 是刻意打破分层的)。


七、隧道与 VPN:封装的嵌套

隧道技术就是"把一个完整的包当成数据,再封装一次"。

普通 IP 包: [以太网][IP][TCP][数据] VXLAN 封装(数据中心常见): [外层以太网][外层IP][UDP][VXLAN头][内层以太网][内层IP][TCP][数据] └────────── 底层网络只看这部分 ──────────┘└──── 当作纯数据 ────┘ IPsec 隧道模式: [以太网][新IP][ESP头][原IP][TCP][数据][ESP尾][ESP认证] └──── 加密部分 ────┘ WireGuard: [以太网][IP][UDP][WG头][ 加密后的整个内层 IP 包 ]

隧道带来的直接后果是可用 MTU 变小:

物理 MTU 1500 减 WireGuard 开销 -60 (IP 20 + UDP 8 + WG 32) ───── 隧道内可用 MTU 1440 再减内层 IP + TCP -40 ───── 实际应用层可用 1400

没正确设置隧道 MTU,症状和前面的 ICMP 黑洞一样:小包通、大包死。这是 VPN 环境下排障的第一个检查点。


八、应用层要自己做封装:TCP 粘包

TCP 提供的是字节流,不是消息流。发送方的send()边界在接收方完全不保留。

发送方: send("HELLO") 5 字节 send("WORLD") 5 字节 接收方可能的结果(全都合法): ① recv() → "HELLOWORLD" 一次收完(粘包) ② recv() → "HELLO" "WORLD" 刚好分开 ③ recv() → "HEL" "LOWOR" "LD" 任意切分(拆包)

成因:Nagle 算法合并小包、接收缓冲区累积、MSS 切分、网络重组。

三种解决方案

① 固定长度 [────── 64 字节 ──────] 简单,但浪费空间,只适合定长协议 ② 分隔符 HELLO\r\n WORLD\r\n HTTP 头部用这个。缺点是数据里出现分隔符要转义 ③ 长度前缀 ← 推荐 [长度 4][消息ID 2][ 消息体 ] 二进制友好,无需转义,解析高效

长度前缀的实现

publicstaticclassPacketCodec{privateconstintLenFieldSize=4;privateconstintMsgIdSize=2;privateconstintMaxBodySize=64*1024;publicstaticbyte[]Encode(ushortmsgId,ReadOnlySpan<byte>body){varbuf=newbyte[LenFieldSize+MsgIdSize+body.Length];// 长度字段统计「msgId + body」,不含自己。这个约定必须写进协议文档BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(0),MsgIdSize+body.Length);BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4),msgId);body.CopyTo(buf.AsSpan(6));returnbuf;}/// <summary>从累积缓冲中取出一个完整包;不足则返回 false 等更多数据</summary>publicstaticboolTryDecode(refReadOnlySpan<byte>buffer,outushortmsgId,outReadOnlySpan<byte>body){msgId=0;body=default;if(buffer.Length<LenFieldSize)returnfalse;// 长度字段都没收全intpayloadLen=BinaryPrimitives.ReadInt32BigEndian(buffer);// 必须校验!不校验等于把 OOM 开关交给对端,这是真实的 DoS 入口if(payloadLen<MsgIdSize||payloadLen>MaxBodySize)thrownewInvalidDataException($"illegal length:{payloadLen}");if(buffer.Length<LenFieldSize+payloadLen)returnfalse;// 包体没收全msgId=BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(4));body=buffer.Slice(6,payloadLen-MsgIdSize);buffer=buffer.Slice(LenFieldSize+payloadLen);// 前移,处理下一包returntrue;}}

三个必须做对的细节:

细节说明
字节序网络协议统一大端(Big-Endian)。x86 是小端,混用会让数值完全错乱
长度上限校验对端发一个 20 亿的长度,你就 OOM 了。这是漏洞,不是优化
长度是否含自身定义清楚并写进文档。两端理解不一致是经典联调地狱

UDP 不粘包,但要自己做可靠性

TCP → 字节流 → 会粘包 → 需要长度前缀 → 但顺序/送达/去重由 TCP 保证 UDP → 数据报 → 不粘包 → 收到就是完整一包 → 但可能丢、可能乱序、可能重复、可能超 MTU

所以实时游戏的 UDP 协议头通常自己带:

[序列号 2][确认号 2][确认位图 4][频道 1][标志 1][ 消息体 ]

这正是 KCP、ENet、QUIC、Unreal NetDriver 在做的事。不要自己从零写可靠 UDP,用成熟实现。


九、QUIC:把封装重新组织了一遍

HTTP/3 底下的 QUIC 值得单独提一句,因为它改变了封装结构:

传统 HTTPS: [IP][TCP][TLS记录][HTTP/2 帧][数据] └ 内核 ┘└──── 用户态 ────┘ 问题:TCP 层的一个丢包会阻塞所有 HTTP/2 流(队头阻塞) QUIC / HTTP/3: [IP][UDP][QUIC 包头(明文)][ 加密载荷: QUIC 帧 + HTTP/3 帧 + 数据 ] └ 内核 ┘└──────────────── 全在用户态 ────────────────┘ 改进:流隔离在 QUIC 层,一个流丢包不影响其他流 连接建立和 TLS 握手合并,0-RTT / 1-RTT 建连

关键变化:可靠性和多路复用从内核的 TCP 搬到了用户态的 QUIC,只借用 UDP 做"把包送过去"这件事。这让协议可以快速演进,不必等操作系统更新。


十、抓包验证

理论要落地,抓一次包就全明白了。

# 抓指定主机的 TCP 包,不解析域名,显示详细头部sudotcpdump-iany-nn-vvvhost203.0.113.8 and tcp port443# 抓包存文件,用 Wireshark 分析sudotcpdump-ieth0-wcapture.pcap-s0# 只看 TCP 握手(SYN 包)sudotcpdump-nn'tcp[tcpflags] & tcp-syn != 0'# 查看路径 MTU(Linux)tracepath203.0.113.8# 测试 MTU:加 DF 位发大包,找到不分片能通过的最大值ping-Mdo-s1472203.0.113.8# 1472 + 8(ICMP) + 20(IP) = 1500

Wireshark 里展开一个包,你会看到和前面图完全对应的层级结构:

▼ Frame 123: 1514 bytes on wire ▼ Ethernet II, Src: aa:bb:.., Dst: cc:dd:.. Type: IPv4 (0x0800) ▼ Internet Protocol Version 4, Src: 10.0.0.2, Dst: 203.0.113.8 Total Length: 1500 Protocol: TCP (6) Time to Live: 64 ▼ Transmission Control Protocol, Src Port: 54321, Dst Port: 443 Sequence Number: 1 Flags: 0x018 (PSH, ACK) ▼ Transport Layer Security ...

十一、要点归纳

┌─ 机制 ────────────────────────────────────────────────┐ │ 每层把上层 PDU 整体视为数据,加自己的头部 │ │ 接收端逐层剥离,层与层之间只通过头部约定交互 │ └───────────────────────────────────────────────────────┘ ┌─ 寻址的三个层次 ──────────────────────────────────────┐ │ MAC → 相邻节点,逐跳重写 │ │ IP → 目标主机,端到端不变(NAT 除外) │ │ 端口 → 主机内进程,属于传输层 │ └───────────────────────────────────────────────────────┘ ┌─ 开销与 MTU ──────────────────────────────────────────┐ │ TCP/IP/以太网固定开销约 58 字节 │ │ MSS = MTU - IP头 - TCP头 = 1460(标准以太网) │ │ 小包的头部占比极高 → 实时同步必须合并包 │ │ 避免 IP 分片;UDP 协议保守取 1200 字节 │ │ ICMP 被过滤导致 PMTUD 失效 → 小包通、大包死 │ └───────────────────────────────────────────────────────┘ ┌─ 应用层的责任 ────────────────────────────────────────┐ │ TCP 是字节流 → 必须自己定消息边界(长度前缀) │ │ 长度字段必须校验上限,否则是 OOM 漏洞 │ │ 统一用大端字节序 │ │ UDP 不粘包但不可靠 → 用 KCP/ENet,不要自己写 │ └───────────────────────────────────────────────────────┘

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询