ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

TCP字节流传输全解:首部、重传、窗口与抓包排查

TCP字节流传输全解:首部、重传、窗口与抓包排查

简介:「燕大计算机网络三级项目TCP传输数据包」面向燕山大学计算机网络课程学习者及需要巩固TCP协议与网络编程的读者,围绕TCP在数据通信中的可靠传输展开,可用于课程实践、实验复现与socket编程练习。压缩包共60个文件、约27.88MB,包含cpp与h源码、sln与vcxproj工程文件、exe可执行程序,以及pdb、obj、ipch等编译中间产物,客户端与服务器工程结构较完整,便于直接运行和对照调试。已有1355人学习下载。内容覆盖三次握手建连、数据分段与重组、滑动窗口流量控制、拥塞控制、ACK确认与重传、四次挥手释放等关键环节,并涉及慢启动、快速重传、快速恢复等算法实现思路。读者可借助现成工程观察TCP通信流程、模拟不同网络环境测试性能与适应性,并参考异常处理与排错过程,加深对协议细节和网络编程方法的理解。

1. TCP 的「数据包」是个误会:字节流才是它真正传输的东西

做计算机网络三级项目时,最常见的起手式是写一个程序,把文件切成若干个数据包发出去,对端再按编号拼回来。可只要真抓一次包就会发现:TCP 报文段里根本没有「这是第几个包」这种字段,序号记的是字节偏移。这个反直觉的细节,是理解 TCP 全部机制——重传、滑动窗口、拥塞控制——的起点,也是项目答辩里最容易被追问的地方。很多人把 TCP 当成「带重传的 UDP」来理解,实现能跑通,可一旦遇到粘包、半包、窗口卡死,就完全找不到方向。这一篇想把 TCP 传输数据包这件事从首部字段一路拆到内核队列,讲清楚它为什么这么设计、又是怎么落地的。

2. 报文段首部:20 个字节里每个字段在防哪种故障

TCP 首部的固定部分是 20 字节,加上选项最多 60 字节。这 20 个字节不是随便排的,几乎每个字段都对应一种曾经真实发生过的故障。理解顺序建议是:先看序号和确认号(解决乱序与丢失),再看标志位和窗口(解决连接生命周期与流控),最后看校验和(解决错误投递)。

2.1 序号按字节编号:为什么不是按包编号

如果 TCP 按包编号,发送方切成 3 个包,接收方就必须按这 3 个包的边界重组,中间任何一个包被网络拆成两半,整个编号体系就崩了。按字节编号绕开了这个问题:每一段携带的是「从第 N 个字节开始的 M 个字节」,接收方只要知道起始偏移和长度就能放回正确位置,段与段之间的边界可以任意变化。MSS(最大报文段长度)是双方在 SYN 里协商出来的,路由器中途改变路径 MTU 时,边界还会再变一次。

代价是序号空间只有 32 位,也就是 4GB。千兆链路上跑满,4GB 大约 30 多秒就绕一圈回到原点。如果链路上有一个包延迟超过这个时间才到达,接收方就会把它当成新数据。常见的实现思路是用时间戳选项(RFC 7323)做 PAWS 检测,接收方记录每个连接最近一次收到的时间戳,比它旧的一律丢弃,这样序号回绕就不再是问题。

import struct def parse_tcp_header(seg: bytes) -> dict: # 前 20 字节为固定首部,"!" 表示网络字节序(大端) sport, dport, seq, ack, off_flags, win, csum, urg = \ struct.unpack("!HHIIHHHH", seg[:20]) # 高 4 位是数据偏移,单位是 4 字节;低 8 位是 8 个标志位 data_offset = (off_flags >> 12) & 0x0F flags = off_flags & 0x00FF return { "sport": sport, "dport": dport, "seq": seq, "ack": ack, "header_len": data_offset * 4, # 20~60 "flags": { "FIN": bool(flags & 0x01), "SYN": bool(flags & 0x02), "RST": bool(flags & 0x04), "PSH": bool(flags & 0x08), "ACK": bool(flags & 0x10), "URG": bool(flags & 0x20), }, "win": win, "csum": csum, "urg": urg, "payload": seg[data_offset * 4:], }

