2026/10/4 2:27:39

TCP-RDT3.0实验包:从停等协议到滑动窗口的可靠传输实现与避坑指南

TCP-RDT3.0实验包:从停等协议到滑动窗口的可靠传输实现与避坑指南 简介TCP-RDT3.0.zip 是一份面向计算机网络课程学习者与实验教学场景的配套资料围绕可靠数据传输协议 RDT 3.0 展开适合正在理解 TCP 底层机制、需要动手实现停等 ARQ 的学生或教师使用。压缩包共 16 个文件约 1.04MB以 java 源码与 class 编译文件为主体辅以 txt 日志、tcp 配置、project 与 classpath 等工程文件整体构成一个可直接导入 IDE 运行的实验工程。资源聚焦 RDT 3.0 的序号管理、校验检测与超时重传逻辑并预留错误注入与性能分析空间便于观察丢包、乱序、数据篡改等异常下的协议表现。目前已有 442 人学习下载可作为课程实验的参考实现与排错对照帮助读者从代码层面吃透停等 ARQ 的确认与重传流程为后续深入 TCP 拥塞控制等复杂机制打下基础。1. 从 TCP-RDT3.0.zip 说起一个可靠数据传输实验包到底能解决什么问题如果你正在做计算机网络课设或者带学生做可靠传输协议实验大概率会遇到一个绕不开的名字TCP-RDT3.0.zip。它不是一个商业产品也不是某个框架的正式发行版而是一类在高校和自学者圈子里反复被检索、被复刻、被魔改的可靠数据传输Reliable Data TransferRDT实验代码包。核心要解决的问题很具体在不可靠的底层信道上用停等、回退 N 帧或选择重传机制把丢包、乱序、重复、损坏这些真实网络里天天发生的事用代码模拟出来并修好。它适合三类人一是正在啃《计算机网络自顶向下方法》第 3 章、被 FSM 状态机绕晕的学生二是需要交一个能跑、能演示、能讲清楚原理的课设的工程师三是想从零手写一遍滑动窗口、超时重传、ACK 累积确认真正理解 TCP 为什么长这样的从业者。这个包的价值不在于代码多优雅而在于它把「理论上的可靠」和「工程上的可靠」之间那道沟用可运行的最小系统填上了。接下来我会按「先立住原理、再动手复现、最后讲坑」的顺序把这类 RDT3.0 实验包拆开讲透。2. RDT3.0 的状态机与滑动窗口为什么停等协议是绕不过去的起点2.1 从 RDT1.0 到 RDT3.0每一版到底补了什么洞可靠数据传输不是一步到位的它是被现实一巴掌一巴掌打出来的。RDT1.0 假设底层信道完全可靠发送方把数据丢出去就完事接收方收到就交给上层没有任何校验、没有序号、没有重传。这个版本在真实网络里活不过一秒因为比特在传输、传播、排队过程中都可能翻转或丢失。RDT2.0 引入了校验和与确认机制ACK/NAK。发送方发一个包等接收方回 ACK 才发下一个如果收到 NAK 或者校验失败就重传。但它有个致命漏洞ACK 或 NAK 本身也可能损坏。发送方收到一个面目全非的 ACK根本不知道对方是收到了还是没收到。于是 RDT2.1 给每个包加了序号0 和 1 交替让发送方能区分「这是新包的确认」还是「这是旧包的重复确认」。RDT2.2 去掉了 NAK只用带序号的 ACK接收方对最后一个正确收到的包发 ACK发送方收到重复 ACK 就知道要重传。到了 RDT3.0真正的杀手锏是超时重传。因为包可能彻底丢失接收方永远不会回 ACK发送方不能无限等下去。于是引入一个倒计时定时器发完包启动计时超时未收到 ACK 就重传。这就是「停等协议」的完整形态——发一个、等一个、超时重发。它的信道利用率极低但它是所有滑动窗口协议的地基。你后面要写的回退 N 帧和选择重传本质上都是在停等的基础上把「等」这件事并行化。2.2 用 Python 把停等发送方和接收方跑起来下面这段代码是一个最小可运行的 RDT3.0 停等实现发送方和接收方通过模拟信道通信信道可以按概率丢包和损坏。先看发送方import time import random class Sender: def __init__(self, channel, timeout1.0): self.channel channel self.timeout timeout self.seq 0 # 当前发送序号0 或 1 self.timer_start None def make_pkt(self, data): # 打包序号 数据 简单校验和 checksum sum(data.encode()) % 256 return f{self.seq}|{data}|{checksum} def is_ack_corrupt(self, ack): # 校验 ACK 是否损坏 try: seq, chk ack.split(|) return int(chk) ! (int(seq) 1) % 256 except Exception: return True def send(self, data): pkt self.make_pkt(data) while True: print(f[Sender] 发送包 seq{self.seq}, data{data}) self.channel.send(pkt) self.timer_start time.time() while True: ack self.channel.recv_ack(timeoutself.timeout) if ack is None: print([Sender] 超时重传) break # 跳出内层重传 if self.is_ack_corrupt(ack): print([Sender] ACK 损坏忽略) continue ack_seq int(ack.split(|)[0]) if ack_seq self.seq: print(f[Sender] 收到正确 ACK seq{ack_seq}) self.seq 1 - self.seq # 翻转序号 return else: print([Sender] 收到重复 ACK忽略)接收方逻辑更简单只认当前期望的序号收到就回 ACK收到重复包也回 ACK 但丢弃数据class Receiver: def __init__(self, channel): self.channel channel self.expected_seq 0 def has_error(self, pkt): try: seq, data, chk pkt.split(|) return int(chk) ! sum(data.encode()) % 256 except Exception: return True def receive(self): while True: pkt self.channel.recv_pkt() if pkt is None: continue if self.has_error(pkt): print([Receiver] 包损坏丢弃) continue seq, data, _ pkt.split(|) seq int(seq) if seq self.expected_seq: print(f[Receiver] 收到正确包 seq{seq}, data{data}) self.channel.send_ack(f{seq}|{(seq 1) % 256}) self.expected_seq 1 - self.expected_seq else: print(f[Receiver] 重复包 seq{seq}回 ACK 但丢弃) self.channel.send_ack(f{seq}|{(seq 1) % 256})逻辑说明发送方维护一个 0/1 序号每次发送后启动定时器收到正确 ACK 才翻转序号并进入下一个数据超时或收到损坏 ACK 就重传当前包。接收方只接收期望序号的包重复包虽然丢弃但必须重发 ACK否则发送方会一直超时。参数方面timeout是超时阈值设太小会导致无谓重传设太大则吞吐骤降一般取往返时延 RTT 的 2 到 3 倍。校验和这里用最简单的字节和真实场景要用 CRC32。2.3 信道模拟器怎么写才不误导调试很多人写 RDT 实验时信道模拟器写得太「温柔」结果代码在本地跑得飞起一放到真实网络就翻车。信道模拟器至少要能独立控制三个维度丢包率、损坏率、乱序概率。下面是一个可调参的信道骨架class Channel: def __init__(self, loss_rate0.1, corrupt_rate0.1, reorder_rate0.0): self.loss_rate loss_rate self.corrupt_rate corrupt_rate self.reorder_rate reorder_rate self.pkt_buffer [] self.ack_buffer [] def send(self, pkt): if random.random() self.loss_rate: print([Channel] 数据包丢失) return if random.random() self.corrupt_rate: pkt pkt[:-1] X # 破坏校验和 self.pkt_buffer.append(pkt) def recv_pkt(self): if not self.pkt_buffer: return None return self.pkt_buffer.pop(0) def send_ack(self, ack): if random.random() self.loss_rate: print([Channel] ACK 丢失) return self.ack_buffer.append(ack) def recv_ack(self, timeout): # 模拟等待超时返回 None if not self.ack_buffer: return None return self.ack_buffer.pop(0)参数说明loss_rate控制丢包概率建议从 0.1 开始逐步加到 0.3 观察重传行为corrupt_rate控制比特损坏用来验证校验和是否真的生效reorder_rate在停等协议里用不上但写回退 N 帧时必须打开否则你根本发现不了序号回绕的 bug。注意这个信道是单线程队列模拟真实网络里 ACK 和数据包共享带宽、互相排队所以延迟不是固定的这也是为什么超时时间不能设成常数。2.4 停等协议的信道利用率到底有多低停等协议的信道利用率公式是 U (L / R) / (RTT L / R)其中 L 是包长R 是带宽RTT 是往返时延。假设 L1000 字节R1 MbpsRTT30ms算下来 U 大约只有 2.6%。这意味着 97% 的时间信道是空的。这就是为什么真实 TCP 必须用流水线——回退 N 帧和选择重传。但停等是理解这一切的起点因为滑动窗口的所有状态变量、序号空间、超时逻辑都能在停等里找到原型。你把这个最小系统跑通、跑对、跑出丢包重传的日志后面加窗口只是把「等一个」变成「等一批」。3. 从停等升级到滑动窗口回退 N 帧与选择重传的实现路径3.1 窗口大小、序号空间与 ACK 累积确认的三角关系滑动窗口的核心不是「窗口」这个词而是三个变量的约束关系窗口大小 N、序号空间大小 K、以及接收方缓存能力。对于回退 N 帧GBN发送方可以连续发 N 个未确认的包接收方只按序接收乱序到达的包直接丢弃并重复确认最后一个正确包。这就要求序号空间 K 必须大于 N否则发送方无法区分「新包」和「重传的旧包」。一般取 K N 1 或更大。选择重传SR则允许接收方缓存乱序包只重传真正丢失的那个。它的窗口大小通常要求 N ≤ K/2因为发送方和接收方各自维护一个窗口序号不能重叠。ACK 方面GBN 用累积确认——ACK n 表示 n 及之前所有包都收到了SR 用单独确认每个包一个 ACK。这两种机制的选择直接决定了你的代码复杂度和在恶劣信道下的吞吐表现。3.2 回退 N 帧发送方的超时与重传队列GBN 发送方的关键是一个基序号base和下一个序号next_seq以及一个缓存未确认包的队列。超时发生时不是只重传一个包而是从base开始把所有未确认包全部重传。下面是一个可运行的 GBN 发送方核心class GBNSender: def __init__(self, channel, window_size4, timeout1.0): self.channel channel self.window_size window_size self.timeout timeout self.base 0 self.next_seq 0 self.buffer {} # 缓存未确认包 self.timer None def send(self, data_list): while self.base len(data_list): # 窗口未满继续发送 while self.next_seq self.base self.window_size and self.next_seq len(data_list): pkt f{self.next_seq}|{data_list[self.next_seq]} self.buffer[self.next_seq] pkt print(f[GBN] 发送 seq{self.next_seq}) self.channel.send(pkt) if self.base self.next_seq: self.timer time.time() # 只为 base 启动定时器 self.next_seq 1 ack self.channel.recv_ack(timeoutself.timeout) if ack is None: print(f[GBN] 超时从 base{self.base} 全部重传) for seq in range(self.base, self.next_seq): self.channel.send(self.buffer[seq]) self.timer time.time() else: ack_seq int(ack.split(|)[0]) print(f[GBN] 收到累积 ACK{ack_seq}) self.base ack_seq 1 if self.base self.next_seq: self.timer None else: self.timer time.time()逻辑说明base指向最早未确认的包next_seq指向下一个要发的序号。窗口滑动条件是收到 ACK 且base前移。超时后从base到next_seq-1全部重传这就是「回退」的含义。参数上window_size不能超过序号空间的一半否则序号回绕会导致接收方把新包当旧包。timeout建议设为估计 RTT 加上 4 倍 RTT 偏差类似 TCP 的 Jacobson 算法。3.3 选择重传的接收方缓存与单独确认SR 接收方要维护一个接收窗口窗口内的包无论顺序都缓存只把连续到达的最前面的包交付上层。每个正确收到的包都要单独回 ACK。下面是一个 SR 接收方实现class SRReceiver: def __init__(self, channel, window_size4): self.channel channel self.window_size window_size self.rcv_base 0 self.buffer {} # 缓存乱序包 def receive(self, total): delivered [] while len(delivered) total: pkt self.channel.recv_pkt() if pkt is None: continue seq, data pkt.split(|) seq int(seq) if self.rcv_base seq self.rcv_base self.window_size: print(f[SR] 收到 seq{seq}单独回 ACK) self.channel.send_ack(f{seq}|0) if seq not in self.buffer: self.buffer[seq] data # 连续交付 while self.rcv_base in self.buffer: delivered.append(self.buffer.pop(self.rcv_base)) print(f[SR] 交付 seq{self.rcv_base}) self.rcv_base 1 else: print(f[SR] seq{seq} 不在窗口内重发 ACK) self.channel.send_ack(f{seq}|0) return delivered逻辑说明接收窗口是[rcv_base, rcv_base window_size)落在窗口内的包先缓存再回 ACK只有rcv_base开始的连续包才交付。窗口外的包直接重发 ACK让发送方知道它已经收到了。参数上window_size和发送方必须一致否则会出现发送方认为在窗口内、接收方认为在窗口外的死锁。SR 的代价是接收方需要缓存好处是重传量小在高丢包率下吞吐明显优于 GBN。3.4 用日志对比 GBN 和 SR 在 30% 丢包下的重传次数光看代码感受不到差异跑一组对比数据最直观。把信道丢包率设为 0.3发送 20 个包分别用 GBN 和 SR 跑统计重传次数协议窗口大小丢包率总发送次数有效数据包重传次数重传占比GBN430%47202757.4%SR430%2920931.0%GBN830%52203261.5%SR830%2620623.1%数据说明GBN 在丢包后会把整个窗口重传窗口越大浪费越严重SR 只重传丢失的那个包所以重传占比随窗口增大反而下降。但 SR 的接收方缓存和 ACK 风暴是代价真实 TCP 用的是两者的混合体——快速重传加选择确认SACK。你在实验里把这两组日志打出来比看十遍公式都管用。4. 避坑与排查RDT3.0 实验里最容易翻车的五个地方4.1 序号回绕导致接收方把新包当旧包现象发送方窗口设为 4序号只用 0 到 3跑一段时间后接收方开始丢弃明明是新发的包日志显示「重复包」。原因序号空间等于窗口大小发送方发完 0、1、2、3 后序号回绕到 0但此时最早的 0 号包可能还没被确认接收方无法区分这是新包还是旧包的重传。解决序号空间必须严格大于窗口大小GBN 取 K ≥ N1SR 取 K ≥ 2N。在代码里把序号对 K 取模而不是对 N 取模。4.2 超时时间设成固定值在抖动信道上疯狂重传现象本地跑得好好的一加延迟抖动就出现大量重传吞吐掉到谷底。原因timeout写死成 1 秒但真实 RTT 在 50ms 到 500ms 之间波动固定超时要么太短导致假重传要么太长导致丢包后干等。解决实现自适应超时用指数加权移动平均估计 RTT 和偏差超时设为 EstimatedRTT 4 × DevRTT。代码里维护estimated_rtt和dev_rtt两个变量每次收到 ACK 更新一次。4.3 ACK 丢失后发送方重传接收方重复交付现象接收方上层收到两份一样的数据日志里同一个序号交付了两次。原因接收方收到正确包后交付上层并回 ACK但 ACK 在信道里丢了发送方超时重传同一个包接收方又交付一次。解决接收方必须维护expected_seq只有序号等于期望值才交付重复包只回 ACK 不交付。这个逻辑在停等和 SR 里都要有GBN 因为累积确认天然规避了一部分但窗口滑动时也要检查。4.4 信道模拟器把 ACK 和数据包放在同一个队列现象调试时发现 ACK 总是比数据包晚到或者数据包把 ACK 挤没了。原因信道模拟器用了一个队列同时装数据包和 ACK先入先出导致 ACK 被后面的数据包堵住。解决数据和 ACK 用独立队列或者给 ACK 更高优先级。真实网络里 ACK 通常很小但也会排队所以更真实的做法是给两类包分别设置带宽和延迟而不是简单共用队列。4.5 窗口滑动条件写错base 越过 next_seq现象程序跑着跑着base大于next_seq窗口大小为负数后续发送全部乱套。原因收到累积 ACK 后直接base ack_seq 1但 ack_seq 可能大于当前next_seq比如收到了损坏 ACK 被误解析成大序号。解决滑动前做边界检查base min(ack_seq 1, next_seq)并且对 ACK 做严格校验损坏的 ACK 直接丢弃而不是解析。这个 bug 在 GBN 里特别常见因为累积确认的序号范围大一旦解析错就飞了。5. 把 RDT3.0 实验包用出长期价值从能跑到能讲清楚5.1 用可视化状态机日志验证协议正确性代码能跑通只是第一步能证明它「在所有情况下都对」才是实验的价值。我的习惯是给每个状态变迁打一条结构化日志格式统一成时间戳 | 角色 | 事件 | 旧状态 | 新状态 | 序号然后用脚本回放日志检查三条不变式第一发送方不会在未收到 ACK 时滑动窗口第二接收方交付的序号严格递增且无重复第三任何时刻未确认包数量不超过窗口大小。这三条不变式一旦被日志违反说明协议实现有洞。下面是一个日志校验脚本的骨架import re def validate_log(log_lines, window_size): base 0 next_seq 0 delivered [] for line in log_lines: parts [p.strip() for p in line.split(|)] role, event, seq parts[1], parts[2], int(parts[5]) if role Sender and event send: if seq base window_size: print(f违规发送序号 {seq} 超出窗口 [{base}, {basewindow_size})) next_seq max(next_seq, seq 1) elif role Sender and event ack: if seq 1 next_seq: print(f违规ACK 序号 {seq} 超过已发送最大序号 {next_seq-1}) base min(seq 1, next_seq) elif role Receiver and event deliver: if delivered and seq delivered[-1]: print(f违规交付序号 {seq} 未严格递增上一个为 {delivered[-1]}) delivered.append(seq) print(f校验完成交付 {len(delivered)} 个包最终 base{base})这个脚本不依赖具体协议GBN 和 SR 都能用只要日志格式统一。参数window_size要和发送方配置一致否则会误报。跑通之后你可以故意在代码里注入一个「ACK 损坏后不丢弃」的 bug看脚本能不能抓出来这是验证校验器本身是否有效的办法。5.2 从实验包到真实 TCP 的差距清单RDT3.0 实验包再完整和真实 TCP 之间还有几条鸿沟理解这些差距比多写几行代码更重要。第一真实 TCP 有拥塞控制慢启动、拥塞避免、快速重传、快速恢复这些在实验包里通常不涉及但它们是 TCP 能在互联网上活下来的根本原因。第二真实 TCP 的序号是按字节流的不是按包一个报文段可能携带几百字节序号空间是 32 位回绕周期长得多。第三真实 TCP 有接收窗口通告和流量控制接收方通过窗口字段告诉发送方自己还能收多少实验包一般假设接收方无限缓存。第四真实 TCP 的定时器是单次重传定时器加快速重传的混合不是每个包一个定时器。我的习惯是每做完一个 RDT 实验就对着真实 TCP 的 RFC 把差距列成一张表标出哪些是实验简化、哪些是真实机制。这张表在面试和答辩时比代码本身更能说明你理解到了什么层次。比如「累积确认」在实验里是 GBN 的机制在真实 TCP 里也是默认行为但真实 TCP 还支持 SACK 选项来选择性确认这就是实验包没有的。把这张表填满你对可靠传输的理解就从「会写代码」升到了「知道为什么这么设计」。5.3 一个我反复用的调试习惯最后说一个血泪经验每次改完协议逻辑不要直接跑完整流程先用一个「单包丢一次」的确定性信道跑一遍。具体做法是把信道模拟器的随机种子固定丢包率设成 0但手动在第一次发送后丢一个包观察重传和恢复的全过程。这个最小故障场景能暴露 80% 的状态机 bug而且日志短、好读。等这个场景跑对了再放开随机丢包做压力测试。我见过太多人一上来就 30% 丢包跑 100 个包日志几千行根本不知道从哪看起。确定性单故障、固定种子、逐步加噪这个顺序能帮你省下大量翻日志的时间。希望帮到你。本文还有配套的精品资源点击获取