一个 TCP 段携带四字节数据,接收端为什么可能仍返回与之前相同的 ACK?如果把确认号机械地写成“本段 seq 加长度再加一”,就无法解释数据前面存在缺口的情况。确认号描述连续接收进度,并不只是对当前报文长度做算术。

第 14 篇用按块编号的教学协议组合了确认、重传、窗口和去重。本篇转向真实 TCP:编号单位变成字节,确认语义也不能直接照搬逐块 ACK。连接状态迁移留给第 16 篇,这里只分析建立、传输和关闭过程中相关的序号占用。

编号属于一个方向的字节序列

TCP 是双向通信。一条连接中,两个方向分别维护发送序号与接收进度,不共享一个全局计数器。客户端发出的 ACK 确认服务端方向的数据。计算客户端还有多少已发字节未被确认,则需要服务端返回的 ACK 和客户端发送状态。

RFC 9293 §3.4定义了序号空间。数据按字节计数,SYN 与 FIN 各占一个序号,纯 ACK 不占。对一个段,序号空间中的占用长度可以写成:

1
2
占用长度 = 数据字节数 + (SYN ? 1 : 0) + (FIN ? 1 : 0)
段后边界 = (SEQ + 占用长度) mod 2^32

“段后边界”不等于“接收端必然返回的累计 ACK”。前者由本段内容计算,后者还取决于此前连续接收到了哪里,以及本段是否符合当前连接的接收条件。

假设 SYN 的 SEQ=1000,后续第一个数据字节通常从 1001 开始。四字节 ABCD 占用 1001、1002、1003、1004,下一个数据字节编号为 1005。如果再发四字节 EFGH,它们占用 1005 到 1008;在这八字节之后发送的 FIN 占用 1009,确认到 FIN 后才是 1010。

因此,有序收到四字节数据时,只增加四;收到不带数据的 SYN 或 FIN 时,才因该控制位增加一。ACK 标志本身不增加序号,不能在每个带 ACK 的数据段后再加一。

累计确认停在连续前缀之后

累计 ACK 表示接收端下一期待的序号。若 ACK=1001,就不能仅凭收到一个从 1005 开始的段,把确认推进到 1009:1001 到 1004 的缺口仍然存在。

以接收窗口足够、接收端保留乱序数据、序号不回绕为条件,可以逐步手算:

到达事件 本段占用范围 事件后的累计 ACK 未连续部分
SYN,SEQ=1000 [1000,1001) 1001
EFGH,SEQ=1005 [1005,1009) 1001 已缓存 1005–1008
ABCD,SEQ=1001 [1001,1005) 1009 缺口填上,连续到 1008
FIN,SEQ=1009 [1009,1010) 1010 接收方向结束标记已连续到达

这里描述的是每次处理之后的接收状态,并不要求 TCP 实现为表格中的每个事件单独发送一个 ACK。确认可能延迟、合并或与反向数据一起发出,不能用缺少某个单独 ACK 帧反推字节计数规则错误。

如果 FIN 比缺失的数据先到,也不能让应用越过缺口看到一份完整结束的字节流。结束标记同样处于序号空间中,需要结合连续性解释。重传的数据仍使用原来的字节位置,不产生一套新编号。

重组不能改变应用数据次序

接收端可以缓存先到的较后字节,等缺口补齐再推进连续边界。缓存策略、资源限制和重复重叠的处理属于实现问题;“收到某段”与“把它交给应用”需要分别取证。

RFC 9293 §3.7说明应用写入与分段没有一一对应关系。发送两次四字节写入,不保证线上恰好出现两个四字节段;接收端一次读到八字节,也不能据此判断线上只有一个段。

实验应同时保存应用输入、应用输出以及报文中的字节范围。应用输出证明这次实际读取了什么,报文记录解释序号与确认如何变化。两类记录互相补充,不能用其中之一替代另一类。

SACK 补充哪些范围已经到达

累计 ACK 只指出连续前缀的边界。当后面已有多段数据,发送端还需要额外信息来避免对所有后续内容作同样判断。本篇采用 RFC 2018 的基础 SACK 语义,用区间报告已经收到、但尚未被累计确认覆盖的部分。

RFC 2018 §2–4规定了许可选项和块格式。SACK-Permitted 出现在 SYN 中;数据接收方只有收到对端的许可,才可以发出 SACK。看到建立阶段的许可,只能证明相应能力已经通告,不能证明这次传输实际发生过 SACK 恢复。

每个 SACK 块用左边界和右边界表示左闭右开区间。对于前述乱序四字节,块为 [1005,1009),最后一个已经收到的字节是 1008,而不是 1009。它可以与 ACK=1001 同时出现:连续前缀仍缺数据,后续部分已经缓存。