这段解析的关键在off_flags这一个 16 位字段:高 4 位被复用成首部长度,低 8 位承载标志,中间 4 位保留。所以判断首部长度的移位是 12 而不是 8,写错一位就会把整个 payload 切歪,这就是典型的「代码能跑但数据是错的」。payload的切分必须用data_offset * 4,不能写死 20——带时间戳和 SACK 选项的段首部通常就是 32 字节。

2.2 六个标志位与窗口字段:16 位窗口为什么不够用

SYN 用来交换初始序号(ISN),并且它占一个序号位置。这就是为什么握手时服务端回的是ack = 客户端ISN + 1,而不是原样回ISN。FIN 同理,也占一个序号,所以四次挥手时 ACK 号要加一。这两个「占位」规则如果没搞清,抓包时看到的序号跳跃就全是谜。

RST 是紧急刹车,几乎所有的「连接被重置」最终都能追到三种原因:端口没监听、有未读数据就 close、以及中间设备认为连接失效。PSH 在现代协议栈里基本是历史遗留,内核并不会因为收到 PSH 就立刻把缓冲区数据交给应用,它只影响发送端的推送时机,理解成「建议尽快上交」就够了。URG 更加边缘,绝大多数实现直接忽略。

窗口字段只有 16 位,最大 65535 字节。在千兆以上的链路里,这个窗口根本填不满带宽时延积——带宽 1Gbps、RTT 50ms,管道里能装 6MB 数据,65535 字节的窗口只能跑到 10Mbps 左右。窗口缩放选项用一个 0~14 的移位因子把窗口放大到最多 2^30,这是 TCP 能撑住现代带宽的前提。抓包时如果只盯着win字段的原始值算吞吐,而不看 SYN 里协商的 shift 因子,算出来的数会差几个数量级。

2.3 校验和与伪首部:为什么要拉上 IP 层的信息

TCP 的校验和覆盖三部分:伪首部、TCP 首部、TCP 数据。伪首部是虚拟构造出来的 12 个字节,内容是源 IP、目的 IP、一个保留字节、协议号 6、TCP 长度。它不会真的出现在报文中,只在计算时参与。

为什么要多此一举?因为 TCP 只对自己这一层负责,如果 IP 层把包投递到了错误的目的地(路由错误、NAT 表项串了),TCP 首部和数据本身完全正确,校验和照样能通过,接收方会把别人的数据当自己的收下。把 IP 地址纳入校验范围之后,一旦投错,校验和必然对不上。代价是每一层都要重算——NAT 设备改源地址时必须同步重算 TCP 校验和,这也是很多老旧设备在 NAT 场景下丢包率飙升的原因。

校验算法是 16 位反码求和再取反。硬件网卡普遍支持校验和卸载,所以在本机抓包时经常看到checksum incorrect,这是正常的:报文还没出网卡,硬件还没来得及填。抓包位置选在网卡出口而不是协议栈内部,这个坑后面还会再提一次。

3. 可靠传输的账本:ACK、重传队列和 RTO 是怎么对上的

TCP 的可靠性不靠「每个包都等确认」这种笨办法,它维护的是一本账:发出去的字节、被确认的字节、还在路上的字节,三者之间的差额决定了下一步能发多少。落实这本账需要发送缓冲区、重传队列和至少四个定时器,缺一个就会出现内存泄漏或者永久卡死。

3.1 发送缓冲与重传队列:数据什么时候才能从内存里删掉

调用一次send(),数据只是从用户空间拷贝到了内核的写队列(sk_write_queue),函数返回的字节数表示「接纳了多少」,不表示「发出去了多少」,更不表示「对端收到了多少」。这一点如果理解错,很容易写出「发完就 free 缓冲区」导致数据错乱的代码。

