
简介这是一份基于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 seq0随后发送方打印[T] seq0 timeout, retransmit两个端一对照协议的有效性就直观可见了。丢包率的选择有讲究。0.1 太低偶尔才触发一次重传容易让你误以为协议很稳定0.5 太高整个流程频繁回退日志里大半是 retransmit反而掩盖了正常收发逻辑。我通常先用 0.2 调试看窗口行为确认逻辑无误后再把丢包率调成 0.3 到 0.4 做压测。如果丢包率设成 0.3 后文件传输还能完整结束说明协议在较恶劣条件下依然可用。代码跑通后建议做一次对比实验固定随机种子分别统计 loss_rate0 和 loss_rate0.2 时的 timeout 次数。前者应该为 0后者应该正好等于模拟丢掉的包数。这一步能快速验证“丢包是否真的被重传救回来”也是后续 GBN 版本对照的基线数据。3. 从停等到 GBN滑动窗口、累计确认与参数调整3.1 GBN 和停等的本质差别窗口不只是批量发送GBNGo-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, timeout0.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_size1 和 window_size8 跑一遍对比完成时间。如果吞吐没有明显提升检查发送端是否每次循环都因为网卡缓冲或套接字设置而阻塞住了。超时时间的设置是很多实现翻车的高发区。本地回环 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这个缓存结构可以直接复用。文件传输场景还有一个坑不能靠文件大小判断传输是否结束因为最后一个块可能恰好是完整块大小。正确做法是在协议头增加一个类型字段比如DATA0、FIN1。发送方发完最后一个数据块后单独发一个 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 校验结果存进实验报告这个习惯帮我绕开过不少玄学问题希望帮到你。本文还有配套的精品资源点击获取