SACK 不替代累计 ACK,也不表示应用已经读取、写盘或完成业务。RFC 2018 §8允许接收端后来放弃已经报告的缓存数据,因此发送端不能仅凭 SACK 就把相关重传数据按累计确认的方式永久释放。完整恢复算法需要后续篇章的计时与拥塞机制。

重复 ACK 不能唯一定位丢失

收到多个相同 ACK,说明确认边界在这些观察点没有推进。它可能是前方缺口导致,也可能与乱序或复制有关。RFC 5681 §3.2明确讨论了这些不同成因。

单点抓包还会引入额外不确定性:某段可能经过另一条路径,捕获程序可能遗漏,网卡卸载可能改变观察到的分段形态。把“抓包没看到”直接改写成“线上没有发送”,会混淆捕获能力和协议行为。

若要判断具体重传的触发依据,需要结合发送端状态、计时信息与完整相关报文。本篇只核对序号空间和实际可见确认,不从一次短传输推断拥塞控制、快速恢复或公网丢包率。

序号回绕与相对显示

TCP 首部中的序号为 32 位,计算需要考虑模 2^32。例如 SEQ=2^32-2 的四字节数据,其后边界为 2。普通整数的“大于”比较不能在回绕附近直接代替序号空间的相对关系。

为了阅读方便,抓包工具常把连接起点归一化为相对序号。相对显示中的 SYN=0 与原始首部中的随机起点并不矛盾。本篇实验保存原始值,并使用本次捕获到的各方向 SYN 序号分别归一化,不把显示用的 0 当成真实线上固定初值。

先运行手算,再运行隔离实验

手算程序只执行前面的固定例子,输出接收状态,不构造或发送 TCP 报文。它还检查纯 ACK 不占序号和一次模回绕计算;没有实现接收窗口、丢弃策略或完整重组算法。
1
2
3
python3 sequence.py
python3 sequence.py --help
python3 sequence.py --unknown

正常运行的四个累计确认状态为 1001,1001,1009,1010,乱序阶段的 SACK 区间为 [1005,1009)。未知参数以状态码 2 拒绝。不要使用 python3 -O,否则内置断言不会执行。

真实实验程序 lab.py应在自有 Linux 测试虚拟机中运行,需要该虚拟机内创建网络命名空间和原始抓包 socket 的权限。它不适用于直接修改宿主正在使用的网络。Python 标准库之外使用预先安装的 ip,乱序场景还使用已安装的 tc;没有安装步骤。

1
2
python3 lab.py --help
sudo python3 lab.py > run.jsonl

每组创建随机前缀的 A、B 命名空间与一对 veth,地址为 10.15.0.1/2410.15.0.2/24。服务端只监听隔离 B 的 46015 端口。程序先绑定 B/eth0 的 AF_PACKET 捕获,再接受连接;捕获点位于接收侧,避免用发送侧排队之前的记录代替实际到达顺序。

应用负载为八个八字节块,总计 64 字节。客户端分次写入后关闭发送方向,继续读取回显;服务端读取到 EOF 后返回实际收到的内容。双方都逐字节核对,不根据抓包重建应用返回值。小块写入与 TCP_NODELAY 只是实验设置,不是“一次写入必有一个段”的保证。

脚本保存原始以太帧、TCP 解码与应用输出。数据长度按 IP 总长减去实际 IP 和 TCP 首部长计算,SYN/FIN 的序号消耗单独相加。两方向分别从捕获的 SYN 提取起点,FIN 前应覆盖相对 [1,65) 的 64 字节,确认到 FIN 后为相对 ACK=66。

本次真实报文与手算对照

2026 年 9 月 20 日在 Linux 6.18.10-200.fc43.aarch64、Python 3.14.3、iproute2 6.14.0 的专用虚拟机完成两组运行。完整记录包含环境、应用字节、原始帧、解码及清理结果;实验说明记录条件和局限。结果是本次隔离环境观察,不是公网或生产测量。

基线组中,A 的原始 SYN 序号为 3926289439,B 为 510217489。接收点看到 A 的八个数据段从相对序号 1,9,17,25,33,41,49,57 开始,各含八字节。这一组恰好与八次写入对应,但第二组已经呈现不同的分段记录。

乱序组仅在自建 A/eth0 出口添加如下队列规则,不作用于宿主接口:

1
tc qdisc add dev eth0 root netem limit 100 delay 100ms reorder 100% gap 2