数据在内核里的生命周期大致是这样:进入写队列 → 被切分成不超过 MSS 的报文段 → 发出并挂到重传队列 → 收到覆盖该段首字节的累计 ACK → 从重传队列和后写队列中释放。注意「累计 ACK」这四个字:收到ack = 10001只说明前 10000 字节都到了,至于第 5000 字节是被哪个段确认的并不重要。所以释放逻辑必须按序号区间删除,而不是按段号匹配,用链表按序扫描是最朴素的做法。

重传队列里挂着的东西不能立刻删,因为一旦超时还得重发。Linux 上可以用ss -ti看到这个账本的实时快照:una是最早未确认字节,nxt是下一个要发的位置,snd_wnd是发送窗口,cwnd是拥塞窗口,retrans是重传计数。写项目报告时把这几个字段和抓包序号对齐,比空谈「实现了可靠传输」有说服力得多。

# 只看某条连接的内部账本,-i 展示 TCP 内部信息 ss -tin dst 10.0.0.2 # 输出示例片段: # cubic wscale:7,7 rto:204 rtt:1.5/0.8 ato:40 cwnd:10 ssthresh:7 # send 14600bps rcv_space:14600 notsent:2048

rtt:1.5/0.8是平滑 RTT 和平均偏差,rto:204是当前超时时间(毫秒),notsent表示还在写队列里没被切出去的字节。这些值编不出来,只能从真实运行的内核里读。

3.2 RTO 的估算:为什么超时时间不能写成固定值

重传超时(RTO)如果设成 200ms,跨洲链路 RTT 就 300ms,会疯狂伪重传,把带宽全浪费在重发的包上;如果设成 3 秒,真丢包时恢复又慢得离谱。所以 RTO 必须动态估计,方法是采样 RTT 之后做平滑和波动幅度统计。

RFC 6298 给出的算法是:维护 SRTT(平滑 RTT)和 RTTVAR(RTT 偏差),每收到一个新样本就更新,RTO = SRTT + 4 × RTTVAR,并设下限 200ms(Linux 里是TCP_RTO_MIN),初始值 1 秒。4 倍偏差这个系数不是拍脑袋——它大致对应正态分布的 4 个标准差,保证不会因为 RTT 抖动就误判丢包。

ALPHA, BETA = 1 / 8, 1 / 4 class RtoEstimator: def __init__(self): self.srtt = None self.rttvar = None self.rto = 1.0 # 初始 RTO 1 秒 def sample(self, rtt: float, retransmitted: bool): # Karn 算法:重传过的段无法确定 ACK 对应哪次发送, # 该样本必须丢弃,否则 RTO 会被严重低估 if retransmitted: return if self.srtt is None: self.srtt, self.rttvar = rtt, rtt / 2 else: self.rttvar = (1 - BETA) * self.rttvar + BETA * abs(self.srtt - rtt) self.srtt = (1 - ALPHA) * self.srtt + ALPHA * rtt self.rto = self.srtt + max(0.2, 4 * self.rttvar)

Karn 算法那三行是这段代码的灵魂。重传之后收到的 ACK,无法判断它确认的是第一次发送还是重传的那次,采样就会把 RTT 算错。忽略这个细节,RTO 会越算越小,最后陷入「超时→重传→采样错→RTO 更小→更多伪重传」的正反馈。

3.3 三个重复 ACK 的判据:快重传凭什么省掉一个 RTO

超时重传的代价是必须等满一个 RTO,期间链路空转。快重传的思路是:如果接收方连续收到序号不连续的段,它每收到一个就重复发一次当前期望的 ACK。发送方收到 3 个重复 ACK(dupthresh = 3)就判定该段丢失,立刻重发,不用等定时器。

为什么是 3 而不是 1?因为网络本身会乱序,轻度乱序会产生 1 到 2 个重复 ACK,设成 1 会导致大量伪重传。3 这个值是在乱序和恢复速度之间取的折中,Linux 里可通过tcp_reordering调整,但默认不建议动。

