简介计算机网络实验指导文档聚焦“利用停止等待协议传输数据文件”实验适用于高校计算机/网络专业学生以及需要理解数据链路层流量控制与可靠传输原理的入门学习者。文档系统阐述实验目的、实验环境要求、停止等待协议的基本工作流程深入解析超时重传、确认信息丢失处理、数据包编号机制等核心要点并进一步介绍作为面向字符停等协议实例的BSC协议包括控制字符含义、数据报文与常见控制报文格式、透明传输处理方法及简化实验设计。资源为单份DOC格式实验指导共1个文件压缩包约81KB携带方便适合预习、实验现场查阅和复习。目前已有87人学习使用。通过该文档读者可以对照示意图理解ACK/NAK、定时器、序号0/1以及SOH/STX/ETX/EOT、DLE等控制字符的实际应用掌握停等协议在串行口文件传输中的编程思路从而更好地完成实验报告提升分析网络通信异常的能力。1. 停止等待协议为什么值得亲手做一遍一个在谢希仁《计算机网络》里两三页就讲完的停止等待协议落到实验课上做成“利用停止等待协议传输数据文件”时很多人第一次体会到课本上的“发送—确认—超时重传”六个字在真实代码里至少牵扯帧格式、序号翻转、校验、超时设置、文件分块这五件事。这个实验用 UDP 裸写可靠传输做完之后回头看 TCP 的设计决定也会比背八股清楚得多。这篇笔记按实验二的要求把帧格式到收尾参数的完整实现、几处必踩的坑和验证方法讲透适合正在赶计算机网络实验报告的同学也适合以后要自己写简单可靠 UDP 应用的开发。2. 搭一个最简停等协议帧格式、校验与超时怎么定2.1 为什么用 UDP 裸写而不是直接拿 TCP 改先想清楚一点实验要求的是理解停止等待协议本身而不是把文件从一个进程搬到另一个进程。直接调 TCP 的 send 和 recv底层会自动完成确认、重传、排序停止等待协议在你的程序里根本不存在实验目的全落空。所以常见做法是用 socket 的 SOCK_DGRAMUDP自己造一套可靠传输。UDP 只负责把字节发出去不保证不丢、不乱序、不重复这些保证恰好就是停止等待协议需要补上的部分。用 UDP 的第二个理由是代码量可接受。发送端加接收端合计一百多行就能跑通比起自己调 TCP 的缓冲区、拥塞窗口要可控得多。实验报告里也可以对比着写一句TCP 的可靠传输本身就是从停等协议一步步扩展到连续 ARQ 的实验二相当于把滑动窗口大小缩成 1 之后的退化场景。看湖科大教书匠那套状态机仿真动画时你会发现发送端重传的三种情形——数据帧丢失、确认帧丢失、确认迟到——和这里代码里的分支是一一对应的。2.2 帧格式每种帧该含哪些字段为什么不用字符串分帧我一般建议用一个二进制帧结构不要拿字符串拼协议。字符串分帧在数据内容里遇到特殊字符会非常折腾二进制帧几张表就能说清楚。下面是我在实验里常用的格式后面两章代码都按这个走。字段长度取值说明frame_head2 字节0xAA55固定帧头便于快速判断是不是有效帧frame_type1 字节1: DATA2: ACK3: FIN4: FIN_ACK帧类型seq1 字节0 或 1数据帧和确认帧都要带序号length2 字节0~1024载荷字节数checksum2 字节CRC16对 frame_type 之后、含载荷的整块数据计算payload可变文件数据只有 DATA 帧携带一个容易被忽略的细节ACK 帧也要带序号和校验。ACK 丢失或损坏同样会导致发送方超时重传如果 ACK 不带校验接收方解出一个字节错乱的 ACK 却当正确包处理协议状态机就乱了。FIN 帧则用来告诉接收方“全部数据帧已经发完可以收尾”很多不仔细看实验指导的同学交上来的代码里恰恰少这一帧导致接收端的 socket 一直等下去。这套协议我只用 ACK 加超时重传不引入 NACK。原因是停止等待协议一次只发一帧接收方发现坏帧后不回应即可发送方自然会超时重传NACK 在回退 N 帧、选择重传这类滑动窗口协议里才有实际收益。贸然加一个 NACK 分支会引出“NACK 丢了怎么办”的新问题对理解实验目的没有帮助。2.3 CRC16 与 RTO两个必须提前定好的参数校验我选 CRC16 而不是简单的累加和校验。校验和把所有字节相加取低 16 位实现简单但对偶数个字节翻转这类错误经常漏检实验目的里通常包含“数据链路层怎样做差错检测”的分析CRC16 在课堂推导里是有标准数学背景的适合写进报告。Python 里直接用标准库 binascii.crc_hqx 就能算没有第三方依赖后面代码里组帧和收帧都会各调一次。超时值 RTO 是这个实验里唯一需要反复调的参数。发送方等确认的时间要满足RTO 约等于 RTT 加接收端处理时间再加安全余量。同一台机器自测 127.0.0.1 时 RTT 不到 0.1 毫秒RTO 设 2 秒完全够用跨宿舍甚至跨教学楼实验时丢包和延迟明显上升RTO 设太小会出现“ACK 还在路上发送方已经在重传”的假象设太大则真实丢包时每帧都要空等很久。比较稳的起点是 2 秒在代码里做成命令行参数出问题先查这一项。提示RTO 不是越大越安全。超时重传是面对“确认丢失”时才用的最后手段RTO 过长会让链路利用率进一步恶化这正是停等协议利用率公式反映的问题。停等协议的利用率公式值得写进实验报告U T_frame / (T_frame T_ack 2 × T_prop)。T_frame 是完整帧在链路上发送所需时间T_ack 是确认帧发送时间T_prop 是单向传播时延。局域网里 T_prop 很小U 能到 0.8 以上一旦跨网段传播时延占大头利用率掉得很快。做实验时可以自己 ping 一下对端把实测 RTT 代进去报告里解释这个下降过程会非常加分。王道的复习书里也把“确认丢失之后会不会死等”当简答题反复考原因就在这个公式背后。3. 发送端实现把文件切帧、按 ACK 推着走的完整代码3.1 发送端状态机只有三个状态别写复杂发送端的状态机其实只有三态发送当前帧并等待 ACK、收到有效且序号匹配的 ACK 就前进、超时未收到 ACK 就重发当前帧。注意“收到 UDP 报文”和“收到正确的 ACK”是两回事。很多初次实现的人把 recvfrom 返回当成了确认到达结果收到一个校验失败的包也跟着发下一帧文件看起来传完了md5 对不上。正确做法是每次收包后都过一遍帧头、类型、序号、CRC 四道检查。发送端还有一个容易误解的地方重传时序号不能变。数据帧序号是在“确认成功”之后才翻转的不是每次 sendto 都翻转。如果重传时把 seq 改成了对端 already 期待的另一个值接收端会把它当成新帧写入文件整个文件后半段都会错位。3.2 发送端完整代码切块、组帧、等 ACK、超时重传import socket import binascii import struct import sys FRAME_HEAD 0xAA55 TYPE_DATA 1 TYPE_ACK 2 TYPE_FIN 3 TYPE_FIN_ACK 4 CHUNK_SIZE 1024 def build_frame(frame_type, seq, payloadb): # 帧结构2字节头 1字节类型 1字节序号 2字节长度 载荷 2字节CRC body struct.pack(!BBH, frame_type, seq, len(payload)) payload checksum binascii.crc_hqx(body, 0) return struct.pack(!H, FRAME_HEAD) body struct.pack(!H, checksum) def parse_frame(frame): if len(frame) 8: return None head struct.unpack(!H, frame[:2])[0] if head ! FRAME_HEAD: return None frame_type, seq, length struct.unpack(!BBH, frame[2:6]) if len(frame) 8 length: return None body frame[2:6 length] checksum struct.unpack(!H, frame[6 length:8 length])[0] if binascii.crc_hqx(body, 0) ! checksum: return None return frame_type, seq, frame[6:6 length] def send_file(host, port, file_path, rto2.0): sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(rto) addr (host, port) seq 0 with open(file_path, rb) as f: while True: payload f.read(CHUNK_SIZE) frame build_frame(TYPE_DATA, seq, payload) # 先发一帧然后进入等待确认的内层循环 sock.sendto(frame, addr) while True: try: data, _ sock.recvfrom(2048) except socket.timeout: # 超时未收到 ACK重发当前帧序号保持不变 sock.sendto(frame, addr) continue parsed parse_frame(data) if parsed is None or parsed[0] ! TYPE_ACK: # 校验失败或类型不对继续等待下一个包 continue if parsed[1] ! seq: # 收到旧 ACK说明这个确认对应上一帧忽略 continue break seq 1 - seq if len(payload) CHUNK_SIZE: break # 数据帧全部被确认后发送 FIN 并等待 FIN_ACK fin build_frame(TYPE_FIN, 0) while True: sock.sendto(fin, addr) try: data, _ sock.recvfrom(2048) except socket.timeout: continue parsed parse_frame(data) if parsed is not None and parsed[0] TYPE_FIN_ACK: break sock.close() print(发送完成) if __name__ __main__: host sys.argv[1] if len(sys.argv) 1 else 127.0.0.1 port int(sys.argv[2]) if len(sys.argv) 2 else 8888 path sys.argv[3] if len(sys.argv) 3 else test.bin rto float(sys.argv[4]) if len(sys.argv) 4 else 2.0 send_file(host, port, path, rto)这段代码里内层 while 是协议的核心每次超时重发同一帧但收到无效包时会回到 recvfrom 继续等而不是立刻重发。等到真正匹配当前 seq 的 ACK 才跳出循环再把 seq 翻转。这样避免了一个常见的误区——对损坏的 ACK 做即时反应协议会因为噪声帧疯狂重传。命令行参数顺序是 host、port、file_path、rto。自测时直接python3 sender.py 127.0.0.1 8888 test.bin 2.0。rto 单独放第四个参数是因为跨机实验时几乎所有人都要调它。3.3 发送端参数CHUNK_SIZE、RTO、端口怎么设CHUNK_SIZE 默认 1024这是个居中值。大于常见 MTU1500 字节很多时IP 层会分片单个分片在跨路由实验里更容易被中间设备丢弃而且一丢就是一大段设太小则相同文件要发更多帧超时重传的概率也随之上升。1024 能保证整帧加上协议头仍然落在单个以太网帧里实验场景下最稳。RTO 默认 2 秒。如果接收端和发送端跑在虚拟机的 NAT 网络里宿主机的调度和虚拟网卡延迟会让 RTT 忽高忽低建议调到 3 秒。RTO 这个参数不是精确值实验报告里把设定思路写清楚比写具体数字更重要。端口方面自测时发送端不需要 bind系统会分配随机出口端口接收端固定 bind 8888。两台机器联调时注意防火墙入站规则Windows 第一次运行 Python 监听端口时如果弹窗被取消外部发来的 UDP 包会在防火墙层被静默丢弃表现就是发送端疯狂超时。4. 接收端实现校验、去重、组帧落盘的完整代码4.1 接收端状态机expected_seq 是唯一记忆接收端比发送端多了一个“去重”职责。它唯一要记忆的是一个期待序号 expected_seq初始为 0。收到 DATA 帧且 seq 等于 expected_seq就写盘并翻转 expected_seq收到 seq 不等于 expected_seq 的数据帧说明这一帧其实已经写过只是 ACK 在途中丢了发送方超时重传此时要丢弃载荷但必须回一个 ACK这样发送方才能往前推进。这个“重复帧也回 ACK”是停等协议实现里最关键的一行。漏掉它发送方传完一个块后永远等不到匹配的 ACK会一直等到超时重传传输时间成倍拉长。接收端的核心逻辑就这两条分支不需要更多状态。4.2 接收端完整代码校验、去重、落盘、收尾import socket import binascii import struct import sys FRAME_HEAD 0xAA55 TYPE_DATA 1 TYPE_ACK 2 TYPE_FIN 3 TYPE_FIN_ACK 4 def build_frame(frame_type, seq, payloadb): body struct.pack(!BBH, frame_type, seq, len(payload)) payload checksum binascii.crc_hqx(body, 0) return struct.pack(!H, FRAME_HEAD) body struct.pack(!H, checksum) def parse_frame(frame): if len(frame) 8: return None head struct.unpack(!H, frame[:2])[0] if head ! FRAME_HEAD: return None frame_type, seq, length struct.unpack(!BBH, frame[2:6]) if len(frame) 8 length: return None body frame[2:6 length] checksum struct.unpack(!H, frame[6 length:8 length])[0] if binascii.crc_hqx(body, 0) ! checksum: return None return frame_type, seq, frame[6:6 length] def receive_file(port, output_path, timeout10): sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((0.0.0.0, port)) sock.settimeout(timeout) expected_seq 0 with open(output_path, wb) as f: while True: try: data, peer sock.recvfrom(2048) except socket.timeout: # 超过 10 秒没收到任何帧判定发送端已经退出 print(等待数据超时程序退出) sock.close() return False parsed parse_frame(data) if parsed is None: continue frame_type, seq, payload parsed if frame_type TYPE_DATA: if seq expected_seq: f.write(payload) expected_seq 1 - expected_seq # 重复帧也回 ACK且 ACK 序号必须是数据帧自己的 seq sock.sendto(build_frame(TYPE_ACK, seq, b), peer) elif frame_type TYPE_FIN: sock.sendto(build_frame(TYPE_FIN_ACK, 0, b), peer) break sock.close() print(接收完成) return True if __name__ __main__: port int(sys.argv[1]) if len(sys.argv) 1 else 8888 output sys.argv[2] if len(sys.argv) 2 else recv.bin receive_file(port, output)接收端代码和发送端共用了同一套 build_frame 与 parse_frame 逻辑这保证了双方对帧结构的理解完全一致。重点看 DATA 分支里的两个操作写盘的时机是 seq 与 expected_seq 匹配回 ACK 的时机是任何合法 DATA 帧到达。这两个条件必须分开合在一起写就会在重复帧场景下漏回 ACK。timeout 参数默认 10 秒作用不是等单个 ACK而是兜底“发送端进程崩了”这种情况避免接收端卡死在 recvfrom 里占住端口。实验时如果接收端先启动发送端后启动这 10 秒足够覆盖。4.3 文件名约定与 FIN 帧处理文件名不要从协议帧里解析我做实验时用命令行参数约定接收端启动时直接指定输出路径比如python3 receiver.py 8888 recv.bin。原因很简单帧协议里为了传一个变长文件名要额外引入一个字段和对应解析逻辑和实验目的无关命令行参数约定反而更不容易出错老师在验收时也能一眼看懂。写盘必须用wb二进制模式。Windows 下如果误用w文本模式写入文件时所有换行符都会被改写成\r\n文件字节数直接变化这是整个实验里最典型的翻车点。FIN 帧不需要序号判断。发送端只会发一次 FIN重复发送的 FIN 是超时重传的结果接收端收到 FIN 后回一个 FIN_ACK 并退出即可。FIN_ACK 也不需要序号。把这两帧从 DATA/ACK 的序号逻辑里单独摘出来收尾代码会简单很多。5. 传输文件的避坑清单5 个必踩的坑与对应解法实验指导书上写的实验目的通常是“理解停止等待协议的工作原理”但真正验收时老师看的是你能不能把一个文件完整无损地从一台机器传到另一台。我自己带实验课这几年下面五个坑几乎每个班都会有人踩一遍每条都按现象、原因、解决的顺序说清楚。5.1 传完文件变大文本模式的换行符陷阱现象文件传完后接收端保存的文件比原文件大几十字节文本文件打开后每行结尾多出字符。原因发送端或接收端用了文本模式打开文件。Windows 下文本模式会把写入的\n自动扩展成\r\n读取时反过来做转换。对二进制文件图片、压缩包来说这种转换是灾难字节流被无中生有地修改了。解决发送端open(file_path, rb)接收端open(output_path, wb)全部按二进制处理。文件传输协议里没有“文本”这个概念任何文件都是一串原始字节。这个坑修起来只要改两个字母但定位时最容易让人怀疑协议写错属于值得先排除的低级错误。5.2 重传循环卡死从 IP、端口、防火墙三步定位现象发送端每隔 RTO 就重发同一个包接收端窗口完全没有反应程序像死循环一样一直跑。原因按出现频率排第一是 IP 写错想发给另一台机器却填成了 127.0.0.1数据全发回本机第二是端口不一致接收端 bind 8888发送端却发到 8890第三是 Windows 防火墙第一次弹窗被取消外部机器发来的 UDP 包被静默丢弃接收端根本收不到。解决先在同一台机器上用 127.0.0.1:8888 跑通完整流程确认协议本身没问题再换跨机联调。跨机失败时在接收端机器上执行netstat -ano | findstr 8888确认端口有监听然后在防火墙设置里给 Python 添加入站允许规则。UDP 被防火墙丢弃不会报任何错误只能这样逐项排除。5.3 文件大小对但内容错位expected_seq 没维护现象传输完成后文件大小和源文件完全一致但内容中间有一段重复后面整体错位。原因接收端没有维护 expected_seq收到 DATA 帧就写盘。停止等待协议里ACK 丢失会导致发送端重传同一帧如果接收端把重传帧再写一次数据就重复了。文件大小不变是因为总字节数凑巧对齐但内容已经损坏。解决接收端严格按 expected_seq 判断。seq 匹配才写盘并翻转 expected_seq不匹配时只回 ACK、不改状态。翻转用expected_seq 1 - expected_seq这种最简单的写法0/1 交替协议不要引入更花哨的取模逻辑状态越直白越不容易错。5.4 每传一块都等 2 秒ACK 序号写错现象文件最终能传完但速度极慢每传一个块都刚好卡在 RTO 超时附近小文件也要几分钟。原因接收端回 ACK 时把序号写错了常见的是固定写 expected_seq 而不是数据帧自己的 seq。发送端收到 ACK 后校验通过、类型也对但序号和当前期待不匹配于是忽略它干等到超时。超时后重传的数据帧到达接收端接收端又当成重复帧回了一次错误 ACK双方就这样靠超时一节一节地推进。解决发送端忽略的 ACK用日志一眼就能看出来。每次收到 ACK 打印收到的 seq 和当前 seq如果永远不相等去检查接收端回 ACK 的代码。回 ACK 的序号应该是刚收到的 DATA 帧里的 seq 值不是接收端自己维护的 expected_seq。这个坑排查起来最费时因为传输结果是正确的只是慢。5.5 Connection reset by peer接收端收尾不够优雅现象文件已经传完接收端文件大小正确但发送端最后抛出Connection reset by peer或者类似的 socket 异常。原因接收端回完 FIN_ACK 后立刻 break 并 close socket发送端此时可能刚收到 FIN_ACK 前发出的下一个 FIN 已经被目标端口关闭触发 ICMP 端口不可达Windows 上就会把这个 ICMP 回报成 ConnectionResetError抛给正在 recvfrom 的发送端。解决接收端回完 FIN_ACK 后不急着退出先time.sleep(0.5)再 close给 ICMP 回报留出窗口发送端在等待 FIN_ACK 的循环里也要捕获 ConnectionResetError 当作对方已正常退出处理。两边都做上不管单机自测还是跨机演示都不会在这个位置出状况。6. 用丢包注入与吞吐量验证协议设计别急着交报告6.1 加一个丢包注入开关只跑通一次传输不能证明协议可靠。我在接收端的 parse_frame 之后加一个开关if random.random() 0.1: continue模拟 10% 的帧丢失。丢包会随机吃掉 DATA 和 ACK此时发送端日志里会出现重传记录最终文件 md5 依然和源文件一致才能说明“确认—超时—重传”这条链路真的生效。这个开关要放在校验之后否则丢的是解析失败的坏包测不出重传路径。6.2 用 md5 和吞吐量核对实验结果发送前md5sum 源文件接收后md5sum 输出文件两条值一样再谈别的。吞吐量对照可以用第 2 章的利用率公式登录到两台机器后互 ping 拿到 RTT把 T_frame 和 T_ack 代进公式算出理论利用率再和实际传输总字节数除以耗时对比。误差主要来自 socket 队列调度和 RTO 边界上的等待实验报告里说明这些就足够严谨。6.3 把停等协议往滑动窗口推一步把 seq 从 0/1 改成多位计数器发送端允许一次缓存 N 个未确认帧就是回退 N 帧接收端再维护一个接收窗口就是选择重传。做完这一步再回头背王道复习书里 TCP 可靠传输的章节你会发现那些术语全部变成了自己写过的代码分支。这些年每学期都有人带着跑不通的停等协议来问我后来养成的习惯是不给实验报告交“只跑通一次”的传输代码必须先做丢包注入再贴重传统计。没有验证过可靠性的协议代码我不会往任何实验文档里放。希望帮到你。本文还有配套的精品资源点击获取