简介「燕大计算机网络三级项目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:2048rtt:1.5/0.8是平滑 RTT 和平均偏差rto:204是当前超时时间毫秒notsent表示还在写队列里没被切出去的字节。这些值编不出来只能从真实运行的内核里读。3.2 RTO 的估算为什么超时时间不能写成固定值重传超时RTO如果设成 200ms跨洲链路 RTT 就 300ms会疯狂伪重传把带宽全浪费在重发的包上如果设成 3 秒真丢包时恢复又慢得离谱。所以 RTO 必须动态估计方法是采样 RTT 之后做平滑和波动幅度统计。RFC 6298 给出的算法是维护 SRTT平滑 RTT和 RTTVARRTT 偏差每收到一个新样本就更新RTO SRTT 4 × RTTVAR并设下限 200msLinux 里是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 个重复 ACKdupthresh 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 个 MSSRFC 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 是 1500MSS 大约 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 注入一次丢包看恢复行为。这三步花不了十分钟但能挡掉绝大多数「在我机器上是好的」的事故。希望帮到你。本文还有配套的精品资源点击获取