Skip to content

第10章 Sequence Number:给字节编号

TCP 向应用层提供有序的字节流。但在底层,网络可能会将一段数据拆分成多个报文段(Segment),这些报文段到达的时间和顺序往往也是错乱的。Sequence Number(序列号,简称 Seq)的作用就是为字节流中的每个位置提供统一的坐标,让接收方能够正确地重排数据、识别数据缺口,并把重传的数据放到正确的位置上。

本章我们将继续使用之前的 10 字节数据样本:

text
Seq = 0x6a1b2c3e
Payload = 48 45 4c 4c 4f 20 54 43 50 0a
          H  E  L  L  O     T  C  P  \n

Seq 指向当前报文段的首个数据字节

TCP 首部中的 Sequence Number 字段占用 32 位(bit)。当报文段携带数据时,Seq 表示该报文段中第一个数据字节,在当前发送方向的整个字节流空间里所处的位置。

在我们提供的样本中,第一个字节 H 的编号(即 Seq)是 0x6a1b2c3e。随后的每个字节,其编号依次加 1:

Payload 偏移字节字符线上序列号
048H0x6a1b2c3e
145E0x6a1b2c3f
24cL0x6a1b2c40
34cL0x6a1b2c41
44fO0x6a1b2c42
520空格0x6a1b2c43
654T0x6a1b2c44
743C0x6a1b2c45
850P0x6a1b2c46
90a换行0x6a1b2c47

这组 10 字节的数据占据了一个左闭右开的序列号区间:

[0x6a1b2c3e, 0x6a1b2c48)

区间的左端点属于当前报文段,而右端点则是下一段新数据的起点。数据长度对应的计算过程如下:

0x6a1b2c3e+10=0x6a1b2c48

因此,该发送方向上的下一个 Seq 就是 0x6a1b2c48。在第11章中我们还会看到,当接收方完整收到这 10 个字节后,它回复的 Acknowledgment Number(Ack)也会刚好指向这个右端点。

Seq 编号的对象是字节位置

假设应用程序一次性向 Socket 写入了 10 字节数据 HELLO TCP\n。TCP 在底层可以将它们打包成一个单独的报文段:

text
段 A:Seq = 1,Len = 10,覆盖 [1, 11)

当然,TCP 也可以将它们拆分成两个报文段发送:

text
段 A:Seq = 1,Len = 4,覆盖 [1, 5)   -> HELL
段 B:Seq = 5,Len = 6,覆盖 [5, 11)  -> O TCP\n

无论是哪种分段方式,它们覆盖的都是同一个字节区间 [1, 11),接收方的应用程序最终读到的也是完全相同的有序字节流。这个例子说明了一个核心概念:Seq 的连续性是建立在“字节位置”之上的。至于报文段的边界(即如何切分数据),则会随着 MSS(最大报文段长度)、拥塞状态(Congestion Control)、发送时机以及网卡卸载(Offload)等因素动态变化。

在拆分发送的情况下,如果第二个报文段先到达,接收方可以根据 Seq = 5 把它安置在后半段的位置,并继续等待接收区间 [1, 5) 的数据。如果第一个报文段丢失需要重传,它的重传副本依然会使用 Seq = 1 以及相同的覆盖区间。正是因为有了序列号,TCP 才能在面对乱序到达和重复报文时,拥有统一的识别和处理依据。

双向通信拥有独立的序列号空间

TCP 提供的是全双工通信(Full-Duplex)。在建立连接时,客户端到服务端,以及服务端到客户端这两个方向,会各自独立生成一个初始序列号,并在后续的数据传输中分别向前推进:

text
客户端 -> 服务端:客户端 Seq 空间
服务端 -> 客户端:服务端 Seq 空间

回到我们的样本字段:

text
Seq = 0x6a1b2c3e
Ack = 0x10203041

在这个例子中,Seq 描述的是客户端当前的发送进度,而 Ack 则指向服务端发送方向的进度(即客户端期望从服务端收到的下一个字节位置)。当客户端发送 10 字节后,客户端方向的下一个 Seq 会推进到 0x6a1b2c48;至于服务端方向的 Seq 会如何推进,则完全取决于服务端实际发送了多少数据,以及是否发送了 SYN 或 FIN 控制位。