该命令由脚本经 ip netns exec 在对应命名空间内执行。tc-netem 手册说明延迟与 gap 重排设置;实际排队还包含握手、确认等报文,不能把 gap=2 简单解释为“每两次应用写入必定互换”。100ms 是注入配置值,不是实测公网延迟。

最终乱序组的 A、B 原始 SYN 序号分别为 952091099 和 2818598428。A 数据在 B/eth0 的可见到达起点为 9,1,17,25。以下使用相对序号,帧编号是记录中从零开始的 frame_index

方向与内容 序号或确认 可以核对的事实
6 A→B,八字节数据 SEQ=9 [9,17) 先到,前面的 [1,9) 尚有缺口
7 B→A,ACK 与 SACK ACK=1,SACK=[9,17) 累计确认未越过缺口,后续区间已被报告
8–9 A→B 补齐八字节,B→A 确认 SEQ=1,随后 ACK=17 连续前缀从 1 推进到 17
12–13 A→B,40 字节并带 FIN;B 返回回显 SEQ=25,随后 ACK=66 数据占 [25,65),FIN 占 65,确认后为 66
14–15 B→A FIN;A→B ACK B SEQ=65,A ACK=66 反向 64 字节之后的 FIN 也单独占一位

帧 7 的实际 SACK 选项末部为 05 0a 38 bf c1 e4 38 bf c1 ec:类型 5、长度 10,接着是两个四字节边界。它们减去 A 的原始起点后得到 9 和 17。这里核对的是实际 SACK 块,不能与握手中的 SACK-Permitted 混为一谈。

这组最后一个数据记录同时带 FIN,因此 FIN 的位置不是 TCP 首部 SEQ=25,而是 25+40=65。若再次无条件套用“SEQ+数据长度+1”,会漏掉适用条件:该段确实带 FIN,且接收端已经连续接收此前数据。对前面的乱序八字节段,同一个机械公式仍会给出错误累计确认。

两组应用端都读到并核对了原始 64 字节,捕获载荷按序号覆盖的字节也与之相等。40 字节记录说明不能用应用写入次数替代捕获分段数;本次没有单独测定内核合并与卸载各自的贡献。

关闭后的额外报文也必须保留

乱序组在上述 FIN 和确认之后还有两帧:帧 16 是 A→B、相对 ACK=65;帧 17 是 B→A、RST 标志为 1。原始记录没有删除这两帧。先见到 ACK=66,再见到 ACK=65,也展示了单点按到达时间排列的确认号不一定单调增加。

晚到确认抵达已关闭状态,可以与这样的 RST 轨迹相容;但本次没有同步记录内核状态迁移,不能认定唯一原因。已经核对的应用 64 字节内容与 FIN 序号事实仍然成立,而“整个连接只出现正常 FIN,没有复位”的表述则不成立。第 16 篇将把复位、半关闭和状态观察作为独立验收。

验证边界

本篇已完成固定乱序例子的手算,以及真实 Linux TCP 的 SYN、数据、FIN、累计 ACK 和 SACK 对照。两组采用独立命名空间,最终四个命名空间均删除,虚拟机停止;实验没有改宿主默认路由或访问外部服务。

捕获为原始帧十六进制,不是 pcap 文件。解码器只支持无 VLAN 的以太网、未分片 IPv4 与 TCP,检查首部和长度边界;没有实现 IPv6、分片重组、校验和验证或抓包丢弃计数。短传输的字节覆盖核对不能推出所有捕获均绝不漏包。

乱序来自人工 netem 规则;不同运行的 ISN、端口、分段与重排位置可能变化。没有测量拥塞算法、重传触发原因、长期吞吐、公平性、序号真实回绕、缓存放弃或业务持久化。手算程序中的回绕例子也不能冒充发送数 GiB 数据后的真实回绕实验。

练习

第一题:当前累计 ACK=2001,先收到 [2005,2010),随后收到 [2001,2005)。窗口足够且乱序内容保留时,两个时点的累计 ACK 与第一时点的 SACK 块分别是什么?

校验:先保持 ACK=2001,SACK 可报告 [2005,2010);填上缺口后 ACK=2010。不能因为先到的段有五字节,就在第一次把 ACK 推进为 2011。

第二题:某方向发送 SEQ=3000、不带数据的纯 ACK;随后发送从 SEQ=3000 开始的三字节数据,最后发送 FIN。FIN 应占哪个序号,连续确认到它以后 ACK 为多少?

校验:纯 ACK 不消耗序号,三字节占 [3000,3003),FIN 占 3003,确认到 FIN 后 ACK=3004。此处假定该连接的同步状态已建立,忽略回绕与无效段。

参考资料