简介面向计算机网络课程学生的 C 课程设计资源针对 2021 年中山大学计网期中大作业完整演示如何基于 UDP 实现可靠传输。资料包共 4 个文件压缩后约 466KB包含两个 C 源文件客户端与服务端主程序覆盖套接字编程、发送与接收线程、一份实验报告 PDF 以及 README 说明文档。已有 627 人学习浏览。内容涵盖 ARQ 重传策略、序列号与确认、校验和错误检测、滑动窗口流量控制等关键实现思路报告结合源码可帮助理解 UDP 不可靠性如何被弥补并快速完成运行、调试或二次改造。适合正在做计网课设、需要可靠 UDP 编程参考的同学。1. 用UDP实现可靠传输这份计网期中作业为什么值得你复现用UDP实现可靠传输听起来像是一个伪命题。UDP无连接、不保证送达、不保证顺序连校验和出错都只是静默丢弃但许多实时场景里TCP的重传拥塞机制带来的延迟又让人无法忍受。中山大学2021年计网期中大作业就是这样一个选题要求学生在C里基于UDP套接字自己实现序列号、确认、重传、窗口和缓存排序最终交付一个能在丢包、乱序、重复包环境下正常工作的可靠传输程序。它不只是一份课程作业更是一份把“TCP和UDP的区别”从概念变成代码的实战标本。适合正在学计网、准备面试传输层原理、或者想给UDP业务加上轻量可靠性的人照着抄、拆、改。资源包里是两份成品C源码Server_final.cpp、Client_final.cpp、一份实验报告PDF和README代码量不大但覆盖了从套接字收发到高可靠重传策略的完整链路。接下来我会把它拆成可执行的步骤讲清参数怎么设、坑在哪。2. 可靠传输的四个支柱序列号、确认、重传与窗口2.1 为什么UDP不可靠还要用它UDP报文头只有源端口、目的端口、长度和校验和四个字段发送方sendto之后就不管了接收方是否收到、是否按序、是否重复协议栈一概不负责。对比TCP的确认号、序号、窗口、重传计时器、拥塞控制状态机UDP简直像裸奔。但正因为没有这些机制UDP头部开销只有8字节还能做到发送后立即处理下一条数据端到端延迟在局域网里往往比TCP低一个数量级。在线游戏、VoIP、视频会议、金融行情推送宁可丢一帧也不能等重传的下一帧。可靠传输作业的难点在于你不能修改内核协议栈只能在应用层“抄袭”TCP的核心机制。把UDP当成一条不可靠的管道在其上自己定义数据包格式、自己维护状态、自己决定何时重传。这就逼着你把TCP的RFC机理亲手实现一遍而不是背个三次握手就完事。2.2 序列号与确认机制从停等ARQ开始设计可靠传输的第一步是给每个数据包编号。常见做法是发送方维护一个全局递增的seq接收方维护一个期望收到的期望序号exp收到包后如果seq exp则向上层交付并回复一个携带exp1的ACK如果seq ! exp则说明乱序或丢包接收方缓存该包并回复“我期望的还是exp”让发送方知道缺口在哪。最简单的策略是停等ARQ发一个包等ACK收到再发下一个。这个策略逻辑最简单但吞吐被RTT卡死——局域网里还好一旦RTT超过10ms有效带宽就低得可怜。所以作业里一般要求实现滑动窗口也就是GBN或选择重传。从Server_final.cpp和Client_final.cpp的命名看最终版本大概率是GBN或选择重传下面我会按选择重传SR的思路拆解因为它在乱序环境下效率最高也最容易在实验报告里写出性能对比。ACK包本身也可能丢所以ACK也要携带确认序号同时发送方不能只依赖ACK它必须有一个定时器。超时没收到ACK就把窗口内所有未确认的包重发GBN是全部重发SR只重发那个超时的包。这份作业里我建议你至少实现SR因为乱序缓存和选择性重传才是区分度所在。2.3 滑动窗口与流量控制窗口大小怎么定窗口机制的背后是流量控制接收方告诉发送方“我还能收多少”避免发送方把接收方缓冲区冲爆。在UDP可靠传输里窗口大小通常写死在配置里比如16或32。如果每次收到ACK就移动窗口那么发送方可发送的最大未确认包数就等于窗口大小网络中在途的数据量最大就是“窗口 × 包长度 ÷ RTT”这就是能跑出的理论吞吐。窗口大小不是越大越好。窗口太大会导致接收方缓冲区溢出丢包率上升重传风暴窗口太小则管道利用率不足。常见做法是先设成8测一轮观察重传率如果吞吐上不去但重传率低于1%就加到16或32。实验报告里务必记录不同窗口下的吞吐和重传率这是拿分点。另一个容易忽略的参数是超时时间RTO。RTO太短没丢包也重传制造大量重复包RTO太长真丢包时恢复慢。最简单的动态策略是每收到一个ACK计算当前RTTRTO 1.3 × 平均RTT。作业里通常简化成固定值比如200ms但不推荐——因为一旦路由器瞬移阻塞固定RTO在100ms以内的局域网还行跨网段就翻车。2.4 代码骨架Server_final.cpp里的状态机先看数据包格式这是整套机制的契约。常见定义如下#pragma pack(push, 1) struct Packet { uint16_t type; // 0DATA, 1ACK uint32_t seq; // 数据序号对ACK来说是确认序号 uint16_t len; // 数据长度0表示纯ACK uint16_t checksum; // 校验和简单起见可以算所有字节 char data[1400]; // 实际负载 }; #pragma pack(pop)注意#pragma pack(push, 1)是让结构体按1字节对齐避免编译器在字段间插入padding否则发送方和接收方看到的结构体长度不一致recvfrom返回的字节数对不上。seq类型用uint32_t避免序号回绕后比较出错。data长度定1400是因为UDP负载上限约65507字节但MTU通常1500IP头UDP头占28字节所以应用层每个包别超过1472留出网络层分片余量。Server_final.cpp里的主状态机大概是这样的循环while (true) { fd_set readSet; FD_ZERO(readSet); FD_SET(serverSock, readSet); timeval timeout {0, 200000}; // 200ms int ret select(serverSock 1, readSet, NULL, NULL, timeout); if (ret 0 FD_ISSET(serverSock, readSet)) { recvfrom(sock, pkt, sizeof(pkt), 0, ...); if (pkt.type ACK) { // 更新对端接收窗口推进发送窗口 } else if (pkt.type DATA) { // 查序号决定交付、缓存还是丢弃 // 回复ACK } } else if (ret 0) { // 超时扫描未确认包重传超时的 } }select在这里的作用是把无限期阻塞的recvfrom变成可超时的阻塞。ret0表示超时这时扫描重传队列ret0表示有包可读读完后立刻回ACK。这种单线程事件循环比双线程收发更安全不用处理共享锁。超时时间200ms可以根据局域网实测微调。3. 把理论落到C套接字核心收发流程与参数详解3.1 套接字初始化bind、sendto、recvfrom的正确姿势服务端和客户端的初始化基本一致关键在于UDP套接字没有连接的概念sendto时必须指定对端地址recvfrom时要知道来源地址才能回包。下面是服务端初始化代码#include winsock2.h // Windows下 #pragma comment(lib, ws2_32.lib) // Linux下用 sys/socket.h, netinet/in.h, arpa/inet.h int serverSock socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); sockaddr_in serverAddr; memset(serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family AF_INET; serverAddr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 serverAddr.sin_port htons(8888); if (bind(serverSock, (sockaddr*)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { std::cerr bind failed: WSAGetLastError() std::endl; return -1; }socket()第一个参数AF_INET表示IPv4第二个参数SOCK_DGRAM就是UDP数据报套接字第三个参数IPPROTO_UDP明确使用UDP协议。如果写成SOCK_STREAM那就变成TCP了作业里最常见的低级错误。bind之前要把sockaddr_in清零否则里面的sin_zero残留垃圾字节会导致bind失败。端口号用htons转成网络字节序发送方和接收方如果都在本机测试端口可以一样但要确保没有被系统占用。发送数据的核心是sendto接收方必须带着来源地址回ACKsockaddr_in clientAddr; socklen_t addrLen sizeof(clientAddr); int bytes recvfrom(serverSock, buffer, sizeof(buffer), 0, (sockaddr*)clientAddr, addrLen); // 回ACK给这个来源 ACK ack; ack.type htons(1); ack.seq htonl(expectSeq); sendto(serverSock, (char*)ack, sizeof(ack), 0, (sockaddr*)clientAddr, addrLen);recvfrom的第五、第六个参数一定要初始化尤其是addrLen如果传入的是一个更大的值某些平台会返回截断错误。收到数据后处理完马上用同一个clientAddr回ACK不要等到下个循环再回否则对端超时重传网络里就会充满重复包。这个“先处理再回ACK”的顺序看起来无关紧要但在高丢包环境下延迟一个微循环都可能导致重传风暴。3.2 超时重传的实现select与clock_gettime的配合UDP的recvfrom默认是阻塞的没有超时概念所以必须用select或者setsockopt的SO_RCVTIMEO来模拟超时。我推荐select因为select能够在同一个循环里同时监听多个套接字方便你把控制通道和数据通道合在一起。下面是用select实现200ms超时的发送循环片段// 假设baseTime是上一次发送时间rtoMs是重传超时 while (unackCount 0) { fd_set readSet; FD_ZERO(readSet); FD_SET(sock, readSet); // 计算本次select还需要等多久 int waitMs rtoMs - (nowMs() - sentTime); timeval tv {waitMs / 1000, (waitMs % 1000) * 1000}; int active select(sock 1, readSet, NULL, NULL, tv); if (active 0) { recvfrom(sock, ackBuf, sizeof(ackBuf), 0, ...); // 如果是ACK且序号落在窗口内标记该包已确认 } else if (active 0) { // select超时说明对端没回ACK重传最早未确认的包 sendto(sock, sendBuf[oldestSeq], ...); } }这里的nowMs()可以用clock_gettime(CLOCK_MONOTONIC)实现注意不要用time(nullptr)系统时间被手动修改会导致RTO计算错乱CLOCK_MONOTONIC不受系统时间跳变影响。select的timeout参数每次循环都要重置因为它返回后会变成0这是C网络编程里经典坑。重传次数必须有限制。常见做法是设置最大重传次数比如8次超过后给上层报错“对端不可达”。如果不设上限UDP丢包后程序会无限重传端口一直被占用进程无法退出作业演示时就会卡死在那里。3.3 乱序缓存与排序用map还是vector接收方收到乱序包时不能立刻给上层必须缓存到期望序号那个包到达后再按序交付。这个缓存容器我建议用std::mapuint32_t, Packetkey是seq内部是红黑树插入和查找都是O(log n)窗口一般16或32性能足够。std::mapuint32_t, char cache; // seq - data[]简单起见 void deliverOrdered() { while (cache.count(expectSeq)) { // 把cache[expectSeq]交付给上层应用 writeToApp(cache[expectSeq].data, cache[expectSeq].len); cache.erase(expectSeq); expectSeq; } }注意expectSeq溢出问题。seq是uint32_t当到达0xFFFFFFFF后再加1会回绕到0。不要用比较序号要用减法判断bool isNewer(uint32_t a, uint32_t b) { return (int32_t)(a - b) 0; }这是TCP序列号比较的标准做法利用有符号整数的溢出特性只要序号差值不超过2^31比较结果就正确。作业数据量很小一般不会回绕但代码里写上这个函数实验报告里就能多写一条“考虑了回绕场景”面试也常问。3.4 实验报告里的性能数据怎么看实验报告PDF里最值得看的是吞吐量和重传率这两张表。正常结果应该是丢包率0%时吞吐接近网卡上限丢包率5%时选择重传比GBN吞吐高一倍左右丢包率超过20%时两者都明显下降但选择重传的延迟抖动更小。如果报告里出现“丢包率越高吞吐反而越高”那多半是测试方法有问题比如用sendto成功返回次数当吞吐UDP根本不保证对端收到sendto返回成功只代表数据进了内核缓冲区这不叫发送成功。另一组关键数据是RTO调优。固定RTO200ms时在RTT1ms的局域网里每次丢包要干等200ms重传吞吐极低。报告里如果写了RTO自适应调整比如用指数加权移动平均估算RTT就是加分项。你可以验证发送方在收到ACK时记录时间差rtt 0.8 * oldRtt 0.2 * newestRtt再设rto 1.3 * rtt通常比固定值稳得多。4. 计网期中作业避坑指南丢包、乱序与重复包的现场排查4.1 现象接收方一直收不到数据但wireshark显示包已经发出用wireshark在接收方那台机器上抓包能看到UDP报文从发送方到达了本机网卡但应用程序就是没打印任何内容。原因往往不是网卡丢包而是接收方套接字没有绑定发送方实际使用的端口。sendto时如果对方绑定了0.0.0.0:8888而发送方却把包发到了127.0.0.1:9999抓包记录在环回接口上能看到应用却收不到。解决检查bind的端口和客户端sendto的目标端口是否一致检查是否有防火墙拦截Windows下需要允许程序通过防火墙的入站规则检查recvfrom的buffer是否小于UDP包长度UDP是数据报协议如果recvfrom的缓冲区小于待接收数据报多余部分会被协议栈丢弃而且不会像TCP那样流式读取。4.2 现象ACK丢失导致发送方超时重传接收方重复写入文件这是可靠传输作业最典型的“重复包”问题。发送方因为没等到ACK超时重传了同一个seq接收方这个seq之前已经交付过了但它不知道怎么识别重复于是把同一段数据又写进输出文件文件里出现重复字节。解决接收方必须维护一个“已交付序号”的集合或者记录最大连续交付序号。收到一个DATA包时先判断它的seq是否小于期望的序号如果是说明是重复包不要写入但要重新回复ACK——因为对端重传说明它没收到之前的ACK。注意重复包的ACK也要回否则对端会一直重传这个旧包导致后续新包被阻塞。4.3 现象窗口设到32后吞吐反而从40Mbps掉到15Mbps窗口越打越大在途数据变多接收方缓冲区很快被占满后续包无处存放只能丢弃触发大量重传。而且每次丢包不是单包而是窗口内多个包同时超时重传风暴导致网络拥塞吞吐断崖式下跌。解决把窗口调回16并把接收方的缓存容量按“窗口 × 1.5”设置留出冗余。更本质的做法是实现TCP的拥塞避免维护一个拥塞窗口cwnd初始为1每个RTT没丢包就线性加1丢包就减半。作业里不强制但报告里写了就能展示你理解了问题本质。4.4 现象用std::thread同时跑发送线程和接收线程程序崩溃或ACK丢失常见做法是发送方一个线程send接收方一个线程recv两个线程同时操作同一个sockfd逻辑看起来很清晰。但UDP套接字是全双工的多个线程同时read或write同一个fd没问题问题出在你想要“共享窗口状态”——发送线程更新窗口接收线程处理ACK如果这两块状态没有加锁数据竞争会让窗口序号乱跳严重时直接SEGFAULT。解决要么用select单线程事件循环要么给窗口结构加std::mutex。作业规模没必要用双线程单线程select完全够用还能避免一堆锁问题。除非你要做演示版的双向同时传输才考虑多线程那时务必给每个socket分配单独的互斥锁。5. 怎么验证你的可靠传输真的可靠iperf3、netcat与自测脚本5.1 用iperf3模拟UDP丢包环境iperf3本身只能测UDP吞吐和丢包率不能模拟丢包。要在本机模拟网络损伤Linux下用tc netemWindows下可以用Clumsy或Network Emulator。常见做法是在发送方网卡上加上丢包规则# 添加10%丢包10ms延迟0.1%重排序 sudo tc qdisc add dev eth0 root netem loss 10% delay 10ms reorder 0.1% # 跑iperf3 UDP打流5秒目标端口5201 iperf3 -u -c 127.0.0.1 -p 5201 -b 50M -l 1400 -t 5-b 50M表示目标比特率50Mbps-l 1400表示每个UDP包负载1400字节这和你代码里定义的Packet::data大小接近。看输出里的“Lost/Total Datagrams”和“Jitter”这就是不用你自己的程序、先单独验证网络环境损伤程度的基准。然后再跑你自己的Server_final和Client_final在同样丢包规则下对比吞吐和重传次数。注意tc规则要记得删掉sudo tc qdisc del dev eth0 root忘了删的话后面所有操作都会莫名其妙丢包。5.2 用netcat构造乱序和重复包场景netcat适合做最简单的连通性验证比如先启动你的服务端再用nc随便发一串字节看服务端是否会回ACK。要构造乱序可以写一个小脚本把待发送的包编号打乱后再sendto这里给一个Python片段模拟乱序发送import socket, random s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) packets [(i, bdata_%03d % i) for i in range(20)] random.shuffle(packets) # 打乱顺序 for seq, data in packets: # 构造符合你协议的包类型seq数据 pkt seq.to_bytes(4, big) data s.sendto(pkt, (127.0.0.1, 8888))这个脚本不依赖你的C代码它直接往你的服务端端口灌乱序UDP包用于验证接收方的乱序缓存是否生效。注意seq必须是网络字节序且包格式要和你Server_final.cpp里定义的完全一致否则接收方解析失败会当成校验错误丢弃。5.3 一个简单的自测脚本统计重传率与吞吐在发送方代码里加两个计数器sentCount和retransCount。每调用一次sendtosentCount每次因超时重传retransCount。传输结束后打印总发送包数: 1000 重传包数: 137 重传率: 13.7% 有效吞吐: 42.3 Mbps手动确认输出文件和原始文件是否一致用md5sum或cmpcmp original.bin received.bin echo 完全一致不要只看文件大小同一段数据重复写入会导致大小不同如果大小相同但内容错位cmp能检查出来。建议传输一个20MB左右、内容随机的二进制文件这样比纯文本更能暴露校验和与重传策略的问题。如果你发现重传率比tc设置的丢包率高那就说明你的RTO设置偏短有大量没必要的重传如果重传率接近丢包率但文件依然损坏说明校验和或缓存逻辑有bug。6. 从作业到工程把UDP可靠传输改造成通用模块的技巧6.1 用状态机驱动收发而不是两个独立线程作业里最常见的实现是“发送线程接收线程”但工程上要处理双向收发、同时维护收发两个窗口时双线程加锁很容易把逻辑搅浑。我在重构这个项目时会把收发统一成一个ReliableChannel::tick()方法每次调用做三件事发送窗口内新数据、尝试读取一次套接字并处理ACK/数据、检查是否超时重传。这样整个通道变成一个可被上层驱动的状态机不管在单线程循环里还是在epoll事件回调里都能嵌入也不用担心锁问题。class ReliableChannel { public: void tick() { sendWindowData(); recvAndProcess(); checkTimeoutRetransmit(); } private: uint32_t sendBase, sendNext, recvExpect; std::mapuint32_t, Packet recvCache; std::mapuint32_t, steady_clock::time_point sendTime; };这个类设计成不持有套接字而是通过回调函数收发包方便你把它嵌套进更大的网络框架。作业验收只看功能但如果你将来要把它用到游戏同步或音视频传输状态机模式才是能长期维护的形态。6.2 序列号比较和窗口边界永远是核心风险点使用(int32_t)(a - b) 0判断序号新旧而不是直接比较大小。这一点在窗口边界处理上尤其重要窗口内的包序号允许回绕如果你用if (seq sendBase)判断是否在窗口内当sendBase回绕到接近UINT32_MAX时所有后续包都会被误判为过期。正确做法是判断isNewer(seq, sendBase) !isNewer(seq, sendBase windowSize)sendBase windowSize也要用uint32_t无符号加法自动回绕。6.3 不要把超时重传做死要留出活的RTO我从这个作业里学到的最大教训是固定RTO的可靠传输只有在实验室里才可靠。真实网络里RTT会在50ms到500ms之间波动固定200ms的RTO要么造成大量无谓重传要么在Wi-Fi抖动时恢复极慢。后来我每次重构这套代码都会强制走一遍RTO自适应流程每收到一个ACK就更新RTT估算rtt 0.875 * rtt 0.125 * sampleRttRFC 6298的风格再让rto max(100ms, 1.3 * rtt)。验证时故意用tc加一次100ms抖动观察重传率是否明显下降。从那以后我每次写可靠性协议都会先验证“在抖动下重传次数是否平稳”否则再漂亮的状态机也只是黑匣子。希望这份作业的拆解思路能帮你在复现时少踩几个坑把报告写成老师愿意给高分的样子。本文还有配套的精品资源点击获取