ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python实现UDP可靠传输:停等、GBN与SR协议详解

Python实现UDP可靠传输:停等、GBN与SR协议详解

简介:基于Python实现可靠数据传输协议的课程设计资源,面向计算机网络课程中学习UDP、可靠传输与滑动窗口机制的本科生。项目从停等协议出发,逐步扩展为GBN协议,并进一步改造为SR协议,覆盖单向/双向可靠数据传输、模拟丢包验证以及基于C/S结构的文件传输应用,适合作为实验参考或课设起点。压缩包共14个文件,约723KB,主体为8个Python源码文件,另含3个txt数据文件、1份docx实验报告、1个license和1个README说明,代码与文档分工清楚。已有596人学习浏览。解压后可获得完整的协议实现、配置与数据示例、报告文档及使用说明,便于对照协议流程、检查收发逻辑并快速二次开发。

1. 基于Python的UDP可靠传输协议项目:停等、GBN与SR的一次性落地

如果你正在做计算机网络课程设计,大概率会遇到一个坎:UDP不可靠,但老师要求基于UDP自己实现一个可靠数据传输协议。这个项目就是为这场景准备的,它用纯Python实现了停等协议、GBN滑动窗口协议和SR选择重传协议三个完整版本,每个版本都自带模拟丢包模块、双向传输支持和C/S文件传输应用。它不是教学Demo,发送方和接收方是两个独立可运行的Python程序,跑完还能在接收端看到还原出来的完整文件。适合正在做网络实验或课程设计的同学直接复用,也适合想彻底搞懂UDP可靠传输底层机制的开发者。

2. 项目架构与目录拆解:从src/protocol到data的每一层

2.1 目录结构:每个文件都知道自己该干什么

拆开压缩包后,项目布局非常工整,先看一张文件清单:

文件/目录职责
src/protocol/server.py发送方主程序,负责分块、发送、超时重传
src/protocol/client.py接收方主程序,负责接收、校验、确认、落盘
src/protocol/util协议工具模块,包含报文打包、校验和计算
src/config.py全局参数配置,端口、窗口大小、丢包率都在这里
src/data/server_data.txt发送方的原始数据文件
src/recv/client_recv.txt接收方还原后的数据文件
report.docx课程设计报告,可直接改写成自己的实验报告
README.md运行说明和环境要求

这个分层很标准,config.py把可调参数全部收敛,protocol目录放网络相关逻辑,data和recv分别是输入输出目录。我拆过不少课程设计包,能做到这一层级的并不多。最常见的问题是参数散落在代码硬编码里,跑通一个场景再想换丢包率就得满文件找,而这个项目把所有变量都集中到了config.py。

2.2 技术选型:为什么是UDP socket加struct打包

项目核心是模拟可靠传输,所以传输层协议必须是UDP,否则就失去了实验意义。Python的socket标准库提供sendto和recvfrom,正好对应UDP的无连接收发模式。难点在于UDP的报文边界是保留的——发多少次就收多少次,不像TCP流那样有粘包问题,但UDP本身不保证顺序、不保证到达,这两点得靠协议层自己解决。

协议工具模块里用了struct.pack和struct.unpack做报文序列化,这个选择很合理。相比手动拼接字符串再用split切分,struct可以把定长头部打包成紧凑的二进制字节流,序列号、确认号、校验和一个包就能装下,接收方解析时按固定格式unpack即可,出错概率低得多。以下是典型的报文打包逻辑:

import struct # 报文头格式: 类型(1字节) + 序列号(4字节) + 确认号(4字节) + 数据长度(4字节) HEADER_FMT = "!BIII" HEADER_LEN = struct.calcsize(HEADER_FMT) def make_packet(seq, ack, packet_type, data=b""): header = struct.pack(HEADER_FMT, packet_type, seq, ack, len(data)) return header + data def parse_packet(packet): header = packet[:HEADER_LEN] packet_type, seq, ack, data_len = struct.unpack(HEADER_FMT, header) data = packet[HEADER_LEN:HEADER_LEN + data_len] return packet_type, seq, ack, data