在响应报文中,双方的角色会发生对换。例如,在服务端还没有准备好发送应用层数据时,它回复的纯 ACK(不带数据的确认报文)可能会是这样的:

text
Server Seq = 0x10203041
Server Ack = 0x6a1b2c48

可以看到,这里的两个数值恰好分别对应两套独立的序列号空间。关于 Ack 累计确认(Cumulative Acknowledgment)的具体语义,我们将在第11章中为您详细讲解。

SYN 和 FIN 控制位各占用一个序列号

除了实际的数据字节会占用序列号空间外,SYN 和 FIN 这两个控制位在被设置时,也会各自“消耗” 1 个序列号位置。因此,在发送完一个报文段后,下一个报文段的起始序列号(NextSeq)可以通过以下公式统一计算:

NextSeq=(Seq+L+S+F)mod232

其中:

  • L 是 TCP Payload 的真实字节数;
  • 当 SYN 置位时 S=1,其余情况 S=0
  • 当 FIN 置位时 F=1,其余情况 F=0

需要注意的是,像 ACK、PSH、RST 这样的标志位,都不会消耗序列号空间。同时,TCP Options(TCP 选项)属于首部的一部分,因此选项的长度也不会计入数据长度 L 中。

情形一:纯 ACK

text
Seq = 7000,Payload Len = 0,SYN = 0,FIN = 0
NextSeq = 7000 + 0 + 0 + 0 = 7000

由于计算结果没有增加,纯 ACK 可以连续多次发送且使用相同的 Seq。它纯粹是为了向对方报告接收状态,而本方向的新数据起点仍然保持在原位置。

情形二:SYN 携带选项

假设客户端的初始序列号 ISN 为 0x6a1b2c3d,其发出的 SYN 报文携带了 20 字节的 TCP Options,但 Payload 长度为 0:

NextSeq=0x6a1b2c3d+0+1+0=0x6a1b2c3e

这也正是我们在前面样本中首段数据所使用的 Seq。在这个过程中,20 字节的 TCP Options 只是扩充了 TCP 首部的长度,并未占用序列号空间;只有 SYN 标志本身消耗了 1 个序列号。

情形三:普通数据

在这个样本中,TCP 标志位(Flags)包含了 ACK 和 PSH,且承载了 10 字节的数据(Payload):

0x6a1b2c3e+10+0+0=0x6a1b2c48

情形四:FIN 与数据同行

假设某个报文段的 Seq 从 5000 开始,它携带了 3 字节的数据,并且同时设置了 FIN 标志位:

text
数据字节位置:5000、5001、5002
FIN 的位置:  5003
NextSeq:       5004

套用前面的公式,也能得出同样的结果:

5000+3+0+1=5004

当对端接收并确认这段数据及 FIN 标志时,它回复的累计 Ack 同样会指向 5004。

ISN:初始序列号从何而来

在 TCP 三次握手期间,通信双方会各自挑选一个 32 位的初始序列号(Initial Sequence Number,简称 ISN)。客户端会在其发送的 SYN 报文中声明自己的 ISN,而服务端则会在 SYN+ACK 报文中声明服务端的 ISN。握手完成后,双方发送的首个实际数据字节的编号,都会从各自的 ISN 加 1 开始。

我们在本教程样本中做如下约定:

text
客户端 ISN = 0x6a1b2c3d
服务端 ISN = 0x10203040

于是握手之后:

text
客户端首个数据 Seq = 0x6a1b2c3e
服务端首个数据 Seq = 0x10203041

现代操作系统的 TCP 协议栈会根据相关规范,结合安全策略,动态生成具备一定随机性和安全性的 ISN,以防止序列号预测攻击(Sequence Number Prediction Attack)。在进行抓包分析时,我们不需要关心 ISN 的具体生成算法,只需从握手报文中读取双方的原始 Seq 作为基准,随后顺着两个方向分别进行推算即可。

Wireshark 中的相对序列号(Relative Sequence Number)