更进一步的优化是 SACK(选择性确认)。累计 ACK 只能告诉发送方「前面都到了」,中间的洞在哪并不知道,一次丢多个段时发送方只能一个一个试。SACK 选项用若干个块描述「我收到了哪些不连续的区间」,发送方可以精确地只补洞。再配合 DSACK(重复 SACK),接收方可以告诉发送方「你刚重发的这个我早就收到了」,用来识别伪重传并修正拥塞窗口。这些选项都在 SYN 阶段协商,不支持就用不了,因此抓包看到 SACK 块的前提是握手包里带了SACK permitted。

4. 两个窗口的博弈:rwnd 与 cwnd 谁说了算

TCP 每一时刻能发多少数据,由两个完全独立的窗口取最小值决定:接收方通告的 rwnd 反映「对方还能吃多少」,发送方维护的 cwnd 反映「网络还能塞多少」。前者是流控,防止把接收端缓冲区撑爆;后者是拥塞控制,防止把中间链路压垮。两者混为一谈,是很多性能问题的根源。

4.1 接收窗口与零窗口探测:窗口更新丢了怎么办

rwnd 的计算很简单:接收缓冲区的空闲空间。应用层读得慢,缓冲区被填满,rwnd 就降到 0,发送方必须停下来。问题在于「窗口更新」这条通知本身也是一个 TCP 段,它也可能丢。如果更新通知丢了,发送方会永远认为窗口是 0,双方就这么僵住——这是早期 TCP 的经典死锁。

解决办法是坚持定时器(persist timer):窗口为 0 后,发送方启动一个定时器,超时后发一个 1 字节的探测包,接收方回应当前窗口值。这个定时器采用指数退避,间隔从几十毫秒逐步拉长到几秒,避免在长时间空闲连接上耗资源。抓包里看到源端口发来 1 字节 payload、序列号还停在原地不前进的段,基本就是窗口探测。

另一个容易忽略的点是窗口收缩。接收方在通告窗口后,应用层又往缓冲区里塞了数据,缓冲区变小,第二次通告的窗口可能比第一次小。TCP 标准不建议收缩窗口,但现实里确实会发生,发送方必须处理「已经按老窗口发出去、新窗口却变小」的矛盾,否则就会越界。现代内核通过延迟窗口更新和设置收缩阈值来规避,应用层的血泪经验是:不要一边收数据一边往同一个 socket 上写大块数据,读写缓冲区打架时 rwnd 会剧烈抖动。

4.2 拥塞窗口 cwnd:从慢启动到快恢复的状态机

cwnd 不是协商出来的,是发送方自己猜的。猜的规则分三个阶段:

慢启动:初始 cwnd 是 10 个 MSS(RFC 6928,早期是 1 个),每收到一个 ACK 就加一个 MSS,效果是每个 RTT 翻一倍,指数增长。之所以叫「慢」,是相对更早期的「一上来就发一整个窗口」而言,实际增长相当激进。

拥塞避免:cwnd 涨到 ssthresh 之后改线性增长,每个 RTT 只加一个 MSS。这个拐点就是「网络大概能承受多少」的估计值。

拥塞发生后的处理:超时重传时,ssthresh 降到当前 cwnd 的一半,cwnd 直接回到 1 个 MSS,重新慢启动——这一下非常狠,是「乘法减小」。快重传触发时则走快恢复:ssthresh 和 cwnd 都降到一半,然后直接从这个值开始拥塞避免,不回到 1。

这套 AIMD(加性增、乘性减)的核心思想是:多个连接共享链路时,谁都能慢慢加,但一旦拥塞就集体砍半,收敛到公平点。它的问题在长肥管道上很明显——RTT 200ms、带宽 10Gbps 的链路,从丢包恢复到跑满要几分钟。所以现代内核默认用 CUBIC,把增长曲线改成三次函数,RTT 短的时候增长慢,接近饱和时增长快,兼顾公平和吞吐。再新的还有 BBR,完全基于带宽和 RTT 建模,不把丢包当拥塞信号,在有一定随机丢包的链路上优势明显。

