
简介这是一份面向计算机专业学生的 UDP 可靠传输实现方案源自中山大学 2021 年计网期中大作业适合正在学习传输层协议、套接字编程或需完成类似课程设计的学习者参考。资源包共 4 个文件压缩后约 466KB包含两个 C 源文件客户端与服务端完整实现、一份实验报告 PDF 和一份 README 说明从代码、文档到使用指引均有覆盖。项目针对 UDP 无连接、易丢包、可能乱序等特点给出了序列号、确认应答、超时重传、流量控制等关键机制的落地写法并演示了 socket 编程中 bind、sendto、recvfrom 等核心调用能够帮助读者直观理解如何基于不可靠信道构建可靠传输。配套实验报告还涉及测试与调试思路适合作为课程设计实现和报告撰写的参照。压缩包目录结构清晰便于按需查看源码或文档已有 627 人学习下载。1. 用UDP实现可靠传输期中大作业的题在问什么计网课里总有一道经典款大作业不让你用TCP却要你把TCP干的活——确认、重传、顺序、去重——全部用UDP自己实现一遍。第一次看到这题的人第一反应是UDP还能可靠其实UDP本身只保证尽力投递可靠是靠站在它上面的协议层做的期中大作业要的就是把这层可靠传输机制在应用层亲手搭出来。题目年份可以是2021也可以是今年核心考察点没变过序号怎么设计、确认怎么应答、超时怎么算、窗口开多大。适合刚学完TCP原理、又需要一个动手项目的同学也适合正在用UDP做实时音视频、需要自己控制可靠性的从业者。一句话这道题不是让你避开TCP而是把TCP最核心的传输机制拆下来放到UDP上重装一遍——先理解了这一点后面所有代码都不是玄学。2. 先搭协议骨架为UDP加上确认、超时、重传三件套2.1 为什么不用TCP可靠传输的自由度在哪里这类作业的第一个问号永远是TCP天生可靠为什么还要在UDP上重写一套可靠传输。答案分两层。对学生来说TCP的可靠逻辑算是内核里的黑匣子你看到的是序列号、确认号、重传定时器这些抽象概念背再多OSI模型也不如亲手写一遍。大作业要求你用代码把滑动窗口怎么推进、校验和怎么验、超时怎么触发这些概念变成行为证明你理解的不只是名词。对从业者来说TCP的拥塞控制、重传决策、队头阻塞是写死的你做实时音视频、游戏同步宁可丢一个不重要的包也不等全部重传怎么办传统方案是改造UDP后来的QUIC也是这个思路——用UDP承载把可靠性逻辑搬到应用层自己控制。所以这个作业练的不是一条命令而是一种我能决定可靠性行为的能力。顺带把TCP和UDP的区别说破TCP的可靠是内核给的黑匣子UDP把可靠的权利下放给你代价是出错没人管。可靠传输不能省只能自己写。动手之前先给自己定个纪律报文封装好之前先想清楚什么样的包值得发出去、什么样的包值得重传。2.2 最小报文格式序号、确认、标志位、校验和怎么排所有可靠传输协议的前提是有元数据编号、回执、状态。站在UDP数据报上再封装一层头部设计成10字节定长数据放后面偏移长度字段说明02seq发送序号按包计数22ack确认号期望收到的下一个序号41flags低4位ACK、FIN、RET、SYN51reserved保留全062checksum对整个报文做16位补码校验和seq和ack按包序号走不按字节计数。TCP按字节编号但期中大作业按包计数更直观接收方收到的确认号就是下一个想要包的序号不需要考虑偏移量。flags位里实际能用到的最多四个ACK表示确认报文、FIN表示结束、SYN表示握手的首包、RET表示这个包是重传包。RET位在调试重传计数时很有用后面验证章节会用到。校验和的计算范围是整个报文包括头部的seq、ack、flags、reserved。算校验和时checksum字段先置0算完再把结果填回去。接收方把收到的报文中checksum字段清0后重算一遍和报文里带的checksum对比一致就通过。这样接收方能自己验不需要依赖UDP/IP自带的校验。2.3 从发送到确认一次可靠传输的完整生命周期拿一个数据包完整走一遍可靠协议的最小闭环就清楚了。发送方把待发送数据封装成报文记录下本地时间send_time然后把包发出去接下来开始等确认。接收方收到报文后先做三个动作重算校验和、检查seq是否等于自己期望的序号、根据结果回ACK或丢弃。校验失败直接丢冒充新包的重复包也丢但重复包可以顺手回一个ACK——这是快速恢复的关键后文会展开。发送方收到ACK后检查ack值是否等于自己上一次发出包序号加1。相等则说明对方已经收好清除该包的定时器窗口前移一位不等说明有包丢了等RTO超时触发重传。如果ACK里的seq异常协议应该能识别这是迟到包还是错误包直接丢弃不处理也别更新RTT样本。一次成功传输至少消耗1个RTT发送耗时不起决定作用RTT才是瓶颈。这也是停等协议性能上不去的原因下一章展开。你可以把上述闭环理解成一发一收确认的最小区块所有可靠协议都是把这个区块放大成窗口而已。2.4 最小代码骨架本地跑通一发一收的Python示例先写出两个最基础的函数封包、解包。下面这套代码只做一件事但不做这套就什么都做不了。import socket import struct HEADER_SIZE 10 FLAG_ACK 0b0001 FLAG_FIN 0b0010 FLAG_RET 0b0100 def checksum(data: bytes) - int: if len(data) % 2: # 奇数长度补一个空字节 data b\x00 s 0 for i in range(0, len(data), 2): s (data[i] 8) data[i 1] s (s 0xFFFF) (s 16) # 回卷进位 return (~s) 0xFFFF def make_packet(seq: int, ack: int, flags: int, payload: bytes b) - bytes: body struct.pack(!HHBBH, seq, ack, flags, 0, 0) payload csum checksum(body) return struct.pack(!HHBBH, seq, ack, flags, 0, csum) payload def parse_packet(raw: bytes): if len(raw) HEADER_SIZE: raise ValueError(packet too short) seq, ack, flags, reserved, csum struct.unpack(!HHBBH, raw[:HEADER_SIZE]) payload raw[HEADER_SIZE:] body_check raw[:6] raw[8:] # 把checksum字段清零 if checksum(body_check) ! csum: raise ValueError(checksum mismatch) return seq, ack, flags, payload这里有几个参数值得交代。HEADER_SIZE是10正好对应一个短整型seq、一个短整型ack、两个单字节、一个短整型校验和。struct.pack里的!是网络字节序也就是大端序保证x86机器和ARM机器收到的字节序列一致。checksum里那行(s 0xFFFF) (s 16)处理的是进位回卷这是16位补码校验和的经典规矩不做这步对高速率下的大包会有漏检。收拾完封包再看发送端最小逻辑。注意socket要settimeout把等待变成超时可感知def send_one(udp_sock: socket.socket, addr, data: bytes, seq: int, rto: float) - bool: pkt make_packet(seq, 0, 0, data) udp_sock.sendto(pkt, addr) deadline time.time() rto while time.time() deadline: try: raw, _ udp_sock.recvfrom(4096) except socket.timeout: break seq_r, ack_r, flags, _ parse_packet(raw) if (flags FLAG_ACK) and ack_r (seq 1) % 65536: return True return Falsesettimeout的单位是秒rto初始给1.0就够跑通本地回环。ack_r seq 1表示对方想要的正好是该包之后的那一个序号这是ACK的判断标准。parack_r共有65536个模65536的运算在序号循环后会出现边界情况第4章会专门说。3. 从停等到滑动窗口三种ARQ协议的实现与加分选型3.1 停等协议最朴素的可靠传输RTT是性能上限停等协议是ARQ家族里最直白的实现发送方发一个包等确认确认到了再发下一个。确认没到、超时到了重发同一个包。它的性能模型一眼看穿假设一个包大小为L字节一个RTT就是发出到确认回来的时间那么最大吞吐量约等于L/RTT。RTT是30ms时1500字节一个包吞吐上限只有50KB/s左右——即便网卡打着千兆你也只能吃到这个数。所以停等协议只能用来打基础作业里如果实验环境是本地回环RTT往往小于1ms问题不大一旦丢包率上来等待的时间会成倍放大。停等协议的代码量最少核心逻辑不超过50行适合第一次跑通链路。它的状态也最少一个seq、一个timer、一个ack。但验收时如果老师给个10%丢包加50ms RTT的模拟环境停等协议的吞吐曲线会非常难看这也是很多同学分数上不去的原因。3.2 回退N协议GBN一次丢包窗口全部重来GBN引入发送窗口的概念发送方一次可以发N个包出去不用等第一个的确认发完再发第二个。接收方仍然只按序接收seq等于期望序号就收下并回ACK否则直接丢弃——哪怕它的序号是合法的超前到达包。这个丢弃超前包的行为是GBN的精髓也是它的致命伤。发送方窗口里第k个包丢了接收方从第k个之后收到的所有包都进不了接收队列只能触发发送方对整个窗口的某个超时。发送方在RTO超时后从丢包的位置开始把后面的包全部重发一遍。GBN的吞吐模型可以这么估算若窗口大小为W包RTT为T链路带宽为B则理论上吞吐不超过min(W * L / T, B)。丢包率p比较高时一次重传带来的代价是整个窗口的N个包有效吞吐会以约N/(N1)的系数衰减。RTT越大、窗口越大GBN在丢包环境下的退化越严重。代码上的关键状态是base和nextseqbase是已确认且最老的未确认包序号nextseq是下一个要发送的包序号。收到ACK推进base窗口满了就停止发新包、转去等ACK。这已经是标准实现比停等多了大约40行。3.3 选择重传协议SR只重传丢的那一个SR把丢弃超前包改成缓存超前包。接收方维护一个接收窗口不在窗口内的包丢弃在窗口内的包即便乱序也缓存下来等到缺失的包补齐再一口气把这一段连续数据递交给上层。发送方的变化更大每个包都要有自己的定时状态超时只重发单个包而不是回退整个窗口。这个每个包独立定时器的实现成本是最高的——在Python里能用字典记录next_time在C里你可能要为每个序号维护一个clock值。GBN只维护一个定时器SR要维护W个。SR的性能优势在丢包率高的长肥管道上非常明显10%丢包、窗口64包场景下GBN平均每次丢包要重传几十个包SR只重传一个差距是数量级的。期中大作业如果带宽不大、随机丢包率不超过10%SR的加分效果更多体现在思路和代码结构上不体现在分数绝对值上。3.4 选型建议作业范围、吞吐需求与代码量怎么权衡三种协议在考卷上是三个知识点在实现里是三个复杂度梯度。给一张实际选型表协议额外代码量接收端缓存高丢包表现加分点停等约50行无差吞吐线性下降实现正确、思路清晰GBN约100行无中回退N包浪费带宽写清楚窗口推进逻辑SR约200行需要窗口大小决定好只重传缺失包缓存管理 独立定时器我的建议是第一次实验全部基于停等跑通链路和校验然后按GBN交作业这是大作业最常见、最稳的平衡点。如果你打算挑战SR先明确接收窗口和发送窗口要一样大且接收方缓存区的序号判定必须严谨否则乱序包管理的bug会花掉比写整个协议更多的时间。无论选哪条路WR载入实验环境里就必须先测一遍确认类异常再谈性能。4. RTT估计与超时重传RTO、RTTVAR参数怎么调才不翻车4.1 RTT样本采集与Karn算法重传样本必须丢弃RTT测量很简单发出包时记录send_time收到对应ACK时取当前时间相减就得到一个RTT样本。难的是样本污染。当一个包超时重发了它的ACK可能是对第一次发送的响应也可能是对第二次发送的响应。你无法从ACK本身分辨如果把这个混血样本喂给RTT估计器RTO会被拉大或拉小后者更致命——RTO被拉小会导致大量不必要的重传网络里又增加重复包进一步恶化RTT估计形成正反馈风暴。解决方法是Karn算法重传过的包它对应的ACK不产生RTT样本。直到一个包没有经过重传就被确认才能更新RTT。这个规矩在实现里表现为给每个包标一个retransmitted标志确认到达时先查标志再决定是否更新RTT。4.2 超时计算RFC 6298的平滑公式与最小/最大RTO固定RTO是野外大作业最容易翻车的地方设小了频繁误判丢包设大了丢包后恢复极慢。要自适应直接搬RFC 6298的公式。先维护两个变量SRTT平滑后的RTT和RTTVARRTT的方差估计。拿到新样本RTT_sample后按下面顺序更新alpha 1 / 8 beta 1 / 4 if srtt is None: srtt rtt_sample rttvar rtt_sample / 2 else: rttvar (1 - beta) * rttvar beta * abs(srtt - rtt_sample) srtt (1 - alpha) * srtt alpha * rtt_sample rto srtt max(1, 4 * rttvar) # 1ms是对时钟精度的补偿注意必须先更新RTTVAR再更新SRTT因为RTTVAR用的是旧SRTT。RTO本义是大多数样本落在SRTT4σ以内这套参数就是TCP的默认参数作业里直接照抄即可。工程上再垫两个下限RTO最小200ms最大3000ms。这两个上下限值得抄进参数表里因为本地回环RTT只有0.1ms算出的RTO可能只有几毫秒稍微调度抖动一次就重传会误伤自己。RTO越大恢复越慢设3秒上限是保证丢包时能快速自愈的底线。4.3 定时器实现Python、C与C#各自的做法与坑Python里最常见做法是socket.settimeout加绝对时间戳deadline time.time() rto循环里等ACK超时后判断time.time() deadline再重传。这个方案简单可靠但对单线程来说重传期间不能做别的事吞吐上不去。C语言做UDP可靠传输通常用select()做超时接收把socket放进读集合select(fd, readfds, NULL, NULL, tv)返回0表示超时返回1表示有数据可读。tv里的timeval要每次循环重置因为select会改写剩余时间——这个坑让不少人第一次跑测就卡在重传风暴里。C#的UDP编程里最常见的坑是read udp: unknown error (code10054)。当UDP包发到一个未监听的端口Windows会回送ICMP端口不可达recvfrom/Receive抛出WSAECONNRESET异常。这个错误不是协议错误而是ICMP反馈处理方式是捕获后忽略并继续别把它当业务失败处理。这也是下一章平台差异坑第一条的来源。4.4 一期大作业的参数表从初始RTO到socket缓冲给自己一张参数表所有值写死在配置类里不要散落在代码各处参数名建议值说明初始RTO1000ms第一次发送前还不知道RTT最小RTO200ms防止本地回环误判最大RTO3000ms丢包时恢复速度的下限RTT平滑系数alpha1/8RFC 6298标准值RTT方差系数beta1/4RFC 6298标准值窗口大小16~64包超过序号空间一半会出问题重传次数上限5~8次超限直接断开别无限重传socket缓冲区2MBSO_RCVBUF/SO_SNDBUF单包数据上限1400字节留出IP和UDP头避免IP分片窗口大小有个硬限制不能超过序号空间的一半。16位序号空间是65536窗口超过32768时接收方在窗口边界无法区分新包和重传包。作业里窗口给到64包足够给32768是自找麻烦。socket缓冲区调大是为了抗住突发流量默认的64KB在本地回环的高速率下很容易丢包。5. UDP可靠传输踩坑现场丢包风暴、序号溢出与平台差异5.1 现象丢包一高吞吐归零超时重传像发了疯丢包率5%时协议还稳调到20%吞吐直接断崖抓包看到一堆重复传输的包在网里反复横跳日志里全是重传记录。原因是RTT估计被反复重传污染。一个包重传三次后它的ACK终于回来了你拿这个ACK更新RTTRTT瞬间变成重传时间×nRTO被推到最大值下一个包真的丢了又要等最大RTO才重传恢复奇慢吞吐自然趋零。解决分两步。第一步严格应用Karn算法重传过的包一律不产生RTT样本。第二步每次重传之后RTO翻倍直到收到一个干净确认再把RTO恢复成SRTT 4*RTTVAR。翻倍是TCP的指数退避思想防止重传风暴把自己打死。5.2 现象Windows上跑着挂着、Linux上一跑就崩还冒10054同一套代码在Windows机房能跑通拿回宿舍Linux上跑recvfrom开始报错怀疑人生。原因在平台差异。Windows的UDP socket收到ICMP端口不可达时会把错误反馈到最近的recvfrom调用表现就是C#的unknown error code10054或WinSock的WSAECONNRESET。Linux默认不做这个反馈UDP发到没监听的端口时发送方根本不知道。你的代码如果没处理这个异常Windows上会被异常打断Linux上则安静地丢包——两边表现完全不同。解决是在捕获异常时判断如果是10054/ECONNRESET说明对端端口不可达忽略它继续循环。同时统一把缓冲区和超时策略按上一章的参数表固定了别指望平台默认值替你兜底。5.3 现象序号循环到0接收方把新包当成旧包的确认跑了一个小时后逻辑突然错乱接收方开始丢包且日志显示收到大量旧序号的确认但实际网络是健康的。原因是16位序号循环复用。当接收窗口停在59999等待重传时发送方窗口已经推进到5下一个新包的序号恰好绕回59999接收方会认为这是早该到的旧包直接按重复包丢弃。窗口越大这种碰撞概率越高。解决是约束窗口大小窗口必须小于序号空间的一半即窗口上限32767。作业环境窗口给16~64包远低于上限但如果你为了性能把窗口调到几千包就必须正视这个边角问题。TCP里也有一模一样的检查逻辑作业里把它写在窗口滑动函数的前置条件里顺便能拿个加分点。5.4 现象CPU占用跑到100%单线程轮询在空转程序运行时CPU风扇狂转系统监视器里单核100%但吞吐量上不去。原因是主循环写成了while True: recvfrom()没有数据时recvfrom立即返回或抛异常程序在循环里不断空转白白烧CPU。这在UDP网络调试里非常常见测试工具如果没做阻塞控制很容易把本机跑满。解决是给socket设置超时后把接收改成有数据才处理的模式。Python里把settimeout(0.001)之后在循环里加一个短sleep或改用select/epoll等可读事件。C语言用select()天然就是事件驱动别在起跑线上把性能扔了。5.5 现象校验和一直失败原因在字节序实验环境的本地回环数据明明没丢但接收方反复报checksum mismatch。原因翻到最后发现是字节序问题。发送方在x86小端机上用struct.pack(!H, seq)封装接收方如果按照小端解包seq和ack全被倒过来校验和自然对不上。另一个常见原因是把校验和的范围算错把calc出来的checksum又当成输入的一部分重新算了一遍。解决是统一用网络字节序解包解析时一律struct.unpack(!HHBBH, ...)不要手写位移去拼字节。校验范围约定为除checksum字段外的整个报文发送接收共用同一个check函数不要各写一遍。本地先跑一万包的单测校验错误率必须为0。6. 本地验证与进阶用丢包模拟把可靠率从0.99拉到0.99996.1 先用netem把10%丢包砸上去协议写完第一步不是连真实网络而是在本地回环上人为制造丢包。Linux用tc的netem插件就能做到sudo tc qdisc add dev lo root netem loss 10% iperf3 -u -c 127.0.0.1 -b 10M -t 10 sudo tc qdisc del dev lo rootnetem加在lo口上所有走回环的UDP流量都会按10%随机丢包。丢了再删别留着影响其它实验。打流用iperf3的UDP模式-b 10M是目标带宽-t 10是打10秒。这会让你直观看到裸UDP的丢包率再切到你的可靠传输实现对比同一丢包率下有效吞吐的差别。这个环节建议跑三组丢包率1%、5%、10%。记录每个梯度下的重传计数和净吞吐就能量化你的协议质量。如果能做到10%丢包下净吞吐不低于无丢包时的70%期中大作业的验收分基本稳了。6.2 用iperf3做基线对比算清可靠传输的代价有了可靠协议后把相同传输打一遍对比裸UDP打流和你自己的协议可以看到可靠的代价大概是多少。代码里加一个调试开关输出总发送包数、重传包数、确认包数。有了这三个数可靠率就能动手算。可靠率 (总发送包数 - 重传包数) / 总发送包数。这个值在丢包率10%时能稳定在0.99以上说明协议是有效的。如果掉到0.99以下先怀疑超时参数——RTO太小重传和ACK互相碰撞会把可靠率拖下悬崖。6.3 进阶加分项加入快速重传与AIMD拥塞控制超时重传是兜底但一个超时周期往往几百毫秒高峰期丢一个包就要卡一下。进阶做法是快速重传连续收到3个重复ACK不等超时立刻重传缺失包。GBN里收到重复ACK时直接把窗口回溯到ACK指定的位置SR里把这个序号单独重发。拥塞控制可以简单实现AIMD慢启动阶段每收到一个ACK把窗口加1到达阈值后每RTT窗口加1检测到丢包后窗口减半。代码量不超过30行却能把10%丢包下的吞吐再拉高一截。当年我交作业时在10%丢包环境里测出了一地重传后来补上快速重传才算真正过了瘾。从那以后我养成了一个习惯只要是跑网络相关的程序先造一个loss环境再上线。希望这个流程对你有用。本文还有配套的精品资源点击获取