在排查网络问题时,像 1780165694 这样庞大的十进制原始序列号读起来非常费力。为了方便分析,Wireshark 默认开启了“相对序列号”(Relative sequence numbers)功能。它会将每个方向上捕获到的第一个起始序列号作为基准点映射为 0,之后的序列号都显示为相对于这个基准的偏移量。

以客户端方向为例:

报文线上原始 SeqWireshark 相对 Seq序列号消耗
SYN0x6a1b2c3d01
首段 10 字节数据0x6a1b2c3e110
下一段新数据0x6a1b2c4811取决于新 Payload

相对序列号的计算逻辑可以简单理解为:

RelativeSeq=(RawSeqBaseSeq)mod232

对样本的首段数据计算如下:

0x6a1b2c3e0x6a1b2c3d=1

在 Wireshark 中,字段 tcp.seq_raw 保存的是抓包到的真实原始值,而 tcp.seq 则是根据当前显示设置计算出的结果(通常就是相对值)。如果你在撰写排查分析报告,建议标注清楚所使用的到底是原始值还是相对值。通常来说,相对值更适合用来展示数据流的推进关系;而原始值则在核对底层字节流(Packet Bytes)或对比多款抓包工具的结果时更加可靠。

需要特别注意的一点是,如果你是从 TCP 连接中途开始抓包的(即没有抓到握手阶段),Wireshark 只能把你抓到的第一个报文当作显示基准。此时,这个基准值与真实握手时的 ISN 之间究竟相差多少,往往需要通过其他报文证据才能确认。

32 位序列号的回绕与模运算

因为 Sequence Number 字段只有 32 位,所以它的数值范围是固定且有限的:

0  2321=0  4294967295

当序列号不断累加,超过这个最大值后,它会发生“回绕”(Wrap Around),即从 0 开始重新计数。让我们来看一个即将回绕的例子:

text
Seq = 0xfffffffc = 4294967292
Payload Len = 8

如果用普通的整数加法计算,结果为:

4294967292+8=4294967300

但在 32 位的空间里,我们需要对 232=4294967296 进行取模(求余)运算:

4294967300mod4294967296=4

所以正确的下一跳序列号是:

text
NextSeq = 0x00000004

这 8 个字节的序列号编号依次是:

text
fffffffc fffffffd fffffffe ffffffff 00000000 00000001 00000002 00000003

在 TCP 的协议规范中,正是通过这种基于序列号空间的模运算,配合接收窗口的大小,来判断报文的新旧与先后关系的。现代化的抓包分析工具(如 Wireshark)会自动帮你处理这种回绕现象;但在手工计算和分析跨越最大值边界的报文时,一定要记住顺着这个环形(Ring)空间去推导。

网卡卸载(Offload)会改变本机抓包的观察单位

现代操作系统和网卡通常会启用 TSO(TCP Segmentation Offload)或 GSO(Generic Segmentation Offload)等硬件加速功能。在开启这些功能的情况下,如果在发送端主机上进行抓包,你有可能会看到一个体积远超 MTU 的“巨型” TCP 数据段。这是因为操作系统的网络栈将分段工作交给了网卡硬件,而抓包点往往位于网卡分段操作之前,因此抓到的是未拆分的数据。网卡在随后发送到线路上时,才会真正将其切分成一个个适合网络传输的小报文段。同理,接收端开启的合并卸载(如 GRO/LRO)也会将多个小报文合并,导致接收侧主机抓包时看到“偏大”的报文单元。

不过无需担心,序列号的计算规则依然保持一致:这个“巨型”数据段覆盖的区间跨度,仍然严格等于它的起始 Seq 加上其承载的 TCP 数据长度。关于如何通过不同的抓包位置和卸载状态来辨认这类现象,我们将留在第32章中详细探讨。

动手实验:在相对值与原始值之间切换

