简介:一套基于 Python 实现的 UDP 可靠数据传输协议工程包,面向计算机网络课程设计、实验报告撰写及协议编程学习者。包内从停等协议入手,逐步扩展到 GBN、SR 协议,覆盖单向与双向可靠传输、丢包模拟验证以及基于 C/S 结构的文件传输应用,适合需要完整方案与可运行代码的高校学生参考。资源共 14 个文件,以 8 个 Python 源码文件为核心,包含 server、client、协议实现及工具模块;另有 3 个文本数据文件、设计报告 docx、README 与 license,可配合报告写作和实验复现。压缩包整体约 493KB,结构紧凑,已有 1014 人学习下载。通过源码与设计报告,读者能拿到完整的协议状态机设计、拆包/确认/重传思路、双向通信改进过程及实验结果,便于快速理解 UDP 之上实现可靠传输的要点,并直接复用或二次开发。
1. 基于Python实现的可靠数据传输协议:搜到 zip 之后,先搞清楚它到底在干什么
很多人拿到「基于Python实现的可靠数据传输协议.zip」,第一反应是去搜 python 源码大全,找一份能直接跑的课设代码。但我建议先换一个问题:TCP 已经把可靠传输做完了,为什么还要自己造一个?答案不是“老师让写的”,而是游戏同步、实时音视频、自定义消息总线这些场景,恰恰需要在 UDP 上叠一套轻量可控的可靠传输。这套协议的核心目标就一句话:在可能丢包、损坏、乱序的不可靠信道上,用校验、确认、重传和序号机制,让上层收到一条不丢、不乱、不重的字节流。它可以从几十行的停等协议开始,也可以成长到带回退N步的滑动窗口版本。这篇按我从课设调通到模拟丢包压测的经验来写,适合正在做计网实验、准备手撕代码、想在 UDP 上做通信底座的人。
2. 可靠传输要解决的三个问题:丢包、损坏、乱序,以及为什么从 UDP 动手
2.1 为什么不直接用 TCP:把黑匣子的边界画清楚
TCP 就是一套成熟的可靠传输协议:握手、序号、校验、确认、超时重传、拥塞控制全都有。但它是内核协议栈里的黑匣子,你没法在用户态改它的窗口策略,也没法观察丢包时它到底重传了什么。课程设计和自研协议要的是“按需裁剪的可靠传输”,所以最常见的做法是:用 UDP 的 socket 打底,把网络不可靠的本质暴露出来,然后在应用层自己实现可靠机制。UDP socket 一发一收,包头没有序号也没有确认号,丢包丢得无声无息,这恰好是研究可靠传输最好的起点——它把 TCP 藏起来的三个问题全部还原到明面上。
你可能会问:既然 UDP 不可靠,为什么很多实时通信场景不用 TCP?因为 TCP 遇到丢包会停在原地等重传、排队,对低延迟场景体验很差。自研协议可以做得更激进:丢一个包只重传那一个,甚至根据业务选择“这个包过期了我就不重传”。这就是基于 Python 实现可靠数据传输协议的根本原因:可靠不只有 TCP 一种形态,它是可配置的策略组合。往大了说,HTTP/3 的 QUIC 也是把可靠传输从内核挪到用户态自己实现,思路和课设里在 UDP 上叠窗口是一回事。
2.2 网络会犯的错,和协议对应的解药
把问题拆开看,不可靠信道犯四类错:比特损坏、报文丢失、报文乱序、报文重复。第一类靠校验和识别出来后直接丢弃;第二类靠 ACK 加超时重传,发出去没确认就当丢;第三类靠序号让接收端排序,配合滑动窗口决定“乱序的包是收还是丢”;第四类靠序号去重,收到重复序号直接丢弃或重发 ACK。这张表是整篇的实现地图:
| 信道问题 | 协议机制 | 代价 |
|---|---|---|
| 比特损坏 | 校验和 + 丢弃 | 每个包多 4 字节 |
| 报文丢失 | 超时重传 + ACK/NAK | 延迟增加,可能出现重复 |
| 报文乱序 | 序号 + 缓存排序 | 接收端内存 |
| 报文重复 | 序号去重 | 同上 |
在这个阶段,先别急着写死代码,先把两个问题的答案定下来:“每收到一个包,我要不要回 ACK;每发一个包,我要不要记时间”。我一般会先在纸上画一遍发送端状态和接收端状态,再动手。Python 的循环写起来快,但逻辑抄起来也容易乱,状态图画清楚,后面排错能省一半时间。
2.3 停等协议为什么不够:链路利用率这道算术题
最朴素的可靠传输是停等协议:发一个包,等一个 ACK,收到再发下一个。它逻辑简单,二十行就能跑通,但对带宽的浪费是灾难性的。假设链路带宽 10 Mbps,RTT 100 ms,应用层包长 1 KB:发送一个包需要 0.8 ms,然后干等 100 ms。链路利用率约等于 0.8 / (100 + 0.8),不到 1%。也就是说,带宽再大,停等协议也只能跑出几十 Kbps 的效果。
滑动窗口就是把“等 ACK 的时间”填满:窗口大小接近带宽延迟积,理论上链路利用率可以接近 100%。带宽延迟积等于带宽乘 RTT,10 Mbps 乘 0.1 s 大约是 125 KB,换算成 1 KB 的包就是一百多个。课程设计里窗口给 8~16 个包,不是拍脑袋,是让发送端在等 ACK 的同时多塞几个包。这里有个不带公式的直觉:链路越宽、距离越远,窗口需要越大;窗口里放着的是“已经发出但还没被确认”的包。
于是协议从停等演进到流水线:GBN(回退N步)和 SR(选择重传)。GBN 实现简单,代价是乱序代价大——丢一个包,接收端会丢弃后续所有包,发送端超时后把整个窗口重传一遍。SR 为每个包单独计时、单独重传,接收端要缓存乱序包,代码复杂一截。课程设计里做 GBN 的最多,因为它比停等更能讲清楚“窗口”和“累积确认”的关系,也比 SR 更容易在答辩时讲透。
3. 报文与校验和:写一个能跑通的最小封包/解包骨架
3.1 报文格式:每个字段都是对抗一类问题
协议层之上是报文格式。自研协议最忌讳“发送直接拼字符串、接收直接切片”,因为语音、二进制、文件内容都可能有任意字节,必须用固定头部加长度字段来切。环境上我默认 Python 3.8 以上版本,只用标准库 socket 和 struct,不需要第三方依赖。下面是一份 20 字节的头部设计,工程里我习惯把它单独放在 packet.py 里:
| 字段 | 类型/宽度 | 作用 |
|---|---|---|
| magic | 2 字节 | 识别协议包,过滤串包 |
| version | 1 字节 | 协议版本,方便演进 |
| type | 1 字节 | DATA / ACK / NAK / FIN |
| seq | 4 字节 | 数据序号,去重与排序 |
| ack | 4 字节 | 确认号,累积确认语义 |
| length | 4 字节 | payload 长度 |
| checksum | 4 字节 | 全包校验 |
magic 看起来是小事,但真有用。同一个端口如果被别的进程占着,或者网卡上混着其他 UDP 流量,没有 magic 的协议会把别人的数据当自己的 payload 解析,轻则乱码,重则把 seq 解析成巨大数字导致消息队列爆掉。version 是为了以后加字段时旧节点还能识别版本不兼容,而不是直接把结构体读歪。
type 字段把控制包和数据包分开,这是停等和滑动窗口能跑的前提。seq 和 ack 的语义我建议在注释里固定下来:seq 表示“这个数据包是发送方的第几个数据包”;ack 表示“发送方下一个期待收到的数据包序号”。这两个语义不统一,后面排查死锁会很痛苦,第 5 章会专门说。
3.2 校验和:用累加取反,而不是一把梭 hash
校验和最常见的实现有两种:CRC 和类 TCP 的 16 位累加取反。课程设计与面试里,类 TCP 风格更合适,因为它好讲、好手写,溢出后能回到 16 位循环进位。我用的是“逐 16 位累加,溢出回卷,最后取反”的做法,写起来不长,效果也够用。注意这里不对 payload 单独做 hash,而是对完整报文算校验和,因为 hash 要逐块从头算,强度高但慢;UDP 场景下校验和强度够用就行。
# packet.py 中的校验和实现,TCP 16 位校验和思路 def compute_checksum(data: bytes) -> int: # 奇数长度补一个空字节,保证逐 16 位累加时不会漏尾巴 if len(data) % 2: data += b"\x00" total = 0 for i in range(0, len(data), 2): word = (data[i] << 8) + data[i + 1] total += word total = (total & 0xFFFF) + (total >> 16) # 回卷进位 return (~total) & 0xFFFF说明:total & 0xFFFF截断到低 16 位,total >> 16把溢出的进位加回低位,这一步是“回卷”。最终取反是为了让全零报文不容易被误判成合法校验。这里有个必须讲清楚的关键规则:发送方计算时,checksum 字段必须置为 0;接收方验证时同样先把 checksum 字段清零再算,比对是否一致。收发两端用同一条规则,否则永远对不上。
3.3 封包与解包:把字节流切成能认的包
接下来是最小骨架:make_packet 负责把消息变成 bytes,parse_packet 负责把 bytes 变回结构体,同时完成校验。用 struct 的!前缀表示网络字节序,避免跨平台大小端问题。
import struct MAGIC = 0x7E57 VERSION = 1 TYPE_DATA = 1 TYPE_ACK = 2 TYPE_NAK = 3 TYPE_FIN = 4 def make_packet(seq: int, ptype: int, payload: bytes = b"", ack: int = 0) -> bytes: length = len(payload) # 先按 checksum=0 组一个完整报文,再算校验和 head = struct.pack("!HBBIIII", MAGIC, VERSION, ptype, seq, ack, length, 0) packet = head + payload cs = compute_checksum(packet) return struct.pack("!HBBIIII", MAGIC, VERSION, ptype, seq, ack, length, cs) + payload def parse_packet(data: bytes): if len(data) < 20: return None magic, ver, ptype, seq, ack, length, cs = struct.unpack("!HBBIIII", data[:20]) if magic != MAGIC or ver != VERSION or length != len(data) - 20: return None payload = data[20:20 + length] # 关键:把 checksum 字段置 0 后重新计算并比较 body = struct.pack("!HBBIIII", magic, ver, ptype, seq, ack, length, 0) + payload if compute_checksum(body) != cs: return None return {"ptype": ptype, "seq": seq, "ack": ack, "payload": payload}这段代码有三个值得说明的点。第一,make_packet 里先组一个 checksum=0 的包计算,再把真实 checksum 填进去,不能对“已经含 checksum 的报文”再算一次,否则接收方验证时永远对不上。第二,parse_packet 直接用 head 加 payload 重新组包,并把 checksum 字段清零,再调用 compute_checksum,保证两端规则一致。第三,length 字段同时承担了边界校验:收到的字节数不对,直接返回 None,宁可丢包也不能把错包当有效包处理。
3.4 MSS 怎么选:别超过 1450 字节
封包骨架有了之后,下一个问题就是数据块切多大。Python 的 UDP socket 理论上最大能发 65507 字节,但超过 MTU(典型以太网 1500 字节)就会触发 IP 分片。分片后的 IP 包在链路上极易丢,丢一片整个 UDP 报文就没了,重传还得重发一整块,效率反而更低。所以常用做法是直接把 MSS 定为 1450 字节,也就是 1500 减 20 字节 IP 头减 8 字节 UDP 头,留出余量给隧道头或 IP 选项。窗口和缓冲区再大,单包也得按这个大小切。
这里给个可以直接抄的切分函数:
MSS = 1450 def split_chunks(payload: bytes, mss: int = MSS): # 按固定大小切成 list,最后一块不足 mss 也没关系 return [payload[i:i + mss] for i in range(0, len(payload), mss)]调用方只需要保证 payload 是 bytes,传文件、传 JSON、传序列化后的对象都行。我一般会在工程里把“分块”和“重组”写在同一边,接收端维护一个 pending 结构,按 seq 拼好再交给上层,避免把分块逻辑散落到业务代码里。
4. 停等协议到滑动窗口:超时重传、累积确认与窗口参数的实现细节
4.1 先把停等协议跑通:二十行内的可靠传输
最小的可靠传输就是停等协议。发送端逐块发数据,收到对应 ACK 才移向下一个块;接收端只接受恰好等于期望序号的块,其余一律丢弃或重发 ACK。下面是完整可跑的骨架,ACK 携带的 ack 字段表示“下一个期望的序号”。注意 import socket、time 这些基础库,代码里不再重复贴。
def stop_wait_send(sock, addr, payload, timeout=0.5): chunks = split_chunks(payload) for seq, chunk in enumerate(chunks): while True: sock.sendto(make_packet(seq, TYPE_DATA, chunk), addr) try: sock.settimeout(timeout) data, _ = sock.recvfrom(2048) pkt = parse_packet(data) if pkt and pkt["ptype"] == TYPE_ACK and pkt["ack"] == seq + 1: break except socket.timeout: # 超时重发同一个块,直到确认 continue def stop_wait_recv(sock): expected = 0 while True: data, addr = sock.recvfrom(2048) pkt = parse_packet(data) if not pkt or pkt["ptype"] != TYPE_DATA: continue if pkt["seq"] == expected: # deliver 是上层回调,你可以改成 print、写文件或交给队列 deliver(pkt["payload"]) expected += 1 sock.sendto(make_packet(expected, TYPE_ACK, ack=expected), addr) else: # 收到旧序号的重复数据,再回一个期望 ACK sock.sendto(make_packet(expected, TYPE_ACK, ack=expected), addr)超时处理是整个协议能否站住的关键。recvfrom一旦设置了 settimeout,超时会抛 socket.timeout,所以停等发送端不需要额外线程,一个 while True 就能循环重发。接收端用 expected 做期望序号,重复包不回数据,直接补 ACK。这段代码很短,但已经覆盖了校验、丢包重传、乱序去重三个机制,窗口之外,可靠传输的底座已经齐了。
4.2 升级到回退N步:窗口、累积确认与一次超时重传整个窗口
停等跑通之后,把“发一个等一个”改成“一次发多个、窗口内自由前进”,就是 GBN。发送端维护两个指针:base 是窗口里最老未确认的包,next_seq 是下一个要发的包。窗口没满就继续发,收到 ACK 就推进 base。
def gbn_send(sock, addr, payload, window=16, timeout=0.3): chunks = split_chunks(payload) total = len(chunks) base = 0 next_seq = 0 last_ack = 0 while base < total: # 窗口没满就先发 while next_seq < base + window and next_seq < total: sock.sendto(make_packet(next_seq, TYPE_DATA, chunks[next_seq]), addr) next_seq += 1 try: sock.settimeout(timeout) data, _ = sock.recvfrom(2048) pkt = parse_packet(data) if pkt and pkt["ptype"] == TYPE_ACK and pkt["ack"] > last_ack: last_ack = pkt["ack"] # ack 是下一个期望序号 base = last_ack except socket.timeout: # 回退N:重传从 base 开始到 next_seq-1 的所有包 for seq in range(base, next_seq): sock.sendto(make_packet(seq, TYPE_DATA, chunks[seq]), addr)GBN 接收端几乎没有变化,唯一的区别是收到乱序包时不再只回 ACK,而是连续回“当前期望序号”的 ACK,让发送端知道窗口根本没推进:
def gbn_recv(sock): expected = 0 while True: data, addr = sock.recvfrom(2048) pkt = parse_packet(data) if not pkt or pkt["ptype"] != TYPE_DATA: continue if pkt["seq"] == expected: deliver(pkt["payload"]) expected += 1 # 无论是否乱序,都回复当前期望序号 sock.sendto(make_packet(expected, TYPE_ACK, ack=expected), addr)GBN 里 ACK 的累积语义是关键。发送端收到 ack=5 意味着“5 以前的数据包我都收到了,下次请发 5”,不需要为每个包单独确认。接收端只要发现 seq 不等于 expected,就说明中间丢了包,后续到达的包一律不作为推进依据,全部丢弃后继续回期望 ACK。这就是“回退N”名字的由来。送实验报告一句话:GBN 用牺牲窗口内部分重传来换取接收端零缓存。你可能想问为什么不用 NAK,停等里 NAK 很直观,但 GBN 里 ACK 已经能传递“我没收到哪个”的信息,控制面少一种包就少一类 bug,这也是 TCP 的选择。
4.3 超时时间怎么设:不是拍的,是算的
超时时间是除窗口外最影响性能的参数。设小了,链路一抖动就疯狂重传;设大了,真丢包时要等很久才补。我一般不会用固定值,而是先测 RTT,用 EWMA 平滑,再把超时设为平滑 RTT 的 1.5 到 2 倍。课程设计没时间动态测,直接把超时定在 0.2 到 0.5 秒起步,再在丢包模拟下观察重传次数来微调。
# 简易 RTT 估算:每次收到 ACK 更新 sample_rtt = time.time() - send_time[seq] estimated_rtt = 0.875 * estimated_rtt + 0.125 * sample_rtt timeout = max(0.1, 2 * estimated_rtt)这个 EWMA 公式是 TCP 经典做法,权重 0.125 让新样本的影响不至于太跳。把它合进 4.2 的 gbn_send:每次 sendto 后记 send_time[seq] = time.time(),收到 ACK 时取样本。
注意:动态测 RTT 时,重传过的包不能重新采样,否则估算会被重传惩罚拉偏,这个坑在第 5.1 节展开。
4.4 窗口大小、MSS、缓冲区的配合
动窗口参数之前,先记住一个比例:窗口要覆盖带宽延迟积,但也不能超过接收端缓冲区。带宽 10 Mbps、RTT 100 ms 时,带宽延迟积约 125 KB,除以 MSS 1450 B 大约是 88 个包,这是理论上限。课设里窗口给 8 到 16 起步很稳,因为丢包模拟下重传效率会随窗口增大而下降,窗口太大反而让回退N的连锁重传放大。下面这张参数表可以直接抄:
| 参数 | 停等 | GBN 初值 | 调整依据 |
|---|---|---|---|
| 窗口 | 1 | 16 | 带宽乘 RTT 后再除以 MSS |
| 超时 | 0.5 s | 0.3 s | 1.5~2 倍平滑 RTT |
| MSS | 1450 | 1450 | 以太网 MTU 余量 |
| 接收缓冲 | 1 块 | 窗口大小块 | 避免不可达后内存吃到满 |
我见过很多课设代码把接收缓冲开成固定 65536 字节,窗口设到 64,结果在模拟 20% 丢包时,接收端因为要消化大量乱序信息而内存占用飙升。稳妥做法是接收端缓冲开成窗口等量,每个位置放一个分块或空位标记,宁可让包丢在网络上,也别让它堆在进程里。
5. 可靠数据传输协议踩坑清单:超时、序号、ACK 语义和回环后的假象
5.1 超时时间太小:一阵通一阵断,像接触不良
现象:本地回环一切正常,放到模拟丢包环境里吞吐骤降,发送端疯狂重发同一个包,抓包看 ACK 其实已经回到对端了。程序看起来在正常工作,速度却慢得让人怀疑人生。
原因:超时设得比真实 RTT 还小,或者重传后 RTT 估算没有排除重传样本。ACK 在路上,发送端等不及就重传,重传的包再触发重复 ACK,链路被自激式重传占满。
解决:超时按 EWMA 的 1.5 到 2 倍平滑 RTT 走;重传过的包不进 RTT 样本。如果没做 RTT 采样,宁可把超时踩大到 0.5 秒,也比 50 毫秒的抖动强。
5.2 校验和算错:对方永远沉默,你永远重发
现象:发送端把包发出去,接收端一个 ACK 都不回;用 print 在两端分别打日志,接收端确实 recvfrom 到了数据,但就是不做确认。排查半天发现是校验和逻辑不对。
原因:这是最典型的“校验和自洽性”错误——发送端用含 checksum 的报文再算一遍,接收端把 checksum 字段清零重算,两边规则不一致;或者接收端忘了清零,导致任何报文都过不了校验。
解决:发送端算校验和时 checksum 位置 0,接收端验证时间样置 0 再算,比对一致才收。第 3.3 节的 parse 代码就是这个规则,照抄不会错。排错时写一行 print,把 un pack 出来的 cs 和重算的值打出来,一两个来回就能定位。
5.3 ACK 语义漂移:两个端点对“ack 指什么”的理解不同
现象:窗口偶尔能推进,推到某个序号后就卡死,重启又正常;单包文件能传,多包文件必卡。越是大文件越明显,排查时往往盯着窗口大小看半天。
原因:发送端认为 ack=3 是“收到 3 号包”,接收端回的是“期望 3 号包”,两者差 1。乱序时差 1 恰好能让窗口假推进,直到某个边界才暴露死锁。
解决:把 ack 的语义写进代码注释:ack 等于下一个期望收到的 seq。接收端第一次收到 seq=0,确认 ACK 的 ack 填 1;发送端收到 ack=1,base 前移到 1。两端用同一套固化逻辑,不要临时“顺手填了 seq”。
5.4 本地回环测试全是假象:不掉包的测试测不出可靠协议
现象:本机测试 10 MB 文件秒传,重传计数为 0;一搬上局域网或真机链路,丢包 5% 后直接卡死,最后才发现重传逻辑根本没被触发过。
原因:loopback 接口的丢包率几乎为零,RTT 也比真实网络小几个数量级。可靠传输协议真正要对抗的丢包、乱序、延迟抖动,回环测试一个都测不出来。
解决:用第 6 章的 tc/netem 在回环上人为加丢包和延迟,让本地开发环境模拟出广域网特征。没有 Linux 环境,就写一个概率丢包代理层,在 sendto 和 recvfrom 之间故意丢弃一部分包,效果一样。
5.5 窗口与 recvfrom 阻塞互相卡死:程序“死了”但进程还活着
现象:窗口填满后程序不再发包,也不再收包,CPU 占用不高,进程看起来活着,实际在 recvfrom 里阻塞等一个永远不会来的 ACK。如果不打日志,根本看不出它卡在哪一行。
原因:发送端把“窗口推进”和“超时重传”写进同一次 recvfrom 阻塞操作。窗口满时发送端没有可发的包,却在等 ACK;而 ACK 需要接收端先收包,接收端如果也在等,就成了经典死锁。
解决:把发送循环和收包循环分开。常见做法是发送端起一个后台线程专门收 ACK,发送主循环只管发包和超时;或者用非阻塞 socket 加 select/poll 做多路复用,别让任何一个循环独占 recvfrom。我一般用非阻塞 socket,代码里没有线程,行为也更好预测。
6. 证明协议可靠:丢包模拟、吞吐统计和三次验证
6.1 用 tc/netem 把丢包率“调”出来
不用真机,Linux 本机就能模拟广域网。tc 命令挂在回环接口上,加丢包率和延迟,本地测试立刻变成真丢包环境:
sudo tc qdisc add dev lo root netem loss 5% delay 20ms # 测试完成后一定要清理 sudo tc qdisc del dev lo root注意:lo 是回环设备,加上丢包后本机所有环回流量都会受影响,测完立刻删除规则。
实际跑的时候可以做一组 0%、2%、5%、10% 的对比测试,每档记录总耗时和重传次数。这张表放实验报告里,比任何“效率很高”的形容词都有说服力。
6.2 三个可信的验收指标
协议靠不靠谱,只看三个数字:有效吞吐、重传率、乱序率。有效吞吐用 payload 总字节数除以总耗时;重传率统计发送端实际 sendto 次数与分块数的比值;乱序率在接收端统计“seq 不等于 expected”的次数占总包数比例。代码层面就是在两侧各加一个计数器:
# 发送端统计片段 send_cnt += 1 if pkt and pkt["ptype"] == TYPE_ACK: ack_cnt += 1 # 结束后打印重传率 = (send_cnt - total) / total参数不要散落在代码里。把窗口、超时、MSS、最大重传次数集中到一个配置字典,跑实验时只改配置,重跑脚本看三个指标。这个“后悔药”式配置,能让调参从半小时缩短到两分钟。
6.3 一个收尾习惯
做成这一步,其实你已经不是在交课设,而是在评判自己的协议。我后来做任何自研通信协议,第一步不是写功能,而是先把丢包模拟脚本和统计打点搭好,因为协议没有度量就只是玄学;有了丢包率、重传率、吞吐这三个数,所有“感觉卡”“感觉稳”的反馈都能落地成一个能改的参数。这个习惯帮我少走了很多弯路,也希望帮到你。
本文还有配套的精品资源,点击获取