这个设计把报文头固定为13字节,!BIII中的!表示网络字节序(大端),保证跨机器解析一致。类型字段区分数据包和ACK包,序列号和确认号用于排序与重传判断,数据长度告诉接收方这一帧有效载荷有多大。需要改协议时,只需要调整HEADER_FMT,所有封包解包的地方会自动跟随。

2.3 校验与纠错:checksum的计算位置有讲究

项目里除了基础的UDP传输,还在util模块中加入了校验和计算。这里有个细节容易踩坑:校验和的计算必须覆盖整个报文头,而不只是数据部分。只校验数据、不校验头部的做法很常见,但序列号一旦被篡改,接收方即使收到合法数据也会因为错序而误判重传。

def calculate_checksum(packet): if len(packet) % 2 == 1: packet += b"\x00" total = 0 for i in range(0, len(packet), 2): word = (packet[i] << 8) + packet[i + 1] total = (total + word) & 0xFFFFFFFF total = (total >> 16) + (total & 0xFFFF) return (~total) & 0xFFFF

这段实现的是类TCP的16位补码校验和,计算对象是整个报文(头部加数据)。发送方在make_packet时把checksum字段填好,接收方对收到的完整报文重新计算,结果与报文字段不一致就丢弃。逻辑上要注意循环里total >> 16加上total & 0xFFFF这一步是处理进位回卷,漏掉这一步计算出的校验和会不准确,测试时容易出现“时好时坏”的间歇性丢包。

3. 核心模块拆解:从停等协议到GBN、SR的代码演进

3.1 停等协议:发送方发一包等一包,超时重传

停等协议是可靠传输的地基,逻辑就一句话:发送方发一个包,等待ACK,收到后再发下一个;ACK超时就重传当前包。听起来简单,但实现时的核心问题是“等多久算超时”。这个项目在config.py中配置了超时时间,发送方用一个select.select或settimeout控制收包阻塞时间。

import socket import time import config from protocol.util import make_packet, parse_packet sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 数据包类型: 0=数据 1=ACK 2=FIN # 停等协议发送循环 def stop_and_wait_send(data_chunks): seq = 0 for chunk in data_chunks: while True: pkt = make_packet(seq=seq, ack=0, packet_type=0, data=chunk) sock.sendto(pkt, (config.SERVER_ADDR, config.SERVER_PORT)) try: sock.settimeout(config.TIMEOUT) resp, _ = sock.recvfrom(2048) pkt_type, recv_seq, recv_ack, _ = parse_packet(resp) if pkt_type == 1 and recv_ack == seq + len(chunk): break # 正确的ACK,推进序号 except socket.timeout: print(f"seq={seq} 超时,重传") continue seq += len(chunk)

这里的序号不是按包个数递增,而是按字节数递增,recv_ack == seq + len(chunk)确认的是“这个包以及它之前的数据都收到了”。我在阅读时留意到项目采用字节序号而不是包序号,这有一个实际好处:文件传输场景下,接收方可以用序号偏移直接写入文件对应位置,天然支持后续做断点续传。settimeout(config.TIMEOUT)每次循环都重新设置,避免了超时时间累积导致等待越来越久的问题,这点值得照抄。

3.2 GBN滑动窗口:累积确认与批量重传

GBN在停等协议之上加了一个发送窗口,允许发送方在未收到ACK的情况下连续发送多个包,提高了信道利用率。代价是接收方只接受按序到达的数据包,一旦中间丢一个,后续全部丢弃,发送方要从最小的未确认序号开始重传。这就是“Go-Back-N”名字的由来。