实验步骤

  1. 捕获一条从 SYN 开始的完整连接,显示过滤器使用 tcp.stream == N
  2. 为数据包列表添加 tcp.seqtcp.seq_rawtcp.len 三列,或在字段树中逐项查看。
  3. 在 Wireshark 的 TCP 协议首选项中记录 Relative sequence numbers 的当前状态。
  4. 依次记录客户端 SYN、第一条客户端数据、下一条客户端数据的 Seq 与 Len。
  5. Seq+L+S+F 计算每条报文的右端点。
  6. 切换相对序列号显示,再次读取同一批报文;检查 Packet Bytes 中的四个原始字节是否保持原值。
  7. 若出现重传,比较重传报文与原报文的原始 Seq、Len 与覆盖区间。

预期现象

  • 在抓取到完整握手过程的前提下,SYN 报文的相对 Seq 通常会显示为 0,而随后发送的首个数据字节的相对 Seq 将从 1 开始。
  • 对于普通的数据报文段,其下一个 Seq 将严格等于当前的 Seq 加上 tcp.len
  • 纯 ACK 报文的 tcp.len 为 0,因此本发送方向的 Seq 会维持不变,停留在新数据的起点。
  • 发生重传时,重传的报文段会再次覆盖先前已经分配的序列号区间。
  • 切换 Wireshark 的“相对序列号”显示设置,仅仅会改变界面上的数值展示方式,底层的 pcap 抓包文件中实际记录的那 4 个字节的 Seq 原始值不会发生任何改变。

小测验:理解检查

  1. 假设有一条纯 ACK 报文,它的 Seq 为 700,且 Payload Len 为 0。请问该方向上的下一个 Seq 是多少?
  2. 某条数据报文段的 Seq 为 1001,Payload Len 为 400。请问它覆盖了哪个左闭右开区间?下一个 Seq 是多少?
  3. 有一条报文的 Seq 为 3000,其中携带了 2 个字节的数据,并且设置了 FIN 控制位。请问下一个 Seq 是多少?
  4. 有一条 SYN 报文,它的 Seq 为 0xabcdef00,不仅包含 40 字节的 TCP Options 选项,Payload 长度为 0。请问下一个 Seq 应该推进到哪里?
  5. 某报文的 Seq 已经达到了 0xfffffffe,且 Payload Len 为 4。请按照 32 位空间的模运算规则,计算出下一个 Seq。
参考答案
  1. 700+0=700。由于没有数据也没有 SYN/FIN,序列号不会推进。
  2. 它覆盖的区间为 [1001,1401),下一个 Seq 也就是右端点,即 1401。
  3. 3000+2+1=3003。2 字节数据消耗 2 个位置,FIN 控制位额外消耗 1 个位置。
  4. TCP Options 选项位于首部,对序列号空间的贡献为 0,只有 SYN 控制位消耗 1 个位置,所以下一个 Seq 为 0xabcdef01
  5. 0xfffffffe + 4 的结果对 232 取模,发生回绕,最终结果为 0x00000002

本章小结

  • Seq(Sequence Number)指向当前发送方向中,当前报文段所承载的第一个数据字节在整个字节流中的位置。
  • 长度为 L 的数据报文段,会覆盖序列号空间中左闭右开的区间 [Seq,Seq+L)
  • SYN 和 FIN 这两个控制位在被设置时,也会各占用一个序列号位置;而 TCP Options 选项仅扩充 TCP 首部,不消耗序列号空间。
  • TCP 连接中的两个发送方向拥有互相独立、各自生成的初始序列号(ISN)和序列号空间。
  • 发生数据重传时,重传报文会复用原来的序列号区间;而乱序到达的报文段也是依靠 Seq 来找回自己在字节流中的正确位置。
  • Wireshark 提供的“相对序列号”功能更加便于我们阅读和分析推进关系,但如需查看真实的四字节原始值,应参考 tcp.seq_raw 字段。
  • 32 位的序列号空间是环形的,它按 232 进行模运算,当超过最大值(4294967295)后,会平滑地回绕到 0 重新开始。

如需进一步深入了解相关细节,推荐阅读 RFC 9293 规范中的 Sequence Numbers 章节


上一章:第9章 源端口和目标端口 · 所属篇:第三篇 · 下一章:第11章 Acknowledgment Number · 教程总览

Licensed under CC BY-NC-SA 4.0.