4.3 实际发送量 = min(rwnd, cwnd):内核怎么算这个数

每一次决定「现在能发多少」,内核算的是:

可发送字节 = min(cwnd, rwnd) - (nxt_seq - una_seq)

其中nxt_seq - una_seq是已在途未确认的字节数。这个公式解释了几个常见现象:rwnd很大但cwnd只有 10 个 MSS 时,一次只能发 14KB 左右,跟带宽没关系;cwnd很大但对方应用不读,rwnd归零,一样发不出去。

这里还有两个著名的相互作用。一是 Nagle 算法:它要求「上一个发送的小段还没被确认时,不能发新的小段」,目的是合并小包减少首部开销。二是延迟 ACK:接收方为了搭便车,通常等 40ms 或者收到两个段才回确认。两个机制同时开着,就会产生经典的 40ms 延迟——发送方等 ACK 才发下一块,接收方等第二块才回 ACK,双方互等。低延迟交互场景下必须设TCP_NODELAY关掉 Nagle,这不是玄学,是两套「省流量」策略叠加出来的副作用。

import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 关闭 Nagle s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 256 * 1024) # 接收缓冲 s.connect(("10.0.0.2", 9000))

TCP_NODELAY关掉的是发送端合并小包的等待;SO_RCVBUF设置的是接收缓冲区大小,它直接影响通告出去的 rwnd 上限。注意在内核里手动设置SO_RCVBUF会关掉接收缓冲自动调优,除非确实知道自己在做什么,否则让内核自己调通常更好。

5. 抓包排查:五个让我当场翻车的误判

抓包工具给出的结论看起来很权威,但每一个标记都是根据有限信息猜出来的。下面五条都是我在项目联调里真实踩过的坑,写出来是想让你少走一遍。

5.1 看到「TCP Retransmission」就断定链路丢包

现象:Wireshark 里满屏红色重传,抓包文件一交,结论写成「网络质量差」。

原因:重传只说明「发送方认为这个段没被确认」,至于原因是丢包、是 ACK 丢、还是原包只是延迟到达(伪重传),抓包工具分不出来。尤其是 ACK 在反方向丢掉的场景,接收方其实早就收到了数据,只是确认没回来。

解决:把两端抓包按时间对齐,如果接收端能看到原始段,说明是 ACK 丢了或者伪重传,跟链路质量无关;再看有没有 DSACK 或时间戳能佐证。判定丢包率要用「发送端发出但接收端从未收到」的比例,而不是重传次数。

5.2 ACK 号长时间不动,就以为连接卡死

现象:抓包里 ACK 一直停在某个值,双方都没再发数据,看起来像死连接。

原因:很可能是接收窗口归零。发送方在等窗口更新,接收方在等应用读数据,双方都没错,只是应用层处理太慢。这种情况下 TCP 连接是活的,坚持定时器还在跑。

解决:看同一个段的win字段,如果是 0,再去接收端机器上用ss -tmn看Recv-Q是否顶满。真实原因往往在应用层——读循环里做了耗时操作,或者读缓冲区开得太小。

5.3 本机回环测试全绿,换两台机器就崩

现象:127.0.0.1上跑一切正常,改成局域网 IP,大文件传输就出现数据错位或者截断。

原因:回环接口的 MTU 通常是 65536,几乎不会触发分段,而真实以太网 MTU 是 1500,MSS 大约 1460。回环上永远只有一个「包」,跨机时变成几十个段,应用层如果默认「一次 recv 就等于一条完整消息」,立刻暴露粘包和半包问题。

解决:应用层必须自己做消息边界,长度前缀或者固定分隔符,不能依赖一次recv的返回。测试时至少用两台机器,或者把回环 MTU 调小来复现。

5.4 把 send 的返回字节数当成「对端已收到」