import config from protocol.util import make_packet, parse_packet # 滑动窗口发送逻辑,base是最早未确认的包,next_seq是下一个要发的包 def gbn_send(data_chunks): base = 0 next_seq = 0 window_size = config.WINDOW_SIZE data_len = len(data_chunks) while base < data_len: while next_seq < base + window_size and next_seq < data_len: pkt = make_packet(seq=next_seq, ack=0, packet_type=0, data=data_chunks[next_seq]) sock.sendto(pkt, (config.SERVER_ADDR, config.SERVER_PORT)) next_seq += 1 try: sock.settimeout(config.TIMEOUT) resp, _ = sock.recvfrom(2048) pkt_type, _, recv_ack, _ = parse_packet(resp) if pkt_type == 1: # 累积确认:收到ack=n,说明n之前所有包都收到了 base = recv_ack except socket.timeout: # 超时重传从base开始的所有包 next_seq = base print(f"超时,从seq={base}开始重传")

窗口大小WINDOW_SIZE是吞吐量和丢包恢复成本之间的天平。窗口越大,正常传输时效率越高,但每丢一个包需要重传的后续包也越多。项目配置里给了一个保守的默认值,实际使用时建议按丢包率动态调整:丢包率5%以下窗口可以开到8以上,丢包率超过20%时窗口超过4会让吞吐量雪崩,因为大量带宽花在重传被丢弃的后续包上。一个容易忽略的约束是窗口大小必须小于序号空间,否则接收方无法区分新包和重传包,这就是经典“序号回绕”问题,后面避坑章节会展开。

3.3 SR选择重传的改造:接收端缓存乱序包

GBN的缺点在丢包率高时很致命:一个包丢了,后面一堆正确的包全被丢弃重传。项目把GBN改进为SR协议,核心变化在接收端——不再只收按序的包,而是把乱序到达的包缓存起来,只对缺失的序号触发重传请求。

# SR接收方核心逻辑 recv_buffer = {} # 缓存乱序到达的包 expect_seq = 0 # 当前期望接收的序号 def sr_receive(): global expect_seq while True: packet, addr = sock.recvfrom(2048) pkt_type, seq, ack, data = parse_packet(packet) if pkt_type == 0: if seq < expect_seq or seq in recv_buffer: # 重复包或已缓存包,直接重发ACK ack_pkt = make_packet(seq=0, ack=seq, packet_type=1, data=b"") sock.sendto(ack_pkt, addr) elif seq == expect_seq: # 正好是期望的包,推进窗口 expect_seq = seq + len(data) while expect_seq in recv_buffer: expect_seq += len(recv_buffer.pop(expect_seq)[0]) else: # 乱序包,缓存但回复ACK recv_buffer[seq] = (data, addr) ack_pkt = make_packet(seq=0, ack=seq, packet_type=1, data=b"") sock.sendto(ack_pkt, addr)

这里有个关于ACK形态的选择:SR协议的确认既可以是累积确认也可以是独立确认,项目采用的是“对每个数据包单独回复ACK”,这样发送方就知道具体哪个序号丢了,只需要重传单个包。接收端用recv_buffer字典缓存乱序包,expect_seq一旦命中就连续推进,同时从字典中顺势把连续的数据都拿出来。这段逻辑是SR实现的核心,建议逐行读通再考虑改动。

从模块演进看,这个项目的代码组织方式是先写停等、再扩展为GBN、最后到SR,三条协议都放在同一个protocol目录下,文件间通过config.py切换模式。做二次开发时,不需要推翻重写,改一个配置项就能在三种协议间切换,这比拆成三个独立项目更实用。

4. 模拟丢包与文件传输:验证协议有效性的两条路径

4.1 丢包模拟:怎么造出“时有时无”的网络环境

UDP本身不丢包,模拟丢包是为了验证协议的重传机制。项目在socket收发层包了一层丢包过滤器,每次收到数据包时按概率决定是否直接丢弃,并且把ACK包和数据包的丢包率分开配置,避免出现“数据包全到、ACK全丢”的极端场景。

