简介:这是一份基于Python实现的可靠数据传输协议课程设计资源包,以UDP为底层通道,完整演示了停等协议、GBN滑动窗口协议及SR协议的设计与实现过程。面向计算机网络课程学生、需要完成类似实验或对传输层协议感兴趣的学习者,可借此掌握可靠传输原理、分组丢失处理、滑动窗口机制以及C/S文件传输应用的构建方法。包内共14个文件,核心为8个Python源码文件,涵盖服务器、客户端、配置及工具模块,另有txt测试数据、docx实验报告、markdown说明和license许可文件,压缩包仅723KB,便于下载和快速部署。资源围绕实验场景展开,包含模拟丢包验证协议有效性、将停等协议扩展为双向传输、基于停等协议实现文件传输、将GBN改进为SR等经典环节,配套文档可帮助梳理实现思路与排错方法。目前已有596人学习使用,适合作为课程设计参考和协议学习入门素材,尤其适合需要提交完整实验报告的学生。1. 基于 Python 的 UDP 可靠传输实现:先跑通停等,再谈 GBN如果你正被基于 Python 实现可靠数据传输协议的课程设计卡住,这份以 UDP 为底座的协议实现包值得花一个下午拆开跑通。它没有直接扔给你一份读不懂的 GBN 源码,而是把“UDP 本身不可靠”这件事摆上台面,从最简单的停等协议开始,逐步升级到滑动窗口、双向传输和文件传输。打开资源时最直接的感受是代码分工清楚:server.py 管发送,client.py 管接收,config.py 预留丢包率和超时参数,非常适合边改边验证。无论你在做停等、GBN 还是 SR,这套代码都能当作可运行的地基,尤其适合网络课设、面试梳理和想彻底搞懂可靠传输细节的开发者。2. 停等协议落地:校验、超时重传与丢包模拟的代码骨架2.1 停等协议背后的状态机时序停等协议是 ARQ 家族里最容易建模的模型:发送方每发一个分组就停下来,等待接收方回 ACK;收到 ACK 才发下一个分组,超时没收到就重传当前分组。整个发送过程只有两个状态,要么处于 ready 状态可以发新数据,要么处于 waiting-for-ACK 状态,所有数据都卡在当前序号上。接收方更简单,能正确收到期望序号的分组就回 ACK,否则直接丢弃,什么都不回。停等时序里最容易被忽略的是“重复旧分组”的场景。假设接收方刚处理完分组 0 并回 ACK,但 ACK 在链路上丢失,发送方超时重传分组 0。此时接收方期望的序号是 1,收到旧分组 0 时必须再次回 ACK 0,否则发送方会一直等待一个永远不会到来的 ACK 1。这就是为什么停等协议里的序号不是可有可无的装饰:它用来区分新分组和重传分组,避免接收方把重传数据当成新数据重复处理。课程设计选 UDP 而不是 TCP,原因恰恰是 UDP 不保证不丢、不重、不乱。你需要在应用层自己实现校验和、序号判定、超时重传和确认机制。如果直接用 TCP,这些逻辑都被内核接管了,反而看不到可靠传输到底是怎么拼起来的。基于 UDP 做一份自己的可靠传输,才能把协议的每个环节暴露在日志和断点下。对回环测试来说,UDP 丢包率极低,所以实验要求模拟丢包,这一点我会在 2.3 节单独展开。停等还有一个明显的工程上限:如果链路时延高,发送方绝大部分时间都在等 ACK,带宽利用率约等于 1 / (1 + 2 * RTT / 发送时间)。在本地环回测试也许看不出差距,但把模拟 RTT 加到 50ms 到 100ms 后,吞吐会跌到几个 KB/s。这个短板正好是下一章引入 GBN 的理由,但先把停等代码写对,GBN 的坑你才能看得明白。2.2 基于 UDP 的停等代码骨架:包结构、发送循环与接收判定资源里的 src 目录是协议主体,我们先给出一个可以直接替换进 server.py 和 client.py 的最小包结构。我用 4 字节序号加 8 字节校验码作为协议头,payload 放在后面,当基础骨架使用:import hashlib import socket import struct SEQ_SIZE = 4 # 序号字段占 4 字节,使用无符号整型 CHECKSUM_SIZE = 8 # 校验码取 MD5 前 8 字节 PKT_HEADER_LEN = SEQ_SIZE + CHECKSUM_SIZE def make_packet(seq: int, payload: bytes) -> bytes: # 用 MD5 前 8 字节做应用层校验,便于演示,实际工程可以换 CRC32 checksum = hashlib.md5(payload).hexdigest()[:8].encode() return struct.pack("!I", seq) + checksum + payloadstruct.pack("!I", seq)把序号转成网络字节序的无符号整型,保证收发包解释一致。校验码放在序号后面,接收端解析时按照固定步长切分,避免出现字节偏移错位。MD5 前 8 字节在演示场景里已经能拦截绝大部分数据篡改问题,虽然不如 CRC 高效,但教学资源里更容易看出校验逻辑。发送端采用循环发送、超时重传的写法:def stop_wait_send(sock: socket.socket, addr, seq: int, payload: bytes, timeout: float = 0.5) -> None: pkt = make_packet(seq, payload) while True: sock.sendto(pkt, addr) sock.settimeout(timeout) try: ack_bytes, _ = sock.recvfrom(SEQ_SIZE) ack_seq = struct.unpack("!I", ack_bytes)[0] if ack_seq == seq: print(f"[T] seq={seq} acknowledged") return except socket.timeout: print(f"[T] seq={seq} timeout, retransmit")每次sendto之后设置settimeout(timeout),阻塞在recvfrom上等待 ACK。这里有一个关键点:recvfrom(SEQ_SIZE)只读 4 字节,因为在停等模型里 ACK 包不需要带校验和,只需要序号告诉发送方“我确认的是哪一包”。如果收到的是上一轮迟到的 ACK,序号对不上就继续循环等待,不能让旧 ACK 推动状态机。接收端更需要小心,因为 UDP 包可能损坏,也可能重复到达:import random def stop_wait_recv(sock: socket.socket, expected_seq: int, loss_rate: float = 0.0, timeout: float = 3.0): while True: data, addr = sock.recvfrom(65535) if len(data) < PKT_HEADER_LEN: continue seq = struct.unpack("!I", data[:SEQ_SIZE])[0] checksum = data[SEQ_SIZE:PKT_HEADER_LEN].decode() payload = data[PKT_HEADER_LEN:] md5 = hashlib.md5(payload).hexdigest()[:8] if seq != expected_seq or md5 != checksum: print(f"[R] drop packet seq={seq} expected={expected_seq}") continue if random.random() < loss_rate: print(f"[R] simulated loss seq={seq}") continue sock.sendto(struct.pack("!I", seq), addr) return payload, addr接收端同时判断序号和校验码,两者有一个不满足就丢弃。注意丢包模拟我放在序号和校验都通过之后,这模拟的是“数据包在链路上消失”而不是“包损坏”,语义更干净。返回时同时给出 payload 和对端地址,便于上层拿到数据并知道该回 ACK 给谁。如果recvfrom设置了 timeout 也要处理超时,但在接收端被阻塞的情况下,一般使用更长的等待时间,或者干脆先不设 timeout,等发送方主动超时重传。2.3 模拟丢包:把丢包放在接收端,才能触发超时重传模拟丢包不是简单地在发送端跳过某个包。如果发送端用continue不发某个分组,接收端会因为一直等不到数据而阻塞,不会给出任何协议层面的反馈,实验记录里只能看到时序卡死,看不到“超时重传”这个关键行为。我的做法是在接收端判断是否“假装这个包没收到”:random.seed(42) # 固定随机种子,让实验可复现 loss_rate = 0.2 # 20% 丢包率,适合观察重传又不至于卡死把丢包判断放在收到合法包之后、回 ACK 之前。当random.random()小于loss_rate时,直接跳过sendto回 ACK 的逻辑,发送方收不到 ACK,自然会在 timeout 后重传。这样日志中会出现[R] simulated loss seq=0,随后发送方打印[T] seq=0 timeout, retransmit,两个端一对照,协议的有效性就直观可见了。丢包率的选择有讲究。0.1 太低,偶尔才触发一次重传,容易让你误以为协议很稳定;0.5 太高,整个流程频繁回退,日志里大半是 retransmit,反而掩盖了正常收发逻辑。我通常先用 0.2 调试,看窗口行为;确认逻辑无误后再把丢包率调成 0.3 到 0.4 做压测。如果丢包率设成 0.3 后文件传输还能完整结束,说明协议在较恶劣条件下依然可用。代码跑通后建议做一次对比实验:固定随机种子,分别统计 loss_rate=0 和 loss_rate=0.2 时的 timeout 次数。前者应该为 0,后者应该正好等于模拟丢掉的包数。这一步能快速验证“丢包是否真的被重传救回来”,也是后续 GBN 版本对照的基线数据。3. 从停等到 GBN:滑动窗口、累计确认与参数调整3.1 GBN 和停等的本质差别:窗口不只是批量发送GBN(Go-Back-N)把发送方从停等的串行模型里解放出来。发送方维护一个长度为 N 的发送窗口,窗口内所有序号都可以连续发送,不必等每个 ACK 回来再发下一个。但接收端的逻辑并没有变复杂:仍然只接受期望序号的分组,一旦发现某个序号丢失,后面到达的乱序分组会被全部丢弃,并继续回送最后一个有序分组的 ACK。发送方超时后会回退到最老的未确认序号,重传整个窗口内所有分组。用窗口大小为 4 举一个直观例子:发送方连续发 0、1、2、3,假设分组 2 在链路中丢失。接收方收到 0 和 1 后回 ACK 0 和 ACK 1,收到分组 3 发现不是期望的 2,直接丢弃并回 ACK 1。发送方收到超时信号后,把发送指针回退到 2,重传 2 和 3,而不是只重传 2。这个“回退整段窗口”的动作就是 GBN 名字的由来。GBN 的接收方不缓存乱序分组,这一点是它与 SR 协议最明显的分界线。乱序分组到了就丢,表面上看很浪费,却换来接收端逻辑的极度简单:只需要维护一个expected_seq变量,不会出现半包缓存和乱序重组的问题。代价是丢包率升高时吞吐会断崖式下降,因为一个包的丢失会导致后面所有在途分组全部重传。课程设计里先做 GBN 再做 SR,就是为了让你直观感受这个性能差异。3.2 GBN 发送端代码改造:发送窗口、累计确认与超时回退发送端需要维护两个变量:base表示窗口内最老的未确认序号,next_seq表示下一个可以发送的新序号。每次收 ACK 都尝试把base向前推进,超时则把next_seq拉回base。核心循环如下:base = 0 next_seq_num = 0 window_size = 4 total = len(data_packets) # data_packets 是已经切片好的数据块 while base < total: # 发送窗口内所有未发送的分组 while next_seq_num < base + window_size and next_seq_num < total: sock.sendto(make_packet(next_seq_num, data_packets[next_seq_num]), addr) next_seq_num += 1 try: ack_seq = recv_ack(sock, timeout=0.6) if ack_seq >= base: base = ack_seq + 1 # 累计确认:ack_seq 及之前的分组都确认了 except socket.timeout: print(f"[GBN] timeout, go back to {base}") next_seq_num = base # 从 base 开始重传窗口内所有分组ack_seq >= base的过滤很关键。GBN 接收方回送的 ACK 是最后连续收到的那一个序号,如果收到的旧 ACK 小于当前base,说明已经收到过更新的确认,直接忽略即可。如果把旧的 ACK 当成新确认,base可能倒退,窗口会重复发送数据。next_seq_num = base是 GBN 重传的固定动作,不需要单独考虑哪个包丢了,统一重传整个窗口。这是实现最简单的策略,代价也是显而易见的。为了配合重传,data_packets必须保留窗口内尚未确认的数据副本,发送端不能像停等模式那样发出一包就释放一包。接收端代码比停等版本还规则,只要维护一个连续期望序号:expected_seq = 0 last_ack = -1 while True: data, addr = sock.recvfrom(65535) seq, payload = parse_packet(data) if seq == expected_seq: # 接收正确,推进期望序号 expected_seq += 1 last_ack = seq sock.sendto(struct.pack("!I", seq), addr) handle_payload(payload) else: # 乱序或重传,直接丢弃,并回送最后一个有序 ACK if last_ack >= 0: sock.sendto(struct.pack("!I", last_ack), addr)注意接收方在遇到乱序分组时并不保持沉默,而是回一个last_ack。这样发送方虽然知道有包没对,但具体丢的是哪个包它并不关心,反正超时后整个窗口都会重传。如果last_ack初始是 -1,且期望序号是 0 却收到了分组 2,此时不应该回 -1,直接丢弃即可。实践中建议在分组头加一个类型字段区分 ACK 和 DATA,不过课程设计用单独的方法回 ACK 也完全够用。3.3 GBN 参数调整:窗口大小、超时时间与丢包率的配合GBN 涉及的参数主要三个,调试时建议先固定丢包率,再调窗口和超时。参数推荐范围调试说明window_size4 ~ 16窗口太小等于没发挥 GBN 优势,太大在丢包率较高时重传成本也高timeout0.5s ~ 1.0s至少要大于一个 RTT,本地回环跑 0.3s 以下容易误超时loss_rate0.1 ~ 0.30.2 最容易看到重传行为和窗口回退日志window_size = 1时,GBN 其实退化成停等协议,代码逻辑依然能跑,但效率没有区别。验证 GBN 是否生效,最简单的方法是固定同一份数据,分别用 window_size=1 和 window_size=8 跑一遍,对比完成时间。如果吞吐没有明显提升,检查发送端是否每次循环都因为网卡缓冲或套接字设置而阻塞住了。超时时间的设置是很多实现翻车的高发区。本地回环 RTT 通常不到 1ms,但 Python 线程调度、日志打印和随机数计算会引入抖动,timeout 设成 0.1s 可能仍然频繁超时,导致窗口不停回退。我一般先把 timeout 设成 1s,验证逻辑无误之后再逐步缩小到 0.5s 或 0.3s,并观察日志里 timeout 次数是否显著增加。丢包率升高时,GBN 的窗口越大,重传风暴越明显。比如丢包率 0.3、窗口 16,一个包丢失后最多触发 15 个分组一起重传,再加上网络中原本就存在的重复包,日志量会爆炸。课程设计报告里可以专门做一张表,记录不同窗口大小下的重传次数,以此说明 GBN 在丢包环境下的瓶颈,并自然引出 SR 协议。4. 双向通道与文件传输:把协议从 Demo 变成 C/S 应用4.1 单向改双向:独立序号空间与捎带确认大多数课程设计要求把单向改成双向,很多人第一反应是开两个 socket,一个发一个收,但这种方案会在连接管理和端口分配上多出不少边界问题。我的习惯是保持一个 UDP socket,让两端各自维护自己的发送序号和接收序号。数据包头对应扩展成两个序号字段:一个代表当前数据包的序号,另一个代表我要确认对端哪个序号。import struct HEADER = struct.Struct("!II") # 前 4 字节是发送序号,后 4 字节是确认序号 def make_data_packet(seq: int, ack_seq: int, payload: bytes) -> bytes: return HEADER.pack(seq, ack_seq) + payload def parse_data_packet(pkt: bytes): seq, ack_seq = HEADER.unpack(pkt[:8]) payload = pkt[8:] return seq, ack_seq, payload接收方收到数据后,需要把回复内容里带上当前要确认的序号。如果正好有数据要发给对方,就把数据包的ack_seq字段填成对方已经连续收到的序号,这叫捎带确认。如果没有数据要发,也要回一个只有头部没有 payload 的纯 ACK 包。捎带确认能省掉一部分 ACK 流量,但复杂度在于纯 ACK 包也要能识别类型。可以继续用 payload 长度来判断:长度 0 就是纯 ACK。双向模式下两端协议逻辑其实完全对称。将原先的send_loop和recv_loop改成两个线程运行,一个负责发送数据,一个负责接收并确认。这里要注意线程共享同一个 socket 时的并发问题:sendto和recvfrom是线程安全的,但日志输出和状态变量要加锁。否则发送线程在改base,接收线程也在读base,会出现窗口推进不一致。4.2 文件传输应用:把字节流切成分组,再按序写盘把协议从传输文本字符串升级成文件传输时,第一步是把文件切成固定大小的块。文件块大小需要和 UDP 最大载荷协调,一般取 1024 字节到 2048 字节比较稳妥。def split_file(path: str, chunk_size: int = 1024): with open(path, "rb") as f: seq = 0 while True: chunk = f.read(chunk_size) if not chunk: # 发送结束标记,由上层处理 yield ("EOF", seq) break yield ("DATA", seq, chunk) seq += 1这里我统一把数据块交给上层的可靠发送函数,由它内部决定用停等还是 GBN。注意chunk_size不能太大,因为 UDP 单包最大理论长度是 65535 字节,头部还要去掉 8 字节以上,写 4096 就会让 IP 层产生分片,链路层再一丢整个包就危险了。接收端写文件时不能边收边写,因为 GBN 接收方只接受有序分组,但 SR 版会收到乱序分组。稳妥的做法是维护一个字典缓存乱序包,等期望序号到达后按序写盘:recv_file = open(recv_path, "wb") recv_cache = {} expected = 0 while True: seq, payload = get_valid_next_packet() if seq == expected: recv_file.write(payload) expected += 1 # 把缓存中连续的包补写进去 while expected in recv_cache: recv_file.write(recv_cache.pop(expected)) expected += 1 else: recv_cache[seq] = payload这个写法对 GBN 不产生实际作用,因为 GBN 不会推后乱序包,但如果实验要求把 GBN 升级成 SR,这个缓存结构可以直接复用。文件传输场景还有一个坑:不能靠文件大小判断传输是否结束,因为最后一个块可能恰好是完整块大小。正确做法是在协议头增加一个类型字段,比如DATA=0、FIN=1。发送方发完最后一个数据块后单独发一个 FIN 包,接收方收到 FIN 并确认完所有数据块之后才关闭文件。4.3 资源里的代码结构:server.py、client.py 与 config.py 各管什么这份课程设计资源的目录划分很清晰,直接看路径就能明白实验流程:路径作用src/protocol/server.py发送端协议逻辑,包含停等/GBN(或 SR)的发送循环src/protocol/client.py接收端协议逻辑,负责校验、回 ACK、写入文件src/util/config.py统一配置端口、窗口大小、超时时间、丢包率data/server_data.txt服务器端待发送的测试数据data/client_recv.txt客户端收到的数据,默认从client_data.txt改名得到report.docx课程设计报告模板,可把实验数据抄进去README.md运行步骤和设备环境说明我拿到这份包时最喜欢的一点是config.py单独存在。把端口、窗口、丢包率抽出来之后,做对比实验不用反复改协议代码,只改配置文件就能跑多组参数。如果你的实验报告要求记录参数影响,建议把每组参数的时间、重传次数、最终文件 MD5 都写进一个 CSV 表格,报告阶段直接用数据说话,比纯截图更有说服力。5. 协议实现中的坑:丢包位置、超时风暴与序号回绕5.1 丢包模拟写在发送端,重传永远不触发现象:设置了 20% 丢包率,但日志里完全看不到 timeout,数据照样发完,实验看起来“一切正常”。原因:把random.random() < loss_rate的判断放在了发送端sendto之前,相当于发送端自己吞掉了部分包。接收端并没有经历数据包丢失,期望序号依然按顺序推进,自然会给每个到达的包回 ACK。发送端不会感知到丢包,超时重传机制完全没有被激活。解决:把丢包判断放到接收端收到合法包之后、回 ACK 之前。判断通过后假装这个包没收到,直接continue跳过 ACK 发送。发送端因为收不到 ACK 而超时,才会重传。要验证丢包位置是否正确,最直接的办法是丢包率设为 1.0,观察发送端是否出现持续 timeout 且重传计数不断增长。如果日志里 timeout 一次都没有,说明丢包放错了端。5.2 超时时间比 RTT 还短,产生重传风暴现象:GBN 窗口开到 8 之后,吞吐反而比停等还低,终端刷屏都是retransmit,整个流程像蜗牛一样慢。原因:超时时间设置不合理。本地回环 RTT 不一定恒定,Python 的解释器开销、接收端的解析打印、随机丢包判断都会让响应时间抖动。timeout 设成 0.1s 甚至更短时,很多本来马上就要收到的 ACK 提前被判为超时,于是触发重传;重传的包又污染接收端序号判断,造成更多超时。解决:先把 timeout 调成 1s,跑通后再逐步下降。如果 0.3s 开始出现 timeout 密集增长,说明当前设备负载偏高,可以考虑把丢包率调低或者窗口调小。另一个技巧是用time.monotonic()记录每次发送到收到 ACK 的实际耗时,把统计出来的最大 RTT 再乘 1.5 到 2 作为超时阈值,这是我在实际调试里比较稳的取值方式。5.3 序号回绕导致合法分组被当成重复包丢弃现象:传输超过一定数据量后,接收方突然开始大量丢弃本来合法的分组,丢包率没有变,但多次重传仍然卡在一个区域过不去。原因:序号字段是固定的 4 字节无符号整型,最大到 4294967295,序号回绕到 0 后,简单的seq == expected_seq判断会错乱。比如expected_seq是 4294967294,下一个合法分组是 4294967295,再下一个是 0,但很多人对0的期望判断变成了“小于当前值,属于旧分组”,于是直接丢弃。解决:对序号采用模2^32的差值比较。只要(seq - expected_seq) % (2**32) < window_size,就可以认为该序号在当前窗口范围内,属于合法的后续分组。对 ACK 也要做同样的窗口判定:只有确认序号落在当前窗口内,才能用来推进base。如果你只是课程设计、数据量不大,可能不会碰到回绕,但只要把文件块调小、文件调大,回绕是必然会来的。这个坑我在面试复盘时也经常遇到,属于协议实现里典型的“看着简单但真实存在”的问题。6. 再进一步:SR 改造与一个高信噪比的验证技巧SR 和 GBN 的代码差异其实很小,核心只有两个动作。GBN 超时时是next_seq = base,重传整个窗口;SR 超时时只重传最小的未确认序号,接收端不再丢弃乱序分组,而是把它缓存到recv_cache里等前面的序号补上。如果你已经完成了第 4 章的双向文件传输,那么只需要在接收端增加一个乱序包缓存,在发送端把超时回退改成定向重传,就能从 GBN 升级到 SR。验证 SR 正确性时,我常用一个比“最终文件能打开”更有效的技巧:让发送端在发送每个分组时记录seq -> md5(payload)的映射,接收端收到每个分组时也记录同样的映射,最后比对两张字典是否完全一致。sent_log[seq] = hashlib.md5(payload).hexdigest() recv_log[seq] = hashlib.md5(payload).hexdigest() assert recv_log == sent_log这个验证方式能精准定位“到底是哪个序号丢了、哪个序号被错误缓存”,而不是等文件全部传完才发现数据错位。把丢包率从 0 到 0.5 逐档递增,固定随机种子跑三遍以上,每次都比对这个映射。以前我以为协议跑通就算完,后来有一次 SR 改造后文件能正常打开,但 20% 丢包率下中间某个块内容对不上,整个日志比对才发现是缓存写入顺序写反了。从那以后我每写一个协议,都会强制先跑一遍丢包对比矩阵,并把每档丢包率下的 MD5 校验结果存进实验报告,这个习惯帮我绕开过不少玄学问题,希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份基于Python实现的可靠数据传输协议课程设计资源包,以UDP为底层通道,完整演示了停等协议、GBN滑动窗口协议及SR协议的设计与实现过程。面向计算机网络课程学生、需要完成类似实验或对传输层协议感兴趣的学习者,可借此掌握可靠传输原理、分组丢失处理、滑动窗口机制以及C/S文件传输应用的构建方法。包内共14个文件,核心为8个Python源码文件,涵盖服务器、客户端、配置及工具模块,另有txt测试数据、docx实验报告、markdown说明和license许可文件,压缩包仅723KB,便于下载和快速部署。资源围绕实验场景展开,包含模拟丢包验证协议有效性、将停等协议扩展为双向传输、基于停等协议实现文件传输、将GBN改进为SR等经典环节,配套文档可帮助梳理实现思路与排错方法。目前已有596人学习使用,适合作为课程设计参考和协议学习入门素材,尤其适合需要提交完整实验报告的学生。1. 基于 Python 的 UDP 可靠传输实现:先跑通停等,再谈 GBN如果你正被基于 Python 实现可靠数据传输协议的课程设计卡住,这份以 UDP 为底座的协议实现包值得花一个下午拆开跑通。它没有直接扔给你一份读不懂的 GBN 源码,而是把“UDP 本身不可靠”这件事摆上台面,从最简单的停等协议开始,逐步升级到滑动窗口、双向传输和文件传输。打开资源时最直接的感受是代码分工清楚:server.py 管发送,client.py 管接收,config.py 预留丢包率和超时参数,非常适合边改边验证。无论你在做停等、GBN 还是 SR,这套代码都能当作可运行的地基,尤其适合网络课设、面试梳理和想彻底搞懂可靠传输细节的开发者。2. 停等协议落地:校验、超时重传与丢包模拟的代码骨架2.1 停等协议背后的状态机时序停等协议是 ARQ 家族里最容易建模的模型:发送方每发一个分组就停下来,等待接收方回 ACK;收到 ACK 才发下一个分组,超时没收到就重传当前分组。整个发送过程只有两个状态,要么处于 ready 状态可以发新数据,要么处于 waiting-for-ACK 状态,所有数据都卡在当前序号上。接收方更简单,能正确收到期望序号的分组就回 ACK,否则直接丢弃,什么都不回。停等时序里最容易被忽略的是“重复旧分组”的场景。假设接收方刚处理完分组 0 并回 ACK,但 ACK 在链路上丢失,发送方超时重传分组 0。此时接收方期望的序号是 1,收到旧分组 0 时必须再次回 ACK 0,否则发送方会一直等待一个永远不会到来的 ACK 1。这就是为什么停等协议里的序号不是可有可无的装饰:它用来区分新分组和重传分组,避免接收方把重传数据当成新数据重复处理。课程设计选 UDP 而不是 TCP,原因恰恰是 UDP 不保证不丢、不重、不乱。你需要在应用层自己实现校验和、序号判定、超时重传和确认机制。如果直接用 TCP,这些逻辑都被内核接管了,反而看不到可靠传输到底是怎么拼起来的。基于 UDP 做一份自己的可靠传输,才能把协议的每个环节暴露在日志和断点下。对回环测试来说,UDP 丢包率极低,所以实验要求模拟丢包,这一点我会在 2.3 节单独展开。停等还有一个明显的工程上限:如果链路时延高,发送方绝大部分时间都在等 ACK,带宽利用率约等于 1 / (1 + 2 * RTT / 发送时间)。在本地环回测试也许看不出差距,但把模拟 RTT 加到 50ms 到 100ms 后,吞吐会跌到几个 KB/s。这个短板正好是下一章引入 GBN 的理由,但先把停等代码写对,GBN 的坑你才能看得明白。2.2 基于 UDP 的停等代码骨架:包结构、发送循环与接收判定资源里的 src 目录是协议主体,我们先给出一个可以直接替换进 server.py 和 client.py 的最小包结构。我用 4 字节序号加 8 字节校验码作为协议头,payload 放在后面,当基础骨架使用:import hashlib import socket import struct SEQ_SIZE = 4 # 序号字段占 4 字节,使用无符号整型 CHECKSUM_SIZE = 8 # 校验码取 MD5 前 8 字节 PKT_HEADER_LEN = SEQ_SIZE + CHECKSUM_SIZE def make_packet(seq: int, payload: bytes) -> bytes: # 用 MD5 前 8 字节做应用层校验,便于演示,实际工程可以换 CRC32 checksum = hashlib.md5(payload).hexdigest()[:8].encode() return struct.pack("!I", seq) + checksum + payloadstruct.pack("!I", seq)把序号转成网络字节序的无符号整型,保证收发包解释一致。校验码放在序号后面,接收端解析时按照固定步长切分,避免出现字节偏移错位。MD5 前 8 字节在演示场景里已经能拦截绝大部分数据篡改问题,虽然不如 CRC 高效,但教学资源里更容易看出校验逻辑。发送端采用循环发送、超时重传的写法:def stop_wait_send(sock: socket.socket, addr, seq: int, payload: bytes, timeout: float = 0.5) -> None: pkt = make_packet(seq, payload) while True: sock.sendto(pkt, addr) sock.settimeout(timeout) try: ack_bytes, _ = sock.recvfrom(SEQ_SIZE) ack_seq = struct.unpack("!I", ack_bytes)[0] if ack_seq == seq: print(f"[T] seq={seq} acknowledged") return except socket.timeout: print(f"[T] seq={seq} timeout, retransmit")每次sendto之后设置settimeout(timeout),阻塞在recvfrom上等待 ACK。这里有一个关键点:recvfrom(SEQ_SIZE)只读 4 字节,因为在停等模型里 ACK 包不需要带校验和,只需要序号告诉发送方“我确认的是哪一包”。如果收到的是上一轮迟到的 ACK,序号对不上就继续循环等待,不能让旧 ACK 推动状态机。接收端更需要小心,因为 UDP 包可能损坏,也可能重复到达:import random def stop_wait_recv(sock: socket.socket, expected_seq: int, loss_rate: float = 0.0, timeout: float = 3.0): while True: data, addr = sock.recvfrom(65535) if len(data) < PKT_HEADER_LEN: continue seq = struct.unpack("!I", data[:SEQ_SIZE])[0] checksum = data[SEQ_SIZE:PKT_HEADER_LEN].decode() payload = data[PKT_HEADER_LEN:] md5 = hashlib.md5(payload).hexdigest()[:8] if seq != expected_seq or md5 != checksum: print(f"[R] drop packet seq={seq} expected={expected_seq}") continue if random.random() < loss_rate: print(f"[R] simulated loss seq={seq}") continue sock.sendto(struct.pack("!I", seq), addr) return payload, addr接收端同时判断序号和校验码,两者有一个不满足就丢弃。注意丢包模拟我放在序号和校验都通过之后,这模拟的是“数据包在链路上消失”而不是“包损坏”,语义更干净。返回时同时给出 payload 和对端地址,便于上层拿到数据并知道该回 ACK 给谁。如果recvfrom设置了 timeout 也要处理超时,但在接收端被阻塞的情况下,一般使用更长的等待时间,或者干脆先不设 timeout,等发送方主动超时重传。2.3 模拟丢包:把丢包放在接收端,才能触发超时重传模拟丢包不是简单地在发送端跳过某个包。如果发送端用continue不发某个分组,接收端会因为一直等不到数据而阻塞,不会给出任何协议层面的反馈,实验记录里只能看到时序卡死,看不到“超时重传”这个关键行为。我的做法是在接收端判断是否“假装这个包没收到”:random.seed(42) # 固定随机种子,让实验可复现 loss_rate = 0.2 # 20% 丢包率,适合观察重传又不至于卡死把丢包判断放在收到合法包之后、回 ACK 之前。当random.random()小于loss_rate时,直接跳过sendto回 ACK 的逻辑,发送方收不到 ACK,自然会在 timeout 后重传。这样日志中会出现[R] simulated loss seq=0,随后发送方打印[T] seq=0 timeout, retransmit,两个端一对照,协议的有效性就直观可见了。丢包率的选择有讲究。0.1 太低,偶尔才触发一次重传,容易让你误以为协议很稳定;0.5 太高,整个流程频繁回退,日志里大半是 retransmit,反而掩盖了正常收发逻辑。我通常先用 0.2 调试,看窗口行为;确认逻辑无误后再把丢包率调成 0.3 到 0.4 做压测。如果丢包率设成 0.3 后文件传输还能完整结束,说明协议在较恶劣条件下依然可用。代码跑通后建议做一次对比实验:固定随机种子,分别统计 loss_rate=0 和 loss_rate=0.2 时的 timeout 次数。前者应该为 0,后者应该正好等于模拟丢掉的包数。这一步能快速验证“丢包是否真的被重传救回来”,也是后续 GBN 版本对照的基线数据。3. 从停等到 GBN:滑动窗口、累计确认与参数调整3.1 GBN 和停等的本质差别:窗口不只是批量发送GBN(Go-Back-N)把发送方从停等的串行模型里解放出来。发送方维护一个长度为 N 的发送窗口,窗口内所有序号都可以连续发送,不必等每个 ACK 回来再发下一个。但接收端的逻辑并没有变复杂:仍然只接受期望序号的分组,一旦发现某个序号丢失,后面到达的乱序分组会被全部丢弃,并继续回送最后一个有序分组的 ACK。发送方超时后会回退到最老的未确认序号,重传整个窗口内所有分组。用窗口大小为 4 举一个直观例子:发送方连续发 0、1、2、3,假设分组 2 在链路中丢失。接收方收到 0 和 1 后回 ACK 0 和 ACK 1,收到分组 3 发现不是期望的 2,直接丢弃并回 ACK 1。发送方收到超时信号后,把发送指针回退到 2,重传 2 和 3,而不是只重传 2。这个“回退整段窗口”的动作就是 GBN 名字的由来。GBN 的接收方不缓存乱序分组,这一点是它与 SR 协议最明显的分界线。乱序分组到了就丢,表面上看很浪费,却换来接收端逻辑的极度简单:只需要维护一个expected_seq变量,不会出现半包缓存和乱序重组的问题。代价是丢包率升高时吞吐会断崖式下降,因为一个包的丢失会导致后面所有在途分组全部重传。课程设计里先做 GBN 再做 SR,就是为了让你直观感受这个性能差异。3.2 GBN 发送端代码改造:发送窗口、累计确认与超时回退发送端需要维护两个变量:base表示窗口内最老的未确认序号,next_seq表示下一个可以发送的新序号。每次收 ACK 都尝试把base向前推进,超时则把next_seq拉回base。核心循环如下:base = 0 next_seq_num = 0 window_size = 4 total = len(data_packets) # data_packets 是已经切片好的数据块 while base < total: # 发送窗口内所有未发送的分组 while next_seq_num < base + window_size and next_seq_num < total: sock.sendto(make_packet(next_seq_num, data_packets[next_seq_num]), addr) next_seq_num += 1 try: ack_seq = recv_ack(sock, timeout=0.6) if ack_seq >= base: base = ack_seq + 1 # 累计确认:ack_seq 及之前的分组都确认了 except socket.timeout: print(f"[GBN] timeout, go back to {base}") next_seq_num = base # 从 base 开始重传窗口内所有分组ack_seq >= base的过滤很关键。GBN 接收方回送的 ACK 是最后连续收到的那一个序号,如果收到的旧 ACK 小于当前base,说明已经收到过更新的确认,直接忽略即可。如果把旧的 ACK 当成新确认,base可能倒退,窗口会重复发送数据。next_seq_num = base是 GBN 重传的固定动作,不需要单独考虑哪个包丢了,统一重传整个窗口。这是实现最简单的策略,代价也是显而易见的。为了配合重传,data_packets必须保留窗口内尚未确认的数据副本,发送端不能像停等模式那样发出一包就释放一包。接收端代码比停等版本还规则,只要维护一个连续期望序号:expected_seq = 0 last_ack = -1 while True: data, addr = sock.recvfrom(65535) seq, payload = parse_packet(data) if seq == expected_seq: # 接收正确,推进期望序号 expected_seq += 1 last_ack = seq sock.sendto(struct.pack("!I", seq), addr) handle_payload(payload) else: # 乱序或重传,直接丢弃,并回送最后一个有序 ACK if last_ack >= 0: sock.sendto(struct.pack("!I", last_ack), addr)注意接收方在遇到乱序分组时并不保持沉默,而是回一个last_ack。这样发送方虽然知道有包没对,但具体丢的是哪个包它并不关心,反正超时后整个窗口都会重传。如果last_ack初始是 -1,且期望序号是 0 却收到了分组 2,此时不应该回 -1,直接丢弃即可。实践中建议在分组头加一个类型字段区分 ACK 和 DATA,不过课程设计用单独的方法回 ACK 也完全够用。3.3 GBN 参数调整:窗口大小、超时时间与丢包率的配合GBN 涉及的参数主要三个,调试时建议先固定丢包率,再调窗口和超时。参数推荐范围调试说明window_size4 ~ 16窗口太小等于没发挥 GBN 优势,太大在丢包率较高时重传成本也高timeout0.5s ~ 1.0s至少要大于一个 RTT,本地回环跑 0.3s 以下容易误超时loss_rate0.1 ~ 0.30.2 最容易看到重传行为和窗口回退日志window_size = 1时,GBN 其实退化成停等协议,代码逻辑依然能跑,但效率没有区别。验证 GBN 是否生效,最简单的方法是固定同一份数据,分别用 window_size=1 和 window_size=8 跑一遍,对比完成时间。如果吞吐没有明显提升,检查发送端是否每次循环都因为网卡缓冲或套接字设置而阻塞住了。超时时间的设置是很多实现翻车的高发区。本地回环 RTT 通常不到 1ms,但 Python 线程调度、日志打印和随机数计算会引入抖动,timeout 设成 0.1s 可能仍然频繁超时,导致窗口不停回退。我一般先把 timeout 设成 1s,验证逻辑无误之后再逐步缩小到 0.5s 或 0.3s,并观察日志里 timeout 次数是否显著增加。丢包率升高时,GBN 的窗口越大,重传风暴越明显。比如丢包率 0.3、窗口 16,一个包丢失后最多触发 15 个分组一起重传,再加上网络中原本就存在的重复包,日志量会爆炸。课程设计报告里可以专门做一张表,记录不同窗口大小下的重传次数,以此说明 GBN 在丢包环境下的瓶颈,并自然引出 SR 协议。4. 双向通道与文件传输:把协议从 Demo 变成 C/S 应用4.1 单向改双向:独立序号空间与捎带确认大多数课程设计要求把单向改成双向,很多人第一反应是开两个 socket,一个发一个收,但这种方案会在连接管理和端口分配上多出不少边界问题。我的习惯是保持一个 UDP socket,让两端各自维护自己的发送序号和接收序号。数据包头对应扩展成两个序号字段:一个代表当前数据包的序号,另一个代表我要确认对端哪个序号。import struct HEADER = struct.Struct("!II") # 前 4 字节是发送序号,后 4 字节是确认序号 def make_data_packet(seq: int, ack_seq: int, payload: bytes) -> bytes: return HEADER.pack(seq, ack_seq) + payload def parse_data_packet(pkt: bytes): seq, ack_seq = HEADER.unpack(pkt[:8]) payload = pkt[8:] return seq, ack_seq, payload接收方收到数据后,需要把回复内容里带上当前要确认的序号。如果正好有数据要发给对方,就把数据包的ack_seq字段填成对方已经连续收到的序号,这叫捎带确认。如果没有数据要发,也要回一个只有头部没有 payload 的纯 ACK 包。捎带确认能省掉一部分 ACK 流量,但复杂度在于纯 ACK 包也要能识别类型。可以继续用 payload 长度来判断:长度 0 就是纯 ACK。双向模式下两端协议逻辑其实完全对称。将原先的send_loop和recv_loop改成两个线程运行,一个负责发送数据,一个负责接收并确认。这里要注意线程共享同一个 socket 时的并发问题:sendto和recvfrom是线程安全的,但日志输出和状态变量要加锁。否则发送线程在改base,接收线程也在读base,会出现窗口推进不一致。4.2 文件传输应用:把字节流切成分组,再按序写盘把协议从传输文本字符串升级成文件传输时,第一步是把文件切成固定大小的块。文件块大小需要和 UDP 最大载荷协调,一般取 1024 字节到 2048 字节比较稳妥。def split_file(path: str, chunk_size: int = 1024): with open(path, "rb") as f: seq = 0 while True: chunk = f.read(chunk_size) if not chunk: # 发送结束标记,由上层处理 yield ("EOF", seq) break yield ("DATA", seq, chunk) seq += 1这里我统一把数据块交给上层的可靠发送函数,由它内部决定用停等还是 GBN。注意chunk_size不能太大,因为 UDP 单包最大理论长度是 65535 字节,头部还要去掉 8 字节以上,写 4096 就会让 IP 层产生分片,链路层再一丢整个包就危险了。接收端写文件时不能边收边写,因为 GBN 接收方只接受有序分组,但 SR 版会收到乱序分组。稳妥的做法是维护一个字典缓存乱序包,等期望序号到达后按序写盘:recv_file = open(recv_path, "wb") recv_cache = {} expected = 0 while True: seq, payload = get_valid_next_packet() if seq == expected: recv_file.write(payload) expected += 1 # 把缓存中连续的包补写进去 while expected in recv_cache: recv_file.write(recv_cache.pop(expected)) expected += 1 else: recv_cache[seq] = payload这个写法对 GBN 不产生实际作用,因为 GBN 不会推后乱序包,但如果实验要求把 GBN 升级成 SR,这个缓存结构可以直接复用。文件传输场景还有一个坑:不能靠文件大小判断传输是否结束,因为最后一个块可能恰好是完整块大小。正确做法是在协议头增加一个类型字段,比如DATA=0、FIN=1。发送方发完最后一个数据块后单独发一个 FIN 包,接收方收到 FIN 并确认完所有数据块之后才关闭文件。4.3 资源里的代码结构:server.py、client.py 与 config.py 各管什么这份课程设计资源的目录划分很清晰,直接看路径就能明白实验流程:路径作用src/protocol/server.py发送端协议逻辑,包含停等/GBN(或 SR)的发送循环src/protocol/client.py接收端协议逻辑,负责校验、回 ACK、写入文件src/util/config.py统一配置端口、窗口大小、超时时间、丢包率data/server_data.txt服务器端待发送的测试数据data/client_recv.txt客户端收到的数据,默认从client_data.txt改名得到report.docx课程设计报告模板,可把实验数据抄进去README.md运行步骤和设备环境说明我拿到这份包时最喜欢的一点是config.py单独存在。把端口、窗口、丢包率抽出来之后,做对比实验不用反复改协议代码,只改配置文件就能跑多组参数。如果你的实验报告要求记录参数影响,建议把每组参数的时间、重传次数、最终文件 MD5 都写进一个 CSV 表格,报告阶段直接用数据说话,比纯截图更有说服力。5. 协议实现中的坑:丢包位置、超时风暴与序号回绕5.1 丢包模拟写在发送端,重传永远不触发现象:设置了 20% 丢包率,但日志里完全看不到 timeout,数据照样发完,实验看起来“一切正常”。原因:把random.random() < loss_rate的判断放在了发送端sendto之前,相当于发送端自己吞掉了部分包。接收端并没有经历数据包丢失,期望序号依然按顺序推进,自然会给每个到达的包回 ACK。发送端不会感知到丢包,超时重传机制完全没有被激活。解决:把丢包判断放到接收端收到合法包之后、回 ACK 之前。判断通过后假装这个包没收到,直接continue跳过 ACK 发送。发送端因为收不到 ACK 而超时,才会重传。要验证丢包位置是否正确,最直接的办法是丢包率设为 1.0,观察发送端是否出现持续 timeout 且重传计数不断增长。如果日志里 timeout 一次都没有,说明丢包放错了端。5.2 超时时间比 RTT 还短,产生重传风暴现象:GBN 窗口开到 8 之后,吞吐反而比停等还低,终端刷屏都是retransmit,整个流程像蜗牛一样慢。原因:超时时间设置不合理。本地回环 RTT 不一定恒定,Python 的解释器开销、接收端的解析打印、随机丢包判断都会让响应时间抖动。timeout 设成 0.1s 甚至更短时,很多本来马上就要收到的 ACK 提前被判为超时,于是触发重传;重传的包又污染接收端序号判断,造成更多超时。解决:先把 timeout 调成 1s,跑通后再逐步下降。如果 0.3s 开始出现 timeout 密集增长,说明当前设备负载偏高,可以考虑把丢包率调低或者窗口调小。另一个技巧是用time.monotonic()记录每次发送到收到 ACK 的实际耗时,把统计出来的最大 RTT 再乘 1.5 到 2 作为超时阈值,这是我在实际调试里比较稳的取值方式。5.3 序号回绕导致合法分组被当成重复包丢弃现象:传输超过一定数据量后,接收方突然开始大量丢弃本来合法的分组,丢包率没有变,但多次重传仍然卡在一个区域过不去。原因:序号字段是固定的 4 字节无符号整型,最大到 4294967295,序号回绕到 0 后,简单的seq == expected_seq判断会错乱。比如expected_seq是 4294967294,下一个合法分组是 4294967295,再下一个是 0,但很多人对0的期望判断变成了“小于当前值,属于旧分组”,于是直接丢弃。解决:对序号采用模2^32的差值比较。只要(seq - expected_seq) % (2**32) < window_size,就可以认为该序号在当前窗口范围内,属于合法的后续分组。对 ACK 也要做同样的窗口判定:只有确认序号落在当前窗口内,才能用来推进base。如果你只是课程设计、数据量不大,可能不会碰到回绕,但只要把文件块调小、文件调大,回绕是必然会来的。这个坑我在面试复盘时也经常遇到,属于协议实现里典型的“看着简单但真实存在”的问题。6. 再进一步:SR 改造与一个高信噪比的验证技巧SR 和 GBN 的代码差异其实很小,核心只有两个动作。GBN 超时时是next_seq = base,重传整个窗口;SR 超时时只重传最小的未确认序号,接收端不再丢弃乱序分组,而是把它缓存到recv_cache里等前面的序号补上。如果你已经完成了第 4 章的双向文件传输,那么只需要在接收端增加一个乱序包缓存,在发送端把超时回退改成定向重传,就能从 GBN 升级到 SR。验证 SR 正确性时,我常用一个比“最终文件能打开”更有效的技巧:让发送端在发送每个分组时记录seq -> md5(payload)的映射,接收端收到每个分组时也记录同样的映射,最后比对两张字典是否完全一致。sent_log[seq] = hashlib.md5(payload).hexdigest() recv_log[seq] = hashlib.md5(payload).hexdigest() assert recv_log == sent_log这个验证方式能精准定位“到底是哪个序号丢了、哪个序号被错误缓存”,而不是等文件全部传完才发现数据错位。把丢包率从 0 到 0.5 逐档递增,固定随机种子跑三遍以上,每次都比对这个映射。以前我以为协议跑通就算完,后来有一次 SR 改造后文件能正常打开,但 20% 丢包率下中间某个块内容对不上,整个日志比对才发现是缓存写入顺序写反了。从那以后我每写一个协议,都会强制先跑一遍丢包对比矩阵,并把每档丢包率下的 MD5 校验结果存进实验报告,这个习惯帮我绕开过不少玄学问题,希望帮到你。本文还有配套的精品资源,点击获取