Appearance
第13章 Data Offset、Window、Checksum 和 Urgent Pointer
前面讲过的端口号、Seq、Ack 和 Flags 已经勾勒出了 TCP 首部的主干。本章我们来补齐最后四组固定字段:Data Offset 用于定位数据起点,Window 负责公布接收方的序列号空间,Checksum 用来检查传输差错,而 Urgent Pointer 则标记了紧急信息的边界。
在我们第三篇的共用抓包样本中,这四个字段的原始值如下:
| 字段 | 原始字节或值 | 当前可直接读出的信息 |
|---|---|---|
| Data Offset | 0x80 的高 4 位为 8 | TCP 首部为 32 字节 |
| Window | fa f0,即 64240 | 原始 16 位窗口值 |
| Checksum | e7 fd | 线上字段值为 0xe7fd |
| Urgent Pointer | 00 00,且 URG 为 0 | 当前报文不解释紧急指针 |
后续各节将带你把这些冷冰冰的“字段读数”,还原成结合上下文的真实连接状态。
Data Offset:TCP 数据从哪里开始
Data Offset(数据偏移)字段占 4 个比特(bit),它的单位是 32 位的字(即 4 字节)。假设字段值为
4 位无符号数的最大值是 15。由于一个合法的 TCP 首部至少要包含 20 字节的固定部分,因此
| Data Offset | TCP 首部长度 | 选项区域长度 |
|---|---|---|
| 5 | 20 字节 | 0 字节 |
| 8 | 32 字节 | 12 字节 |
| 10 | 40 字节 | 20 字节 |
| 15 | 60 字节 | 40 字节 |
在原始报文数据中,从 TCP 首部起点向后数第 12 个字节,它的高 4 位就是 Data Offset。比如,如果该字节是 0x80,高 4 位为 8,就意味着 TCP 首部长度是 32 字节,TCP 负载数据从这里开始。
从 IP 长度推导 TCP 数据长度
对于普通的 IPv4 数据报,IP 层的 Total Length 包含了 IPv4 首部、TCP 首部以及真正的 TCP 数据,因此:
回顾我们的共用样本:IP Total Length 是 62 字节,IPv4 的 IHL(首部长度)为 5,TCP Data Offset 为 8。我们可以这样计算:
在这个没有 VLAN 标签的普通 Ethernet II 帧样本里,TCP 数据的起点相对整个以太网帧开头的偏移量就是 HELLO TCP\n。如果你抓包时碰到了 Linux cooked capture (SLL)、回环接口(Loopback)、VLAN 标签或是隧道封装,链路层头部的长度就会发生变化;不过,IP 层和 TCP 层内部的长度计算,依然完全依赖它们各自的字段,不受底层影响。
如果是 IPv6,基础首部是 40 字节,但如果有扩展首部,就会增加 TCP 之前的网络层长度。虽然 Wireshark 的 tcp.hdr_len 和 tcp.len 字段已经帮我们把这些算好了,但如果你想自己手工推导,记得要逐层剥离 IPv6 的扩展首部。
Window:接收方还能接收多少数据
Window(窗口)是一个 16 位的无符号字段,报文的发送方用它来向对方“广播”自己当前的接收能力(即接收窗口大小)。在连接建立后(即 ACK 标志生效时),它以当前报文的 Ack 号作为左边界,表示该主机目前能够接收的后续序列号空间。SYN 报文中携带的是初始的、未经缩放的 Window 值;而下面我们要讲的区间公式,则适用于 ACK 已经生效的常规报文。如果没启用窗口缩放(Window Scale),这个字段的最大值只能是 65535 字节。
假设当前报文携带的确认号为 Ack = A,首部中的原始 Window 字段值为
这样一来,接收方开放的有效接收窗口就可以用一个半开半闭区间来表示:
举个例子,假设服务端发来这样一个报文:
text
Ack = 5001
Window field = 4096
Window scale shift = 4那么服务端的实际有效窗口大小就是:
窗口的右边界也就是
这里有一个关键点:Window Scale 选项是有方向性的。服务端在 SYN 报文里声明的位移数,是用来放大服务端自己日后发出的 Window 字段;而客户端声明的位移数,则用来放大客户端自己的窗口。另外,SYN 和 SYN+ACK 报文自身的 Window 字段永远是原始的 16 位绝对值,窗口缩放只有在三次握手完成后的数据传输阶段才会生效。关于这个协商机制,我们会在第 14 章详细展开。
TCP 首部里的 Window 字段反映的是接收方(也就是所谓的 rwnd,接收窗口)还能腾出多少空间,它主要受接收缓冲区大小和应用程序读取速度的影响。而我们常说的拥塞窗口(cwnd),则是发送方根据网络拥堵情况自己估计出来的一个值,它只保存在发送方本机的内存里,不会出现在 TCP 首部中。发送方在决定当前能发多少数据时,会综合考量接收窗口 rwnd、拥塞窗口 cwnd、网络中已在途(In-flight)的数据量、本地发送缓冲区大小以及应用程序的产出速度。
当报文中的 Window 值为 0 时,意味着接收方暂时关上了大门,不接受任何新数据,但 TCP 连接本身依然保持连通。此时,发送方会启动定时器,通过发送零窗口探测报文(Zero Window Probe)来不断询问对方窗口是否已经恢复。这个现象我们会在第 22 章进行专门的代码级诊断。
Checksum:校验端点、首部和数据的完整性
TCP Checksum(校验和)占 16 位。在 TCP 协议中,发送方强制要求计算校验和,接收方也强制要求验证它。这个校验和的计算范围不仅限于 TCP 自身,它一共包含三部分输入:
- IP 伪首部(Pseudo-header);
- 完整的 TCP 首部(在计算时,先把 Checksum 字段本身临时当作全 0 填入);
- TCP 负载数据(如果数据总长度是奇数个字节,还要在末尾补一个全 0 的字节,这个填充字节纯粹是为了凑偶数参与计算,不会真的发出去)。
对于 IPv4,IP 伪首部包含了源 IP 地址、目标 IP 地址、一个全 0 的字节、协议号(TCP 为 6)以及 TCP 报文的总长度。如果是 IPv6,伪首部则包含 128 位的源/目标 IPv6 地址、TCP 报文总长度、保留的零位,以及代表 TCP 的 Next Header 值(同样是 6)。值得一提的是,即便 IPv6 基础首部和 TCP 之间夹杂了各种扩展首部,伪首部里填入的协议号依然是 6。
在实际计算时,协议栈会临时从真实的 IP 首部里把这些信息“摘”出来拼成伪首部。如果你对着抓包软件的 Packet Bytes(底层字节流)去查,源 IP 和目标 IP 都在 IP 层的区域里,而 TCP 层的字节流依然是从源端口(Source Port)开始的。之所以要搞个伪首部,是为了让校验和也能覆盖到 IP 层的通信端点和协议类型,万一路由器把 IP 地址改错了,或者把 UDP 错当成了 TCP,接收端在算校验和时就能立马发现这种“投递错误”。
TCP Checksum 使用的是经典的“16 位反码求和(One's Complement Sum)”算法:把上述所有输入数据按 16 位(即 2 字节)为单位累加,如果相加过程中产生了溢出(超过 16 位的进位),就把溢出的高位再加回低 16 位里,最后对最终的结果逐位取反。
我们用三个简单的 16 位十六进制数来演示一下这个“进位回卷(折叠)”的过程:
产生了一个进位 1,把它加回到低 16 位里:
最后对 0x1135 逐位取反,就得到了校验和 0xEECA。在真实的 TCP 报文中,发送方要把伪首部、TCP 首部(包含各种选项)以及数据一起走一遍这个流程。接收端收到报文后,会把所有数据连同发送方填好的 Checksum 字段一起丢进算法里再算一遍。如果一切无误,因为“原值”和“反码”相加,最终的结果一定会是全 1,也就是 0xFFFF。
需要明确的是,TCP Checksum 只是一把“防君子不防小人”的锁,它非常擅长发现网络传输中因为线路噪声等引起的偶发比特翻转错误(Bit Flip)。但是,如果中间有个主动的攻击者篡改了你的数据,他完全可以顺手帮你把校验和重新算对。所以,真正的身份认证和防篡改保护,必须要依靠 TLS、IPsec 或者 TCP-AO 等密码学安全机制来承担。
硬件卸载(Checksum Offload)导致的“抓包报错”
现代网卡(NIC)通常都支持发送校验和卸载(Checksum Offload)功能,也就是把算校验和这种苦力活交给网卡硬件去做,减轻 CPU 负担。这样一来,当操作系统把构造好的报文交给抓包软件(如 Wireshark、tcpdump)时,首部里的 Checksum 字段根本还没算,通常被填成全 0 或垃圾值。等报文真要离开网卡流向网线时,网卡才会把它填上。
这就是为什么你在发送端主机上抓包时,经常会看到满屏飘红,提示 Checksum incorrect 或者 unverified,但接收端收到的报文却完全正常的原因。
接收方向也一样,网卡可能已经在硬件层面验证过校验和了,甚至可能合并了报文(如 LRO/GRO),然后再传给系统。所以,在 Wireshark 里看到大片红色的校验和报错时,不要立刻慌张,它往往只是一条提示线索。要判断到底是真出错了还是硬件卸载导致的假象,我们需要结合以下几点综合分析:
- 网卡的 Checksum Offload 功能是否开启;
- 出错报文的方向(是你发出的,还是你收到的?);
- 结合接收端或网络链路中间节点上的抓包进行双向比对;
- TCP 连接层面是否出现了大面积的重传,或者应用层是否报错说数据损坏;
- Wireshark 本身的校验和验证设置是否没勾选正确。
在 Windows 系统中,我们可以使用 PowerShell 命令来查看本地网卡的卸载功能配置(只读不修改):
powershell
Get-Command Get-NetAdapterChecksumOffload
Get-NetAdapterChecksumOffload | Format-Table -AutoSize注意,这些命令是否可用,取决于你的 Windows 版本、网卡驱动程序以及管理员权限。在进行抓包排错时,养成将网卡名称、抓包方向和卸载状态输出一起记录在案的好习惯,能帮你省去不少麻烦。
Urgent Pointer:标记紧急数据的边界
Urgent Pointer(紧急指针)占 16 位,而且只有当 TCP 首部中的 URG 标志位置 1 时,这个字段才有效。根据 RFC 9293 的定义,紧急指针的值是一个相对于当前报文序列号(Seq)的正向偏移量。把 Seq 加上这个偏移量,就能算出紧急数据结束后的第一个字节的位置:
举个例子,如果当前报文的 Seq = 10000,Urgent Pointer = 5,且 URG = 1,那么绝对的紧急指针位置就是 10005。这意味着,从当前序列号起,直到 10005 之前的数据(不包括 10005),都是处于“紧急”状态的待消费数据。
当 URG 标志为 0 时,Urgent Pointer 的值即使是 0 也只是首部里的固定占位符,没有实际意义。
这里要补充个历史背景:早期的网络协议和不同操作系统的 Socket API 对于“紧急数据(Urgent Data)”、“带外数据(Out-of-Band, OOB)”以及它们到底在哪里结束,曾经产生过严重的分歧。因为这些历史遗留问题,现代的新应用已经极少使用 TCP 层的 Urgent 机制了,大家更倾向于在应用层自己定义控制消息(比如在 HTTP/2 里用单独的 Stream 发送控制帧)来保证跨平台的行为一致性。如果你在排查旧时代的遗留系统协议,不仅要参考 RFC 的标准语义,还得去查阅那个年代操作系统 API 和应用程序的真实读取方式,才能理清头绪。
动手实验:逐个字段“解剖”一个报文
我们继续复用第 11 章里那个 39011 端口的抓包文件,随便挑选一个带有载荷(即 tcp.len > 0)的数据报文。在 Wireshark 中展开 IPv4/IPv6 层和 TCP 层的详情树,对照着记录以下信息:
| 观察项 | Wireshark 常用解析字段 | 手工核对方法 |
|---|---|---|
| IP 报文长度 | ip.len、ip.hdr_len 或 IPv6 Payload Length | 用于推算包含 TCP 首部和数据的总长度 |
| TCP 首部长度 | tcp.hdr_len | 读取 Data Offset 字段,乘以 4 |
| TCP 负载长度 | tcp.len | IP 层总长度减去 IP 首部长度,再减去 TCP 首部长度 |
| 原始接收窗口 | tcp.window_size_value | 直接读取 TCP 首部中的 16 位 Window 字段 |
| 有效接收窗口 | tcp.window_size | 原始 Window 值乘以该通信方向上的窗口缩放因子 |
| 校验和 (Checksum) | tcp.checksum、tcp.checksum.status | 结合抓包方向与网卡硬件卸载状态来判断真伪 |
| 紧急指针 | tcp.urgent_pointer | 仅在 URG=1 时,用 Seq 加上偏移量计算出绝对位置 |
核对完这些信息后,去底部的 Packet Bytes 窗口(十六进制字节流)亲自找一找 TCP 数据的起点:找到源端口和目标端口后,向后数 12 个字节,读出这个字节的高 4 位(这就是 Data Offset)。自己乘个 4 算一算数据起点在哪里,然后鼠标点一下 Wireshark 里解析出的 TCP Payload,看看它高亮的起始位置和你的手算结果能不能对得上。
如果你手头还有一台虚拟机,可以在发送端和接收端同时开启抓包,过滤出同一个 TCP 四元组。利用 Seq、Ack、TCP Len 和时间戳把两端的同一个报文关联起来,对比一下两边显示的 Checksum 状态。通常来说,发送端抓到的包会显示“未经验证”或“错误”的值(因为硬件还没填),而接收端抓到的则是计算好的有效值。自己做一遍这种对比实验,能彻底治愈被“大片红色 Checksum 报错”支配的恐惧。
理解检查
- 一个 IPv4 数据报的
Total Length = 100,IPv4 首部由于带有选项变为了 24 字节,TCP Data Offset 为 10。请问实际的 TCP 数据长度是多少? - TCP 首部中的 Window 原始值为 3000,且通过握手得知该方向的 Window Scale 因子是 7。此时的有效接收窗口是多少?
- 假设报文中
Ack = 10001,Window = 32768,并且 Window Scale 的位移值(shift)为 0。那么它向对方公布的允许接收序列号半开区间是什么? - 当
Seq = 50000,Urgent Pointer = 12且URG = 1时,这段紧急数据的结束边界在哪里? - TCP Checksum 能帮你防范哪一类的错误?如果是为了保证业务身份认证和内容防篡改,应该依靠什么机制?
参考答案
- TCP 首部为
字节,数据长度为 字节。 字节。- 有效窗口为 32768,区间是
。 - 终点在
。 - TCP Checksum 只能用于发现传输过程中的偶发比特翻转差错;要想防范主动篡改和实现身份认证,必须依赖 TLS、IPsec 或 TCP-AO 等密码学安全协议。
本章小结
- Data Offset(数据偏移)乘以 4 可以得出 TCP 首部的总字节数;从 IP 层的总长度中减去网络层首部和 TCP 首部,就能算出真正的 TCP 负载数据长度。
- Window(接收窗口)以报文 Ack 号指定的左边界为起点,配合握手时协商的 Window Scale 因子,向对方广播当前有效的可接收序列号空间。
- Checksum(校验和)在计算时会涵盖临时拼凑的 IP 伪首部、完整的 TCP 首部以及载荷数据。由于现代网卡普遍支持校验和硬件卸载(Checksum Offload),在发送端本地抓包时常常会看到虚假的校验和错误。
- Checksum 只能应对链路噪声等偶发传输差错,抵御恶意篡改和提供身份认证必须依靠 TLS、IPsec 或 TCP-AO 等安全机制。
- 当且仅当 URG 标志位为 1 时,Urgent Pointer 才会生效,它表示紧急数据结束边界相对于当前 Seq 的偏移量。
规范依据
- RFC 9293:Transmission Control Protocol (TCP),定义 Data Offset、Window、Checksum、IP 伪首部和 Urgent Pointer。
- RFC 8200:Internet Protocol, Version 6 (IPv6) Specification,定义 IPv6 上层协议校验和使用的伪首部。
- RFC 7323:TCP Extensions for High Performance,定义 Window Scale 与高性能扩展的窗口解释。