import random import config def should_drop(packet_type): if packet_type == 0: return random.random() < config.DATA_LOSS_RATE return random.random() < config.ACK_LOSS_RATE

random.random()返回0到1之间的浮点数,如果丢包率配置为0.3,那30%的包会被模拟丢弃。这里的参数调整直接影响实验效果:丢包率太低时协议几乎不需要重传,看不出协议间的性能差异;丢包率太高时吞吐量崩溃,也看不出正常传输的连续性。我一般从5%起步,逐步提高到60%,分别记录每个档位的吞吐量和重传次数,这样实验曲线最清晰。项目里每个独立运行单元可以单独配置丢包率,有利于测试不同方向上的网络不对称性。

4.2 文件传输应用:从内存数据到磁盘落盘

协议不传输抽象的数据块,而是真正把src/data/server_data.txt发送到接收端落盘为src/recv/client_recv.txt。发送方打开文件后按固定大小分块,每块作为一个数据包载荷;接收方收到数据后根据seq序号直接seek到文件对应偏移写入,这样即使包乱序到达,落盘的字节位置也不会错乱。

# 发送方读取并分块发送 CHUNK_SIZE = 1024 # 每块1KB def send_file(file_path): with open(file_path, "rb") as f: chunks = [] while True: chunk = f.read(CHUNK_SIZE) if not chunk: break chunks.append(chunk) # 传入协议层发送 if config.PROTOCOL_MODE == "SR": sr_send(chunks) elif config.PROTOCOL_MODE == "GBN": gbn_send(chunks) else: stop_and_wait_send(chunks) # 接收方按序号偏移写入 def write_to_file(data, seq): with open(config.RECV_FILE, "r+b") as f: f.seek(seq) f.write(data)

这段的重点是f.seek(seq),seq是字节序号正好用作文件偏移量。实现文件传输时还有个隐性问题:r+b模式要求文件必须已存在,所以接收方启动时先创建空文件并填充到预期大小,或者用open(config.RECV_FILE, "wb")先写入全零字节占位。不少人在写接收逻辑时直接用"wb"模式反复打开,导致每个包覆盖前面文件内容,最后落盘文件只有最后一个包的数据,这是课程设计里最经典翻车点之一。项目的做法是发送方在数据发送前先把文件总字节数通过一个特殊控制包告诉接收方,接收方据此预分配空间。

4.3 运行示例:命令、日志与验证方法

这是个C/S结构,必须先启动接收方再启动发送方。推荐顺序如下:

# 先启动接收端 client.py python src/protocol/client.py # 再启动发送端 server.py python src/protocol/server.py

运行前打开src/config.py检查几个核心参数,通常课程设计默认值已经能直接跑:

参数说明
SERVER_ADDR服务端监听地址,本机跑填127.0.0.1
SERVER_PORTUDP端口,默认12345
TIMEOUT超时重传间隔,单位秒,默认0.5
WINDOW_SIZEGBN/SR的窗口大小,默认8
DATA_LOSS_RATE数据包丢失概率,默认0.1
ACK_LOSS_RATEACK包丢失概率,默认0.05

跑通后验证分两步:第一步看控制台输出,重传日志、ACK确认日志是协议工作的直接证据;第二步对比文件,终端执行diff src/data/server_data.txt src/recv/client_recv.txt,无输出说明两个文件完全一致。如果diff有差异,优先怀疑接收端写入模式而非协议逻辑。课程设计报告中建议附上不同丢包率下重传次数对比表,这个项目控制台输出的重传日志可以直接换算成统计数据。

5. 避坑与排查:五个踩过坑的经典问题

5.1 发送方一直在超时重传,接收方却完全没输出

现象:server.py控制台反复打印“seq=xx超时”,client.py却一个包也没收到。

原因:接收方绑定地址用了localhost而发送方发往127.0.0.1,某些系统上解析结果不一致导致报文没送到。另一个常见原因是防火墙把UDP端口拦了。

