
简介这是一份计算机网络课程实验源码基于Python模拟数据链路层的Go-Back-NGBN协议借助UDP套接字实现可靠文件传输。协议采用发送窗口滑动机制当接收端检测到数据出错或丢包时发送端从出错帧开始回退重发代码完整复现了这一过程。资源面向计算机、通信、自动化、电子信息等专业的学生与学习者既适合完成课程设计、上机作业也适合作为理解滑动窗口、超时重传和确认机制的动手示例。压缩包共含21个文件整体大小仅22KB其中12个Python脚本构成项目核心覆盖帧封装与解析、文件分包与重组、UDP数据收发、地址及字节转换、随机差错注入、日志输出和配置读取等模块7个配置文件可调整仿真参数另有1份txt与1份md说明文档辅助环境搭建与使用。代码模块划分清晰可直接在本地运行观察不同丢包场景下GBN协议的重传行为也可作为基础扩展实现选择性重传SR或停等式ARQ实验。目前该资源已有425人浏览学习经测试运行成功适合网络课程实验参考与二次开发。1. 传输总卡在“文件收不全”GBN协议实验到底考什么很多做过《计算机网络》课程实验的人都有这种体验用 Python 写个 UDP 收发单条消息来回传都正常ping 也通但只要把数据链路层的 GBN 协议挂上去做“可靠文件传输”就总在最后阶段出问题——文件传了一半卡住、校验值对不上、重传风暴把吞吐打没。这个实验考的不是你会不会调 socket而是你知不知道滑动窗口、累计确认、超时重传这三个机制如何配合才能在一个会随机丢帧、损坏、乱序的信道上把文件原样传完。本篇文章会沿着“原理→源码实现→参数调优→踩坑→验证”的顺序把一个能跑通的 Python GBN 模拟数据链路层方案拆开讲清楚。适合正在做计网课设的学生也适合想补一补“可靠传输协议落地细节”的开发者。2. 先弄懂 GBN 的可靠传输模型滑动窗口、累计确认与超时重传为什么缺一不可2.1 GBN 协议在数据链路层管什么丢包、损坏、乱序三个对手数据链路层的基本功能里有一项叫可靠传输。物理链路上会出现三种典型的“坏人”噪声导致比特翻转从而让帧损坏网络拥塞或冲突导致帧丢失并行路径或缓存抖动导致帧到达顺序错乱。如果直接把这些坏帧交给上层文件写入轻则文件内容对不上重则写入流程直接崩溃。GBNGo-Back-N后退 N 帧就是针对这三类问题设计的一套滑动窗口协议。GBN 的思路非常朴素发送端允许连续发送 N 个帧但只保留一个定时器接收端只按顺序接收凡是序号不是自己期望的那个帧一概丢弃。丢掉的帧怎么补靠发送端超时后把窗口内所有已发送未确认的帧从头重传一遍。为什么要全部重传而不是只传那一个坏帧因为接收端不缓存乱序帧发送端无法确定窗口内哪些帧被接收端收下了干脆从最早的未确认帧开始整体后退这正是“Go-Back-N”得名的由来。很多初学者会拿 TCP 的经验来套 GBN觉得应该像 TCP 那样接收端缓存乱序数据、发送端只重传缺失段。如果你这么想就混淆了 GBN 和 SR选择重传。在课程实验这种简化模型里接收窗口为 1 是 GBN 的标志性设定接受“乱序帧必须丢弃”这个设定后面的代码才写得顺。2.2 为什么用 Python UDP 模拟数据链路层而不是直接写链路层课程实验不可能让你去改网卡驱动或直接操作 HDLC/PPP 帧所以常见做法是在 UDP socket 之上模拟出“一条不可靠信道”然后再在这条信道上实现 GBN 协议。换句话说你的应用进程里跑的这段代码就是“模拟的数据链路层”UDP 扮演的是底下那根会出错、会丢帧的物理线路。这里有一个高频误解有人觉得“既然学的是数据链路层就应该拿 TCP 来做”。恰恰相反UDP 没有拥塞控制、没有重传、没有滑动窗口才能让你自己实现完整链路逻辑如果用 TCP底层把丢包重传全干了你的 GBN 代码根本触发不了超时实验结果永远是“一次通过”什么都学不到。还有一个重要细节UDP 只能保证“数据报的发送顺序”在绝大多数情况下等于到达顺序但它不承诺不丢、不乱、不错。在局域网本地回环上测试时丢包率接近 0很多问题根本暴露不出来。为了让实验具有演示价值和说服力通常需要在发送端或一个中间转发函数里人为引入丢包和损坏概率制造“不可靠信道”的效果。这一步看着不复杂但恰恰是后面验证 GBN 是否真的可靠的关键。2.3 窗口模型与序号空间的约束WINDOW 能设多大、为什么 W 必须小于序号空间GBN 的性能优势来自连续发送。设信道时延为 RTT若采用停等协议发送端每发一帧都要等确认链路利用率大致是 1/(1 2a)而 GBN 的发送窗口为 W 时理论上可以把利用率拉到接近 W 倍。窗口越宽吞吐越高但窗口不可能无限大因为序号字段是有限的。以常见实现为例如果序号用 1 字节表示取值范围是 0~255那么发送窗口 W 的最大值必须满足 W 256。更严谨的结论来自“新旧帧区分问题”发送端重传一个旧帧时如果它的序号和接收端期待的“下一个新帧序号”重合接收端会误判新帧导致重复写入。因此标准结论是对于 n 位序号GBN 的最大发送窗口为 W_max 2^n - 1。我在课设里一般会做两层冗余保险序号字段直接用 2 字节struct 的 H 格式取值范围扩大到 0~65535窗口大小压到 4~16 之间。窗口开得过大不仅让“新旧识别”变得危险还会把 UDP 接收缓冲区打爆这在 5.3 节会细说。你如果看到一个 WINDOW 参数第一反应应该是把序号位数抄出来算一下边界而不是直接调大窗口跑。边界没守住时出现的故障现象往往非常隐蔽——文件偶尔能传对偶尔会在中间多出一段重复内容。3. 把 GBN 协议跑起来Python 发送端、接收端与文件分帧实现3.1 实验环境Python 安装与 vscode 配置只依赖标准库开始写代码前先确认环境装好 Python 3.8 以上版本即可Windows、Linux、macOS 都行。用 vscode 的话装一下官方 Python 扩展就能跑用 pycharm 配置 python 环境也一样。这个实验只用 socket、struct、zlib、threading、random 这些标准库不需要 pip install 任何第三方包所以环境问题几乎不会卡住你。工程文件建议拆成三个模块gbn_common.py放帧封装、解析和 CRC 校验函数gbn_sender.py放发送端逻辑gbn_receiver.py放接收端逻辑。这样你在调 bug 时可以单独 import 某个函数做单元测试。UDP 通信地址用127.0.0.1:8000接收端 bind 这个端口发送端向它发数据。如果要在两台机器上做演示把收发地址换成真实 IP 即可但记得关闭防火墙或放行对应 UDP 端口。3.2 帧结构设计与 CRC 校验两个函数搞定组帧和验帧帧结构是整个协议的地基。我采用的帧格式为2 字节序号、1 字节帧类型、2 字节数据长度、4 字节 CRC32、变长 payload。帧类型定义两个值0 表示文件头帧写入文件名、文件大小、总块数1 表示数据帧。文件头单独用一种帧是因为接收端必须先拿到文件信息才能创建文件和判断传输完成条件。# gbn_common.py帧封装、解析、CRC校验 import struct, zlib TYPE_FILE_HEADER 0 TYPE_DATA 1 HEADER_LEN 9 # H B H I 2 1 2 4 def make_file_header(seq: int, name: str, size: int, total_blocks: int) - bytes: payload b\n.join([name.encode(), str(size).encode(), str(total_blocks).encode()]) return make_frame(seq, TYPE_FILE_HEADER, payload) def make_data_frame(seq: int, payload: bytes) - bytes: return make_frame(seq, TYPE_DATA, payload) def make_frame(seq: int, ftype: int, payload: bytes) - bytes: # 校验对象包含 序号类型payload防止 seq/type 被篡改而漏检 crc zlib.crc32(struct.pack(!H B, seq, ftype) payload) 0xffffffff header struct.pack(!H B H I, seq, ftype, len(payload), crc) return header payload def parse_frame(frame: bytes): if len(frame) HEADER_LEN: return None seq, ftype, length, crc struct.unpack(!H B H I, frame[:HEADER_LEN]) payload frame[HEADER_LEN:HEADER_LEN length] calc zlib.crc32(struct.pack(!H B, seq, ftype) payload) 0xffffffff if calc ! crc: return None # 校验失败调用方直接丢帧 return seq, ftype, payload代码逻辑不复杂但有三个关键点值得说。第一CRC 的计算范围把“序号类型”也包括进去了这样如果有人模拟链路层噪声把 seq 字段翻转了校验也能发现只对着 payload 算 CRC 是课设里最常见的漏检写法。第二struct.pack(!H B H I, ...)里的感叹号表示网络字节序发送和解析统一用这个格式就不会出现 ACK 序号解析错位的怪问题。第三帧头固定 9 字节接收端拿到完整帧后先校验再处理先处理后校验是本实验头号坑之一。3.3 发送端核心循环窗口发送、累计确认、超时重传发送端是 GBN 机制中逻辑最重的一端。它要维护两个指针base指向窗口内最老的未确认帧next_seq指向下一个待发送的新帧。窗口未满且还有数据就连续发送收到 ACK 后把base推进超时则把base到next_seq-1的所有帧全部重发。# gbn_sender.py发送端核心循环节选 import socket, struct, zlib, time from collections import deque HOST, PORT 127.0.0.1, 8000 WINDOW 8 # 发送窗口大小 TIMEOUT 0.3 # 重传超时秒局域网可取 0.2~0.5 BLOCK 1024 # 每个数据帧 payload 大小 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(TIMEOUT) addr (HOST, PORT) # 第0帧文件头用停等方式先确认保证接收端拿到文件信息 header_frame make_file_header(0, test.bin, file_size, total_blocks) while True: sock.sendto(header_frame, addr) try: ack, _ sock.recvfrom(1024) if parse_ack(ack) 0: break except socket.timeout: continue base, next_seq 1, 1 frame_cache {} # 窗口内缓存用于超时重传 while base total_blocks: # 窗口没满就继续发这是 GBN 连续发送的关键 while next_seq base WINDOW and next_seq total_blocks: frame make_data_frame(next_seq, chunks[next_seq - 1]) frame_cache[next_seq] frame sock.sendto(frame, addr) next_seq 1 try: ack, _ sock.recvfrom(1024) ack_seq parse_ack(ack) if ack_seq base: base ack_seq 1 # 累计确认ACK(5) 表示 0~5 全部收到 for i in list(frame_cache): if i ack_seq: del frame_cache[i] except socket.timeout: # Go-Back-N 的核心动作超时窗口内全部重传 for i in range(base, next_seq): sock.sendto(frame_cache[i], addr) time.sleep(0.001) # 轻微限速避免瞬间打爆 UDP 缓冲这个循环里有三个决定可靠性的细节。第一个是“累计确认推进 base”收到ack_seq后直接让base ack_seq 1因为 GBN 的 ACK 携带的是“目前已连续确认到的最大序号”不需要为窗口内每一帧单独确认。第二个是超时后重传全部窗口帧而不是只重传base帧这正是和 SR 协议的最大区别。第三个是frame_cache只保留窗口内的帧ACK 推进后及时清理避免大文件造成内存膨胀。3.4 接收端核心循环期望序号校验、落盘与重复 ACK接收端比发送端简单很多它只有一个状态expected即下一个期待到达的帧序号。任何 CRC 失败或序号不等于expected的帧一律丢弃但要把上一次正确确认的 ACK 再回一遍这就是“重复 ACK”。发送端收到重复 ACK 后不会立即重传而是在超时后把整个窗口再推一遍从而补上缺失的帧。# gbn_receiver.py接收端核心循环节选 import socket, struct, zlib HOST, PORT 127.0.0.1, 8000 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((HOST, PORT)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 20) # 阶段1循环收文件头直到校验通过且序号为0 file_info None while file_info is None: data, peer sock.recvfrom(2048) parsed parse_frame(data) if parsed is None: continue seq, ftype, payload parsed if ftype TYPE_FILE_HEADER and seq 0: parts payload.split(b\n) file_name, file_size, total_blocks parts[0].decode(), int(parts[1]), int(parts[2]) send_ack(sock, peer, 0) file_info (file_name, file_size, total_blocks) expected 1 # 阶段2按序收数据帧写文件 received 0 with open(file_name, wb) as f: while received total_blocks: data, peer sock.recvfrom(2048) parsed parse_frame(data) if parsed is None: send_ack(sock, peer, expected - 1) # 损坏帧回重复ACK continue seq, ftype, payload parsed if seq ! expected: send_ack(sock, peer, expected - 1) # 乱序帧丢弃不缓存 continue f.write(payload) received 1 expected 1 send_ack(sock, peer, expected - 1)重复 ACK 的设计容易被人忽略但它是 GBN 接收端唯一的“反馈手段”。如果没有这一行接收端丢弃坏帧后保持沉默发送端就只能在超时后才反应过来平均恢复时间变长。有了重复 ACK发送端虽然不会立即重传但至少能确认“链路还活着、发送窗口也没有被错误推进”。另外注意send_ack(sock, peer, expected - 1)中发送的是expected - 1而不是expected因为 ACK 的含义是“序号为 n 的帧及其之前的全部帧都已收到”发expected会被发送端误认为一个超前确认。4. 可靠文件传输的关键参数帧长、RTO、WINDOW 怎么配合4.1 控制帧与数据帧分开发文件名的可靠传法文件头帧承载文件名、文件大小、总块数这三项缺一不可。文件头如果混入 GBN 窗口里一起发会引入一个确定性的死锁接收端还没收到文件头不知道总块数数据帧来了也无法判断“这是第几块、要不要落盘”只能全丢。而发送端又可能在窗口推进后把文件头帧清出缓存导致永远无法重传。常见做法是让文件头走一次“停等确认”流程——发送端循环发送文件头帧直到收到针对序号 0 的 ACK再进入数据段。这在代码层面多写一个while True但把两条不同可靠性的路径彻底分开避免了大量边界 bug。有些现成源码把文件头也当成普通数据帧直接塞进窗口我的经验是这种实现大概率在丢包率稍高时“偶发失败”且极难复现。4.2 校验选 zlib.crc32 还是 hashlib.md5代价与收益帧级校验我用zlib.crc32运算速度快单个 1024 字节帧校验耗时微秒级。CRC32 对随机比特损坏的漏检率约为 2^-32对这个实验已经非常安全。哈希完成后做一次“整文件比对”会更有说服力传输结束在接收端算一个 MD5发送端也算一个 MD5两边一致说明全程没有出现检测不到的损坏。整文件用 MD5 没问题但把 MD5 放进每一帧就很不划算处理器的开销会拉低吞吐而且每一帧都做安全哈希对课设来说没有必要。还有一个容易踩的细节有些实现会把序号和类型字段排除在 CRC 范围之外。如果信道噪声恰好转了序号位payload 的校验还是通过接收端就会把这个帧当作另一个序号的合法帧写入文件造成静默数据错位。只要把struct.pack(!H B, seq, ftype) payload一起丢进 CRC 计算这个隐患就从根上消除了。4.3 三个必调参数WINDOW、TIMEOUT、块大小的经验值与公式参数建议值设置依据块大小512~1024 字节小于常规 UDP 路径 MTU减少 IP 分片本实验 buffer 设 2048 也够WINDOW4~16序号用 2 字节时最大窗口为 65535但实际受接收缓冲和演示效果限制TIMEOUT2 倍 RTT先测出单程 RTT再乘 2局域网一般 0.1~0.5 秒别低于 50ms序号位宽2 字节用 struct 的!H打包天然规避 1 字节序号的“新老帧撞车”TIMEOUT 设小了会发生“重传风暴”帧其实没丢只是 ACK 慢了一点发送端就急着重传导致接收端收到大量重复帧吞吐率直线下降。TIMEOUT 设大了则丢帧后恢复慢假设 RTT 是 5ms超时设 2 秒那么一个丢帧要等 2 秒才触发重传传一个大文件要卡很多次。我调试时会在代码里打印每次 ACK 的到达间隔取平均值的 2~3 倍作为超时值。WINDOW 和接收端缓冲区要配套。窗口开到 16每帧 1024 字节一瞬间就是 16KB 数据涌进接收端如果接收端的recvfrom处理速度不够UDP 接收缓冲满了就直接丢帧底层丢帧会让 GBN 进入反复重传的恶性循环。接收端初始化时调大 socket 缓冲区是个好习惯sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 20)可以放到开头。4.4 文件写回的边界处理最后一块不满和重复帧写文件的逻辑看起来只是一句f.write(payload)但边界问题全在“块大小”上。文件的最后一个分块很可能不足 512 或 1024 字节接收端如果总按固定大小写文件尾部就会多出一截旧数据或空字节。解决方式是以帧头里的 length 字段为准parse_frame返回的 payload 本身就是按 length 切开的字节串write出来长度天然正确。重复帧是另一个边界问题。超时重传机制会带来重复帧接收端靠seq ! expected直接丢弃这是对的。但要警惕一种“部分重复”的情形假设接收端已经写入了第 5 帧并推进expected到 6此时一个重传的第 5 帧到达会被丢弃可是如果这个重传帧在传输过程中又被截断而变成另一个长度CRC 会先失败根本进不到序号判断这层。所以说“先校验、后判断序号、最后写文件”这三步顺序一个都不能换。5. GBN 实验避坑与排查从卡死到文件对不上的五条踩坑记录5.1 现象打开丢包模拟后程序卡死进度条不动原因出在校验和执行顺序上。不少实现把parse_frame的结果直接拿来写文件忽略了 CRC 校验失败时的处理分支或者在校验前就把数据写入了文件导致文件内容错位后续所有帧的序号判断都乱了。还有一种情况是丢包率设置过高比如 30% 甚至 50%发送端重传的帧也一直在丢窗口内所有帧全部被清空recvfrom永远等不到 ACK。解决思路分两步。先检查接收端是否是“先校验、后写文件、再回 ACK”的正确顺序在parse_frame返回None时一定要走重复 ACK 分支。再把丢包率降到 5%~10%这是让 GBN 表现出“重传后仍能完成传输”的合理区间。最后给发送端加日志打印base、next_seq和ack_seq看是不是base一直停在原地——如果是说明某帧永远没收到而接收端也没能把期望序号推进。5.2 现象传完了但 md5 不一致文件尾部多了几行旧数据现象是 md5 对比失败仔细检查发现接收文件比原文件长。原因基本可以锁定在“最后一块写满固定长度”这个错误上。比如块大小是 1024文件大小是 2500 字节分成三块分别是 1024、1024、452如果第三块写文件时硬写 1024 字节文件就变成 3072 字节多出 572 字节的残留数据。这些残留往往来自发送缓冲区的旧数据很难靠肉眼发现。解决方法是所有写入的 payload 都严格按帧头给出的 length 字段切片不要用BLOCK常量去截断。同时传输完成后可以用一个最终校验来复盘接收端用hashlib.md5()对整个文件算一遍摘要打印出来和发送端比对。参考命令是md5sum received.bin和md5sum original.bin两边一致就说明这个链路真正做到了可靠文件传输。5.3 现象窗口调到 32 后性能暴跌甚至数据错乱窗口增大通常预期吞吐提升但调到 32 后反而出现大量重传、文件内容偶尔重复。根源是序号空间不够用。如果实现中序号用 1 字节表示取值范围只有 0~255当窗口滑动到接近 256 时新帧序号的剩余空间不足重传一个旧帧时接收端无法区分“这是重传的旧帧”还是“合法的新帧”于是把重复数据写入文件。解决方法是把序号改到 2 字节用struct.pack(!H, seq)取值范围拉到 65535窗口 32 就不再触碰边界。同时记得遵守“2^n - 1”的最大窗口约束加上实验习惯性的保守策略窗口不超过序号空间一半。调试时打印一下重传次数如果发现窗口增大后每传 100 帧重传超过 30 次就要回头查序号位宽和接收缓冲两个地方。5.4 现象base 一直不动超时后反复重传但 ACK 序号不对表现为发送端一直在超时重传但收到的 ACK 总是小于base窗口推不动。先不要怀疑丢包率先怀疑 ACK 包的语义和解析。常见错误有两种一是接收端发了expected而不是expected - 1导致发送端认为“接收端在等一个更靠后的帧”base永远小于ack_seq二是struct.pack和unpack的字节序不一致比如发送用!H解析用H序号在本地小端和网络大端之间被错误解释。解决方法是统一字节序在gbn_common.py的parse_ack和make_ack里都用!H格式。然后加一行调试日志在发送端收到 ACK 后打印ack_seq人工验证它是否符合“收到的最后一个连续序号”语义。还要注意 ACK 帧也应该包含类型字段防止把数据帧的 payload 误当成 ACK 序号来解析。5.5 现象本机回环测试正常局域网测试却偶发卡顿在127.0.0.1上跑UDP 基本不会丢包内核直接走回环路径GBN 很难真正体现重传。一旦换到真实局域网无线信号波动、网卡队列溢出、交换机缓存抖动都会造成真实丢包这时重传频率升高是正常的。但如果卡顿严重到无法完成大文件传输更可能是接收端 UDP 缓冲区过小发送端瞬时灌入的窗口帧被内核直接丢弃导致每次都是整窗重传。解决方法是接收端初始化时设置SO_RCVBUF为 1MB 以上发送端在重传循环里加一个time.sleep(0.001)做轻微限速。另一个实用技巧是在发送端统计“总发送帧数 / 文件总帧数”这个比值称为重传开销系数系数越接近 1 说明信道越干净大于 2 说明链路条件很差或参数没调对。把这个指标打到终端比盯着进度条更容易看出系统的健康状况。6. 三个必测场景验证 GBN 实现丢包率、校验损坏与窗口对比验证 GBN 做没做对不能只跑一次“顺利传输”就收工。我通常会准备三个固定场景在发送端挂一个信道模拟函数# channel.py模拟不可靠信道的丢包与比特损坏 import random def channel(frame: bytes, loss_rate0.1, corrupt_rate0.03): if random.random() loss_rate: return None # 模拟丢帧 if random.random() corrupt_rate: frame bytearray(frame) pos 9 random.randrange(len(frame) - 9) frame[pos] ^ 0xff # 模拟比特翻转 return bytes(frame) return frame场景一是loss_rate0, corrupt_rate0的全干净信道。这个场景验证的是基准正确性窗口发送、ACK 推进、文件落盘三个环节没有逻辑错误。文件传完后 md5 必须一致且重传次数应该为 0如果此时出现了重传说明超时阈值设得太小。场景二是loss_rate0.1, corrupt_rate0.03的常规不可靠信道。这个场景验证 GBN 的纠错能力允许重传次数小幅上升但传输必须完成且 md5 一致。重点观察每次丢帧后发送端恢复的速度以及重复 ACK 是否让发送端及时感知链路状况。如果卡住超过 2 秒多半是超时阈值设得过大或接收端丢弃乱序帧后没有回重复 ACK。场景三是窗口对比测试。把WINDOW分别设为 1、4、8、16 各跑一遍同一个文件记录耗时和重传次数。其结果通常是一条先降后升的曲线窗口 1 接近停等吞吐最低窗口 8 左右最优窗口 16 时如果接收缓冲没调大吞吐反而会因重传而下降。把这个结果放进实验报告比空谈“滑动窗口提升利用率”有说服力得多。我自己的一个习惯是跑正式演示前先保留一份带丢包模拟的脚本版本课堂上专门用它表演丢帧和自愈最后再切换到干净信道做完整文件接收。这样的演示既有说服力又不会在关键时刻因为人为故障而翻车。希望这份 GBN 协议的落地拆解能帮你把课设做得更扎实也少走一点那些只有踩过才知道的弯路。本文还有配套的精品资源点击获取