计算机网络实验三:基于UDP实现可靠传输协议与套接字编程实战
1. 从一个经典实验说起为什么这个实验值得反复做如果你正在读计算机相关专业大概率会在某个学期遇到一门叫做“计算机网络”的课而这门课里通常藏着一个让人又爱又恨的环节——实验课。今天要聊的这个项目就是某高校计算机网络课程中的第三个实验。别小看这个“实验三”它往往是从理论走向实战的分水岭前两个实验可能还在让你抓包看报文、配配IP地址到了第三个实验通常就要你真正动手实现一个能跑起来的网络程序了。这个实验的核心价值在于它逼着你把课堂上那些抽象的概念——套接字、三次握手、滑动窗口、超时重传——变成一行行能编译、能运行、能抓包验证的代码。适合谁来参考如果你是正在上这门课的学生这篇内容能帮你理清思路、避开我当年踩过的坑如果你已经工作但想回头补补网络编程基础这个实验也是一个非常好的练手项目因为它足够小、足够聚焦但又覆盖了网络编程最核心的几个环节。我当年做这个实验的时候前前后后折腾了将近一周中间经历了“代码能跑但抓包不对”“本地测试通过但换台机器就崩”“超时重传逻辑写反了导致死循环”等一系列经典问题。下面我就把这个实验的完整思路、关键细节、实操步骤和避坑经验从头到尾捋一遍。2. 实验整体设计与思路拆解2.1 这个实验到底要你做什么虽然不同年份、不同教学班的具体要求可能有细微差异但“计算机网络实验三”在绝大多数高校的课程体系里核心任务都是基于套接字编程实现一个可靠的传输协议或者实现一个简单的客户端-服务器通信系统。常见的形式包括实现一个停等协议Stop-and-Wait的文件传输程序实现一个滑动窗口协议Sliding Window的可靠数据传输实现一个简单的HTTP客户端或服务器实现一个支持多客户端的聊天服务器从“实验三”这个序号来看它通常排在“抓包分析”和“基础套接字编程”之后所以难度会明显上一个台阶。你不再只是调用几个API就完事而是需要自己设计报文格式、处理超时、管理状态机。2.2 为什么选择UDP而不是TCP这是很多同学第一个想不通的地方明明TCP已经提供了可靠传输为什么还要用UDP自己实现一套答案很简单这个实验的目的就是让你理解TCP为什么这么设计。如果你直接用TCP的Socket那三次握手、超时重传、流量控制全部由操作系统内核帮你做了你什么都学不到。用UDP作为底层承载相当于把TCP的实现细节暴露给你让你亲手处理丢包、乱序、重复包这些问题。我当时的实验要求是这样的客户端读取一个本地文件通过UDP发送给服务器服务器接收后写入本地文件要求实现超时重传、确认应答ACK、序号机制并且能够处理丢包和乱序场景。听起来不难但真正写起来状态机的管理会让你头皮发麻。2.3 报文格式的设计取舍设计报文格式是整个实验的第一步也是最关键的一步。你需要决定头部放哪些字段每个字段占多少字节我当时的方案是这样的字段名长度字节说明序号seq4数据包的序号从0开始递增确认号ack4期望收到的下一个序号标志位flags1区分数据包、ACK包、FIN包数据长度len2当前包中有效数据的字节数数据data可变实际载荷最大1024字节这个设计参考了TCP头部的精简版。为什么序号用4字节因为文件可能很大2字节最多只能表示65535个包按每个包1024字节算只能传64MB不够用。为什么数据长度用2字节因为单个UDP包不宜太大1024字节的载荷加上8字节头部总共1032字节远小于MTU不会触发IP分片。提示头部字段的设计没有绝对的对错但一定要保证收发双方约定一致。建议把头部定义成一个结构体用struct.pack和struct.unpack来序列化和反序列化这样不容易出错。2.4 状态机的设计思路停等协议的状态机其实很简单但如果你没想清楚就动手写很容易写出“死循环”或者“卡死”的代码。发送端的状态可以简化为等待上层数据从文件读取一块数据封装成包发送出去启动定时器等待ACK收到正确的ACK滑动窗口回到上一个状态超时则重传重新启动定时器收到重复ACK忽略继续等待接收端的状态更简单等待数据包收到序号正确的包写入文件回复ACK序号加一收到序号错误的包丢弃但重新回复上一次的ACK这一步很关键否则发送端会一直超时我当年就是漏了“收到错误序号时重发上一次ACK”这一步导致发送端超时后重传接收端还是丢弃陷入死循环。后来抓包一看发现接收端在收到乱序包时一声不吭发送端只能傻等。3. 核心细节解析与实操要点3.1 超时定时器的实现方式超时重传是可靠传输的灵魂但定时器怎么实现里面有不少讲究。最简单的做法是用socket.settimeout()在每次发送后设置一个超时时间然后调用recvfrom等待ACK。如果超时抛出异常就重传。这种方式的优点是代码简单缺点是定时器精度受限于系统调度而且每次重传都要重新设置超时。另一种做法是用多线程或select/poll来实现独立的定时器线程。这种方式更接近真实协议栈的实现但代码复杂度会高不少。我当时的实验环境是Python我选择了第一种方案因为实验的重点是理解协议逻辑而不是实现一个工业级的定时器。但这里有一个坑超时时间设多少合适设得太短网络稍微抖动就触发重传造成不必要的网络负载设得太长丢包后恢复慢传输效率低。我一开始设了0.1秒结果在实验室的WiFi环境下频繁重传后来改成0.5秒就稳定多了。如果你是在本地回环地址测试0.1秒完全够用但如果跨机器建议至少0.3秒起步。注意超时时间不要设成固定值就完事。真实的TCP会根据RTT动态调整超时时间这个实验虽然不要求你做自适应但你可以手动测一下RTT然后设一个合理的值。3.2 序号回绕问题序号用4字节表示范围是0到2^32-1。理论上如果你传的文件超过4GB序号就会回绕。虽然实验用的文件通常不会这么大但如果你想把代码写得健壮一点就需要考虑回绕后的比较问题。比较两个序号的大小时不能直接用和而应该用模运算def seq_lt(a, b): return ((a - b) 0xFFFFFFFF) 0x7FFFFFFF这个函数的逻辑是如果a和b的差值在模2^32的意义下大于2^31则认为a小于b。这是TCP协议栈里标准的序号比较方法。当然如果你的实验文件只有几MB这个问题不会暴露出来。但如果你在实验报告里提到这一点老师大概率会给你加分因为这说明你真正理解了序号的本质。3.3 文件读写与分块策略发送端需要把文件分块读取每块封装成一个包。这里有几个细节块大小选择我选的是1024字节。为什么不是512或2048因为1024是2的幂方便计算而且加上头部后不会超过MTU。如果你选4096加上IP头和UDP头总长度可能超过1500字节触发IP分片增加丢包概率。最后一块的处理文件大小不一定能被1024整除最后一块可能不足1024字节。你需要在头部里记录实际数据长度接收端根据这个长度写入文件。文件结束标志发送完所有数据后需要发送一个FIN包通知接收端。接收端收到FIN后关闭文件回复一个FIN-ACK。我当时的做法是在头部用一个标志位区分DATA、ACK、FIN三种类型。FIN包不带数据只带序号。接收端收到FIN后把已接收的数据flush到磁盘然后回复ACK。3.4 接收端的去重与排序虽然停等协议理论上不会出现乱序因为发送端发一个包必须等到ACK才能发下一个但如果你在实现滑动窗口乱序就是常态。即使是停等协议如果ACK丢失导致发送端重传接收端也可能收到重复包。处理重复包的逻辑很简单如果收到的序号等于期望序号正常处理如果小于期望序号说明是重复包丢弃但重发ACK如果大于期望序号说明中间有包丢了丢弃并重发上一次的ACK。这里有一个容易忽略的点接收端在回复ACK时ack号应该是期望收到的下一个序号而不是刚刚收到的序号。我当年就是这里写错了导致发送端一直以为包没到疯狂重传。4. 实操过程与核心环节实现4.1 环境准备与基础套接字搭建先确认你的开发环境。Python的话标准库里的socket和struct就够用了不需要额外安装第三方包。如果你用C或Java也是类似的标准库支持。服务端的初始化代码大概长这样import socket server_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_sock.bind((0.0.0.0, 9999)) print(Server listening on port 9999)客户端import socket client_sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_addr (127.0.0.1, 9999)注意UDP的bind和TCP不太一样。客户端其实也可以不bind操作系统会自动分配一个临时端口。但如果你想让服务端能主动给客户端发消息客户端最好也bind一个固定端口。4.2 报文封装与解析的实现用struct模块来打包和解包头部import struct HEADER_FORMAT !IIBH HEADER_SIZE struct.calcsize(HEADER_FORMAT) def pack_header(seq, ack, flags, length): return struct.pack(HEADER_FORMAT, seq, ack, flags, length) def unpack_header(data): return struct.unpack(HEADER_FORMAT, data[:HEADER_SIZE])这里的!表示网络字节序大端这一点非常重要。如果你的发送端和接收端字节序不一致解析出来的序号会完全乱套。我当年在x86机器上测试没问题换到另一台机器上就崩了排查了半天才发现是字节序的问题。4.3 发送端的完整逻辑发送端的核心循环seq 0 with open(input.txt, rb) as f: while True: data f.read(1024) if not data: break packet pack_header(seq, 0, FLAG_DATA, len(data)) data retry 0 while retry MAX_RETRY: client_sock.sendto(packet, server_addr) client_sock.settimeout(TIMEOUT) try: ack_packet, _ client_sock.recvfrom(2048) ack_seq, ack_num, flags, _ unpack_header(ack_packet) if flags FLAG_ACK and ack_num (seq 1) % 0xFFFFFFFF: break except socket.timeout: retry 1 print(fTimeout, retransmitting seq{seq}, retry{retry}) if retry MAX_RETRY: print(Max retry reached, aborting) break seq (seq 1) % 0xFFFFFFFF这段代码有几个关键点每次重传后都要重新设置超时因为settimeout是一次性的ACK号比较时要考虑回绕重传次数要有上限否则网络彻底断了会死循环。4.4 接收端的完整逻辑接收端expected_seq 0 with open(output.txt, wb) as f: while True: packet, client_addr server_sock.recvfrom(2048) seq, ack, flags, length unpack_header(packet) data packet[HEADER_SIZE:HEADER_SIZElength] if flags FLAG_DATA: if seq expected_seq: f.write(data) ack_packet pack_header(0, (expected_seq 1) % 0xFFFFFFFF, FLAG_ACK, 0) server_sock.sendto(ack_packet, client_addr) expected_seq (expected_seq 1) % 0xFFFFFFFF else: ack_packet pack_header(0, expected_seq, FLAG_ACK, 0) server_sock.sendto(ack_packet, client_addr) elif flags FLAG_FIN: f.flush() ack_packet pack_header(0, (expected_seq 1) % 0xFFFFFFFF, FLAG_ACK, 0) server_sock.sendto(ack_packet, client_addr) break注意接收端在收到错误序号时回复的ACK号是expected_seq而不是seq1。这个细节决定了发送端能否正确判断是否需要重传。4.5 抓包验证与调试代码写完后一定要用抓包工具验证。我当年用的是Wireshark过滤条件设为udp.port 9999然后观察包的交互过程。正常的停等协议交互应该是这样的客户端发送DATA(seq0)服务端回复ACK(ack1)客户端发送DATA(seq1)服务端回复ACK(ack2)...以此类推如果你看到客户端连续发送了多个DATA而没有收到ACK说明ACK包可能被丢弃了或者服务端的回复地址不对。如果你看到服务端反复回复同一个ACK说明客户端在重传。我当年遇到的一个诡异问题是本地测试一切正常但把服务端部署到另一台机器后客户端一直超时。抓包发现客户端的包发出去了但服务端没收到。排查后发现是防火墙把UDP端口拦了。所以如果你跨机器测试记得检查防火墙设置。5. 常见问题与排查技巧实录5.1 典型问题速查表问题现象可能原因排查方法客户端一直超时重传服务端未启动或端口不对检查服务端是否bind成功端口是否一致服务端收到包但不回复解析头部时字节序错误检查struct格式字符串是否带!传输完成后文件不完整FIN包丢失或未正确处理抓包看FIN包是否发出服务端是否break文件内容错乱序号比较逻辑错误检查ACK号是否等于期望序号本地正常跨机失败防火墙或MTU问题关闭防火墙测试减小包大小重传后收到重复数据接收端未去重检查seq与expected_seq的比较逻辑5.2 我踩过的三个坑第一个坑超时时间设得太短。我一开始设了0.05秒结果在实验室的无线网络下几乎每个包都要重传两三次。后来改成0.5秒重传率立刻降下来了。这个教训告诉我超时时间必须根据实际网络环境调整不能拍脑袋决定。第二个坑忘记处理FIN包。我一开始只实现了DATA和ACK文件传完后客户端直接退出服务端还在傻等。结果就是服务端一直阻塞在recvfrom文件也没关闭。后来加了FIN包和对应的处理逻辑才正常结束。第三个坑ACK号计算错误。我一开始把ACK号设成了seq而不是seq1。结果发送端收到ACK后以为对方期望的是当前包于是又重传了一遍。抓包看了一下午才发现这个问题。记住ACK号表示的是“我期望收到的下一个序号”不是“我已经收到的序号”。5.3 性能优化的小技巧虽然这个实验不要求高性能但如果你想让传输快一点可以试试这几个方法增大初始窗口停等协议每次只能发一个包效率很低。如果你实现了滑动窗口可以把窗口大小设为10或20吞吐量会明显提升。批量ACK接收端不必每收到一个包就回复ACK可以累积几个包后再回复。但这样会增加发送端的超时概率需要权衡。减少不必要的打印我一开始在每个包收发时都print结果传输速度慢得离谱。后来把打印去掉速度快了十倍不止。I/O操作是性能杀手。提示如果你在实验报告里想体现“性能优化”的思考可以对比不同窗口大小下的传输时间画个折线图老师会很欣赏。5.4 实验报告的撰写建议实验报告不要只贴代码要体现你的思考过程。我当时的报告结构是这样的实验目的用自己的话复述不要抄实验指导书协议设计画出报文格式图解释每个字段的作用状态机描述用文字或表格描述发送端和接收端的状态转换关键代码只贴核心逻辑不要贴全部代码测试结果抓包截图、传输时间、重传次数统计问题与解决重点写你踩过的坑和排查过程这是加分项特别是第6部分老师最喜欢看的就是你遇到问题、分析问题、解决问题的过程。如果你只写“一切正常”反而显得不真实。6. 从实验到实战这个实验教会我的事做完这个实验后我对TCP的理解上了一个台阶。以前觉得“超时重传”就是四个字现在知道它背后涉及定时器管理、序号回绕、ACK策略等一系列细节。后来在工作中遇到网络编程的问题比如HTTP请求超时、消息队列的可靠投递我都能从这次实验的经验里找到影子。如果你正在做这个实验我的建议是不要只满足于“能跑就行”。多抓包、多测试异常场景、多问几个为什么。比如如果ACK包丢了会怎样如果发送端在等待ACK时上层又给了新数据怎么办如果接收端的磁盘满了怎么办这些问题实验指导书不会告诉你但你想到了就说明你真正入门了。最后分享一个我当年调试时的小技巧在代码里加一个全局的包计数器每收发一个包就打印一行日志格式统一为[SEND] seq0 len1024或[RECV] ack1。这样出问题时把日志和Wireshark的抓包结果对照着看很快就能定位到是哪一步出了问题。这个习惯我一直保留到现在排查网络问题时特别好用。