解决:统一让接收方绑定0.0.0.0,发送方所有报文发往127.0.0.1特定端口,两个地址显式写死,不要依赖主机名解析。运行前先用netstat -anu | grep 端口号确认端口在监听。

5.2 接收文件只有最后一块,前面全是空洞

现象:最终生成的client_recv.txt只有几字节,和源文件大小完全对不上。

原因:接收方每次open文件都用"wb"模式,write时把之前的内容全部清空重写。

解决:启动接收方时预创建目标文件并按源文件大小填充占位字节,后续写入统一用"r+b"模式按seek偏移写。我会在接收协议里额外加一个文件长度控制包,接收方收到后再创建对应大小的占位文件。

5.3 GBN窗口大于序列号空间,重传判断失效

现象:丢包率高了以后,发送方把重传包当成新包,接收方把新包当成重传包,双方错乱。

原因:序列号用seq % 256这类取模表达时,窗口大小超过序号空间,接收方就分不清“新seq”和“旧seq”。这是滑动窗口协议最容易踩的数学坑。

解决:记住一个硬约束:窗口大小必须严格小于序号空间。如果序号是16位(0到65535),窗口不能超过32768。项目里config默认WINDOW_SIZE=8远小于序号空间,但改成大窗口时一定要同时扩展序号位宽。

5.4 调试时丢包像“玄学”,每次结果都不同

现象:同一份代码同一丢包率,重传次数每次跑出来的都不一样,无从分析。

原因:random.random()每次运行都从系统时间取随机种子,导致实验不可复现。

解决:在config.py增加RANDOM_SEED配置项,构造随机对象时传入固定种子值。实验对比时需要可重复,设置种子的丢包序列是完全确定的。等协议调试稳定后再去掉种子跑随机场景。

5.5 超时时间设置太短,引发雪崩式重传

现象:窗口模式下偶发一次网络抖动,发送方瞬间把整个窗口的包全部重传,然后新的正确ACK到达,又推进了窗口,接收方陷入忙乱。

原因:TIMEOUT设成0.1秒以下,链路延迟稍大就触发超时,而重传的包和正常ACK报文在网络中交叉,加剧了拥塞。

解决:超时时间下限建议设为RTT的2倍以上。本机实验RTT很短,可以维持默认0.5秒;如果要跨机器测试,先ping一下对端,再根据RTT动态设置TIMEOUT。一个简单公式:TIMEOUT = max(0.2, RTT * 2 + 0.1)。

6. 二次开发:把单工协议改造成双工的两步改造法

第一个实用的改造方向是把现有的单向文件传输升级为双向文件传输。现在的架构是server发、client收,二次开发时需要在client里增加一个独立的发送线程,server里增加一个独立的接收线程,两个方向各跑一套协议逻辑。具体步骤:在src/protocol/client.py中复用send_file并新增一个发送函数,目标地址改为server的监听地址;同时在server.py中新增接收循环和落盘逻辑。注意两个方向的socket建议分开两个UDP端口,避免收发互相干扰。

第二个改造方向是添加动态超时重传策略。项目当前是固定超时时间,改造后可参考RTT估算的公式:每次收到ACK后记录RTT样本,更新平滑RTT和偏差,超时时间设为EstimatedRTT + 4 * DevRTT。这样网络波动时能自动适配,而不是用固定值赌运气。

验证改造是否成功有个简单测试流程:先保持单工方式跑通一次diff对比,确认原有功能没被改坏;再把config.py的丢包率设到20%以上跑双向传输,确认两个方向的文件都完整落盘;最后把WINDOW_SIZE调至4和16做吞吐量对比,预期窗口增大后吞吐量上升,但重传量也上升。

这套实验做完后我已经把每一个协议的序号空间、超时设置和丢包场景都画成了一张对照表贴在自己实验报告里。从那以后我跑网络协议实验前都会强制走一遍“先最小场景、再丢包场景、最后全双工”三层验证路径,给自己省下了不少改代码的返工时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进