现象:日志里显示send返回 65536,程序据此推进读文件偏移,结果对端收到的数据比预期少。

原因:send返回的是拷贝进内核发送缓冲区的字节数,可能全部、可能一部分(阻塞模式下也可能部分写入)。数据还在本机内存里排队,跟对端收没收到没有任何关系。

解决:用循环写直到全部写完,并且把释放缓冲区的时机绑到业务层确认(应用层 ACK),而不是绑到send返回。协议设计上如果需要强确认,就得自己做应用层确认,TCP 的 ACK 只在传输层可见。

5.5 关闭连接时出现 RST,误以为是代码写错了

现象:四次挥手只看到两次 FIN 加一个 RST,连接被「粗暴」断开,日志里报Connection reset by peer。

原因:主动关闭方调用close()时,如果接收缓冲区里还有没被应用读走的数据,内核会直接发 RST 而不是正常挥手。这是 TCP 的设计——有未读数据说明对端还有信息没被处理,正常关闭会让这些数据悄悄丢失,不如直接报错。

解决:关闭前先shutdown(SHUT_WR)只关写方向,等对方也关,再close();或者应用层约定好「收完所有数据再关」。用SO_LINGER强行发 RST 是另一种选择,但它会丢弃未发数据,只适合明确知道后果的场景。

6. 把复杂性推到端主机:TCP 这个取舍还成立吗

TCP 有一句被引用了四十年的话:端到端原则。可靠性、排序、流控这些能力,全部放在两端的主机上,中间的路由器只管转发,不保存任何连接状态。这个取舍带来的好处是网络核心极其简单、可以无限扩展、任何一个节点坏掉都不影响其他连接。代价是端主机必须自己扛下全部复杂度——重传队列、四个定时器、拥塞状态机,一个都不能少。

这套设计在今天开始出现裂缝。最明显的是队头阻塞:TCP 保证字节流严格有序,一个段丢了,后面所有已到达的段都必须压在接收缓冲区里等,哪怕应用层早就能处理它们。HTTP/2 的多路复用把这个问题放大了——所有流挤在一条 TCP 连接上,丢一个包,全部流一起等。QUIC 的应对方式是把连接状态和可靠性搬到用户态,用 UDP 承载,每条流独立重传,同时把握手和加密合并成一次往返。本质上它没有推翻端到端原则,只是把「端」从内核挪到了应用库,绕开了内核协议栈的升级周期。

但要说 TCP 过时,也不成立。它最大的优势恰恰是那个「笨」——状态全在两端,中间设备不需要理解连接,这让它在 NAT、防火墙、负载均衡器构成的现实网络里几乎无往不利。QUIC 现在最大的阻力就来自这里:UDP 在很多企业网络里被限速甚至阻断,连接迁移和中间设备识别都是麻烦事。

想验证自己对这套机制的理解,最好的方法不是读更多资料,而是主动制造故障。用流量控制工具构造丢包、延迟、乱序,再抓包看内核的反应,比任何图示都直观:

# 在出口网卡上注入 5% 丢包和 100ms 延迟,观察重传与 cwnd 变化 sudo tc qdisc add dev eth0 root netem delay 100ms loss 5% # 验证完后记得清理,否则会一直生效 sudo tc qdisc del dev eth0 root

跑起来之后,一边用ss -ti盯cwnd、ssthresh、retrans的变化,一边抓包核对序号和 SACK 块,能亲眼看到「丢一个段→三个重复 ACK→快重传→cwnd 砍半→线性恢复」这条完整链路。这套项目材料里把首部解析、账本字段对照和故障注入脚本串成了一条线,想自己复现一遍的可以直接取来跑。

从那以后我每次写完一段网络代码,都会强制先做三件事:两端同时抓包、把ss -ti的输出贴进日志、用 netem 注入一次丢包看恢复行为。这三步花不了十分钟,但能挡掉绝大多数「在我机器上是好的」的事故。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表