
简介计算机网络实验指导实验二围绕停止等待协议传输数据文件这一主题面向高校计算机网络课程的学生与指导教师适用于串行口通信实验、协议原理复习及实验报告撰写场景。文档从实验目的与实验环境入手系统阐述了发送方等待ACK/NAK、超时重传、数据包编号等核心机制并结合数据包丢失和确认信息出错两种典型异常说明定时器与0/1编号如何保证传输可靠且不重复同时整理了BSC协议中SOH、STX、ETX、EOT、ENQ、ACK、NAK、DLE等控制字符的用途以及数据报文格式、BCC校验、透明数据转义和简化停等协议的设计方法内容完整、条理清晰。资源包共1个doc文档大小81KB体量紧凑方便直接阅读或打印适合实验前预习和实验中对照。目前已有87人学习下载对初次接触停等协议或需要完成串行文件传输实验的读者具有较强参考价值。1. 停止等待协议实验到底在练什么“停止等待协议”是计算机网络实验指导里几乎必然出现的第二个实验发送方每发完一个数据块就停下来等接收方回一个确认帧ACK收不到就超时重传收到才发下一块循环往复把文件传完。这个实验的目的从来不是让你背状态机而是让你亲手把一个不可靠的 UDP 链路变成一条能传文件的可靠信道顺带把 seq、ACK、帧丢失、重复帧、校验和这些词从教材挪进代码。适合正在赶实验报告、准备期末复习或想把协议真正跑起来的人把停等跑明白后面看滑动窗口和 TCP 重传会少踩很多坑。2. 停止等待协议的确认与重传状态机要比代码先成立写代码之前我建议先把协议的状态转换画出来。停止等待协议叫“停止等待”是因为发送方在同一时刻只允许一个帧在链路上“在途”——发完就停收到确认才继续。这个约束决定了它只需要一个非常简单的状态机也决定了它在局域网里跑得挺快、在长链路上效率特别低。下面把帧交换的四个基本场景拆开这四种情况就是后面调试时真正会遇到的四种路径。2.1 四种帧交换场景正常、丢帧、丢 ACK、重复帧停等协议的最小闭环是“发送方发一个帧 → 接收方校验 → 回 ACK → 发送方收 ACK 再发下一帧”。按这个闭环展开所有情况都能归进四种路径正常传输、数据帧丢失、确认帧丢失、重复帧到达。教材里比如谢希仁那本《计算机网络》的数据链路层章节通常只画前三种重复帧其实是“ACK 丢失导致重传”的必然结果单独列出来反而更容易理解接收端为什么必须去重。场景发送方行为接收方行为结果正常传输发帧、启动定时器校验通过、交付数据、回 ACK发送方收到 ACK翻转序号继续下一帧数据帧丢失定时器超时、重发同一帧没收到无行为重发后接收方正常处理ACK 丢失定时器超时、重发同一帧收到重复帧、丢弃数据但再回 ACK发送方收到新 ACK 后恢复重复帧到达无特殊行为序号与期望不符、丢弃数据、仍回 ACK文件不会重复写入这张表里值得反复咀嚼的是最后一行接收方收到重复帧时数据一定不能落盘但 ACK 必须照发。很多同学在这里想当然认为“重复帧都接收到了就不用再回 ACK”结果发送方等不到确认只能不停重传最后触发重传上限直接崩溃。网络协议里的确认机制不是“收到才确认”而是“无论收到新帧还是旧帧都要让对端知道我还活着”。2.2 单比特序号为什么 0 和 1 交替就够了停等协议里序号只需要 0 和 1 两个值来回翻转这是个反直觉但非常精巧的设计。因为同一时刻链路上最多只有一个数据帧接收方需要回答的问题只有一个这个帧是“我期待的新帧”还是“上一次已经处理过的旧帧”二值序号足够区分这两种状态。接收方维护一个期望序号变量expected_seq初始 0。收到帧时如果帧序号等于expected_seq说明是新帧写入文件并把expected_seq翻转为 1如果不等说明是重传的旧帧丢弃数据但仍回 ACK。发送方维护一个seq每收到一个正确的 ACK 就翻转一次。两个变量在各自端点上独立维护不需要共享状态这是协议能工作的关键。我在给实验报告写讲解时常用一个类比这就像两个人轮流拿编号 0、1、0、1 的票进场检票员只管“这次来的票号是不是我预期的那张”而不是去数这个票号出现过多少次。用状态变量做预期比用“统计收到多少个包”去做重可靠得多因为统计会受重传影响而状态变量遵循的是协议逻辑。理论教材里这一步通常对应自顶向下那套教材的 rdt2.2 / rdt3.0 状态图湖科大教书匠那类讲得细的网课也会在这里画两个角色的状态机。我的建议是动手写代码前先在纸上把发送方和接收方的状态迁移各画一遍代码只是把这张图翻译成 if-else。2.3 超时重传的定时器RTT 是标尺而不是拍脑袋超时重传是停等协议从“不可靠链路”变成“可靠链路”的那只手。发送方发送一个数据帧后立即启动定时器如果在定时器到期前没有收到对应 ACK就重发同样的帧。这里最容易翻车的地方是定时器的时长选择。时长必须大于当前的往返时间RTT否则会出现“伪重传”帧其实已经到达ACK 也在回来的路上但发送方等不及就重发了。RTT 包含数据帧的上行传输时间、接收方处理时间、ACK 的下行传输时间还有网络排队时间所以它不是一个固定值。常见做法是先用一次探测获得初始 RTT然后用加权移动平均来平滑RTTs 0.875 × 旧RTTs 0.125 × 新采样RTT这是 TCP 里经典的平滑公式套到停等协议实验里也完全够用。定时器一般取平滑 RTT 的 23 倍留足余量。一个刚做实验的同学通常会问能不能直接把超时时间设成 0.5 秒省事能但在本机回环loopback上 RTT 只有零点几毫秒0.5 秒意味着每丢一帧都要白白等 0.5 秒丢包率稍高整个实验就慢得让人抓狂如果放到跨机器的真实链路里RTT 变成 50 毫秒0.5 秒又可能不够。所以不要把超时时间写死最好做成一个可以在命令行传入的参数至少也要放在文件开头用变量声明方便实验报告里做多组对比。到这一步理论部分基本够用了。下面直接用 Python 把它落地。3. Python UDP 实现文件传输发送端、接收端与三个必调参数这一章给出一个在本地就能完整跑通的实现。我选 Python 是因为它在 socket、struct、zlib 这几个标准库上几乎没有环境成本而且实验报告里贴代码好讲生产系统里换成 C/Jav 也是一样的逻辑。整个方案包含三个必须理解的参数数据块大小、超时时间、最大重传次数。这三个参数分别对应“链路效率”“链路延迟”和“链路质量”是后面调参实验的主线。3.1 选 UDP 而不是 TCP不可靠信道必须自己扛实验名字里写的是“利用停止等待协议传输数据文件”但没规定传输层用什么协议。我强烈建议用 UDP 套接字而不是 TCP。原因很简单TCP 自己就实现了序号、确认、超时重传和流量控制应用层再用停等协议等于让一个已经会游泳的人套着游泳圈表演游泳——你只能看到“一问一答”的外壳真正需要验证的重传逻辑全部发生在系统协议栈里抓包根本看不到。UDP 把“不可靠”赤裸裸地暴露给应用层数据报可能丢失、可能乱序、可能重复。停等协议要做的恰恰是在这个不可靠信道之上通过“发送→等待→确认→重传”把可靠性一层层搭起来。这正是实验目的。在实际开发里你几乎不会在 UDP 之上重新发明停等协议因为 SCTP、QUIC 这些协议已经帮你做好了但作为教学实验你必须亲手实现一次才能理解 TCP 的确认号为什么那样设计。3.2 帧格式设计头部、校验和与 EOF 终止标记为了在应用层模拟“帧”我们需要自己定义帧格式。这里用二进制结构而不是文本行因为文件数据里可能出现的任意字节不能和分隔符混淆。我用的帧头是 8 字节定长seq1 字节帧序号取值 0 或 1交替翻转frame_type1 字节1 表示数据帧2 表示结束帧EOFlength2 字节payload 字节数无符号短整型最大 65535crc4 字节对“帧头前 6 字节 payload”整体计算出的 CRC32 校验值帧头之后直接跟 payload。EOF 帧的 payload 为空它是文件传输结束的信号接收端收到 EOF 帧后回一个 ACK 并关闭文件。CRC32 用zlib.crc32它比手写的“逐字节求和”可靠很多。逐字节求和的经典问题我在第 5 章会详细讲。这里先记住校验范围必须包含头部里的 seq、frame_type、length不能只对 payload 校验否则头部在传输中被破坏时接收端可能把一个错误位置的帧当成合法数据。3.3 发送端代码切片、超时重传与序号翻转发送端逻辑分三层读文件切片、发帧并等 ACK、超时重传。下面是完整实现。import socket import struct import zlib import sys import time CHUNK_SIZE 1024 # 每个数据块的字节数建议不超过 1400 SERVER_ADDR (127.0.0.1, 8888) TIMEOUT 1.0 # 超时重传阈值单位秒 MAX_RETRY 20 # 单帧最大重传次数 def make_frame(seq, frame_type, payload): # 先按 crc0 填充帧头算出校验和后再填进去 head struct.pack(!BBHI, seq, frame_type, len(payload), 0) crc zlib.crc32(head payload) head struct.pack(!BBHI, seq, frame_type, len(payload), crc) return head payload def send_one(sock, seq, frame_type, payload): frame make_frame(seq, frame_type, payload) retry 0 while True: sock.sendto(frame, SERVER_ADDR) try: ack, _ sock.recvfrom(256) if len(ack) 1 and ack[0] seq: return # 确认帧中的 seq 与当前帧一致才算成功 except socket.timeout: retry 1 print([timeout] seq%d retry%d % (seq, retry)) if retry MAX_RETRY: raise RuntimeError(帧 seq%d 重传超过上限 % seq) def send_file(sock, filepath): seq 0 with open(filepath, rb) as f: while True: payload f.read(CHUNK_SIZE) if payload: send_one(sock, seq, 1, payload) seq 1 - seq # 翻转比特序号 if not payload: # 文件读完了发 EOF 帧 send_one(sock, seq, 2, b) break if __name__ __main__: sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(TIMEOUT) send_file(sock, sys.argv[1]) sock.close() print(发送完成)这段代码里需要注意三个地方。第一make_frame里struct.pack(!BBHI, ...)用的网络字节序!表示大端B是无符号单字节H是无符号双字节I是无符号四字节这样接收端在任意平台上解析结果一致。第二send_one里确认条件用的ack[0] seq这里简单取了 ACK 帧的第一个字节即 ACK 回显的是“被确认帧的序号”。实际工程中 ACK 帧也要校验 CRC教学实现里为了突出重点先不校。第三EOF 帧和普通数据帧走同一个send_one也会等待 ACK。如果 EOF 帧的 ACK 丢失发送方会重传 EOF这时接收端可能已经退出代码会走到重传上限报错。这个问题第 5 章有专门的排查思路。CHUNK_SIZE我默认设 1024 字节理由有两个一是 1024 加上 8 字节帧头后远小于以太网 MTU 1500 字节UDP 数据报不会分片二是 1024 这个值在算文件总帧数时方便心算例如 10MB 文件正好 10240 帧报告里好写。如果实验环境在 PPPoE 这类 MTU 只有 1492 的链路上建议把 CHUNK_SIZE 降到 1024 以下避免分片导致的二次丢包。3.4 接收端代码校验、去重落盘与 ACK 回复接收端逻辑比发送端多一个关键步骤判断重复帧。无论当前帧是新帧还是重复帧都要回 ACK但只有新帧才写盘。import socket import struct import zlib RECV_PORT 8888 OUTPUT_FILE recv_file.bin sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((127.0.0.1, RECV_PORT)) expected_seq 0 out open(OUTPUT_FILE, wb) while True: packet, addr sock.recvfrom(65535) # UDP 数据报边界即帧边界 if len(packet) 8: # 小于帧头长度直接丢弃 continue seq, frame_type, length, crc struct.unpack(!BBHI, packet[:8]) payload packet[8:8 length] head struct.pack(!BBHI, seq, frame_type, length, 0) if zlib.crc32(head payload) ! crc: # 校验失败静默丢弃 print([crc error] seq%d len%d % (seq, length)) continue if frame_type in (1, 2): if seq expected_seq: out.write(payload) # 只有新帧才写盘 out.flush() expected_seq 1 - expected_seq else: print([duplicate] seq%d expected%d % (seq, expected_seq)) ack struct.pack(!BBHI, seq, 3, 0, 0) ack_crc zlib.crc32(ack) ack struct.pack(!BBHI, seq, 3, 0, ack_crc) sock.sendto(ack, addr) # 重复帧同样要回 ACK if frame_type 2: # EOF 帧结束接收 break out.close() sock.close() print(接收完成: %s % OUTPUT_FILE)接收端的核心是expected_seq这个变量。它只在“收到的 seq 等于期望值”时翻转因此能精确识别重复帧。注意 EOF 帧不会被写入文件因为frame_type 2且 payload 为空out.write写进去 0 字节不影响结果但它的expected_seq翻转会导致发送端如果重传 EOF接收端判定为重复帧后仍回 ACK协议状态一致。跑通这个版本后你可以直接用它传一个文本文件试试再用diff或md5sum验证收发的文件一致。这是整个实验的基线版本。接下来要做的是给它加上丢包和时延让“可靠性”从理论变成看得见的数据。4. 丢包模拟与效率测算让实验数据能上答辩很多人做完基线版本就停了因为本机 loopback 上几乎不丢包程序运行得又快又顺报告里只能写“实验结果正确”。这样的实验报告经不起问。真正有价值的实验要在受控的丢包和时延条件下跑并把重传次数、耗时、吞吐率这些数据量化出来。这一章讲怎么给你的代码注入“故障”以及怎么把这些故障下的数据变成报告里的论据。4.1 代码内置丢包率比 tc netem 更可控的教学环境在 Linux 上可以用tc qdisc加 netem 模拟链路丢包例如tc qdisc add dev lo root netem loss 5%。但这个做法在教学环境里有三座大山需要 root 权限Windows 上根本没有这条命令而且它作用在整个 loopback 上会影响你同时跑着的其他网络服务。我一般建议直接在代码里做丢包注入好处是参数完全可控、可复现实验报告可以精确写出“丢包率 5% 时重传了 137 次”而不是“网络好像有点卡”。最简洁的做法是在接收端收到数据帧后按概率直接丢弃不处理也不回 ACK。发送端自然超时重传。import random LOSS_RATE 0.05 # 丢包率 5% # 在 recvfrom 之后、处理 frame 之前插入 if random.random() LOSS_RATE: print([simulate loss] seq%d % seq) continue注意这段逻辑要放在 CRC 校验之前还是之后我的建议是在校验之后、处理之前。因为我们要模拟的是“链路把帧丢了”而不是“接收端校验失败丢弃”区分这两者对统计重传原因有帮助。如果你想模拟 ACK 丢失可以在发送端的recvfrom成功返回后随机丢掉这个 ACK# 发送端收到 ACK 后以同样概率假装没收到 if random.random() LOSS_RATE: continue不过课堂实验通常只模拟一种丢包就够两边都丢会让重传次数变得难以解释。我默认只在接收端丢数据帧这样每次重传都能归因到“数据帧丢失”报告好写。4.2 信道利用率公式与会考的几个数停等协议的效率瓶颈是它最大的特点也是期末复习里反复出现的考点。在不考虑重传的理想情况下发送方每发一帧要经历数据帧发送时间 T_data、等待 ACK 到达的 RTT、ACK 帧自身的发送时间 T_ack。信道利用率 U 近似为U T_data / (T_data RTT T_ack)当链路带宽很高、数据块很小时T_data 通常只有几十微秒而 RTT 在跨地域链路上动辄几十毫秒分母被 RTT 主导U 会低到惨不忍睹。举个例子100Mbps 链路上传一个 1024 字节的块T_data ≈ 0.082ms如果 RTT 是 100msU ≈ 0.082 / 100.164 ≈ 0.08%也就是说链路 99.92% 的时间在空等。传 10MB 文件理论耗时约 1024 秒接近 17 分钟。这个数字在答辩现场一算比任何文字都直观。如果考虑丢包率 p一个帧平均要发送 1/(1-p) 次才能成功实际吞吐率近似为实际传输速率 ≈ U × 带宽 × (1-p)这就是实验里为什么丢包率从 0 升到 5%整个文件传输时间会翻倍甚至更大的原因——重传不光是多发了几个包每个重传都要白白等一个超时周期而超时时间通常比 RTT 大好几倍。4.3 四个实验组把参数组合设计成答辩证据我建议你在实验报告里跑四组对比每组都记录完成时间、发送总帧数、重传次数、文件校验结果。这个组合能同时证明三点协议在无故障下正常工作、协议在丢包下可靠恢复、超时参数对性能有决定性影响。实验组丢包率模拟 RTTTIMEOUT预期观察组 10%0ms1.0s无重传完成时间最短组 25%0ms1.0s出现重传文件仍正确组 30%100ms1.0s无重传但完成时间变长效率低组 40%100ms0.05s伪重传刷屏完成时间反而更长模拟 RTT 不用真的跨机器最简单的方法是在接收端回 ACK 前加一个time.sleep(RTT_MS / 1000.0 / 2)这等效于把链路往返时延拉长到目标值。代码改动只有一行却能让“RTT 对停等协议效率的影响”变成可控变量这是我在实验里常用的手法。组 4 是整份报告里的“亮点实验”当 TIMEOUT 小于实际 RTT 时每一帧都会先超时重传再收到 ACK重传次数等于总帧数程序最终也能跑完但时间惨不忍睹。这个实验直观解释了定时器下限为什么要大于 RTT比背一百遍公式都管用。到这里一个能复现、有数据、可答辩的停等文件传输实验已经成型。接下来看几个高频坑大部分是学生在实现时反复踩过的问题。5. 停止等待协议常见问题现象、原因与排查方法这一章是实验现场最常见的五个问题每条都按“现象 → 原因 → 解决”的路径记录。你如果自己的实现出了类似问题可以直接对标排查。5.1 文件变大或变小重复帧和 EOF 边界同时出了问题现象传输完成后接收端文件大小和源文件不一致多数情况是变大偶尔变小用diff比较文件末尾或中间多出一段重复内容。原因最常见的是接收端没有区分新帧和重复帧把所有收到的 payload 都写进了文件另一个常见原因是 EOF 帧发送在逻辑上放在了“读完最后一块”之后但最后一块的判定用了len(payload) CHUNK_SIZE当文件大小恰好是 CHUNK_SIZE 的整数倍时最后一次读取返回完整块EOF 帧永远发不出去接收端等不到结束信号文件缺少末尾数据。解决接收端必须用expected_seq变量判断“新帧才写盘”重复帧只回 ACK 不写盘。发送端不要用数据块大小判断文件是否读完而是用if not payload判断文件流是否耗尽耗尽后再单独发 EOF 帧。改完后用 MD5 对比验证。5.2 程序卡死超时时间比 RTT 还短现象发送端 log 里全是[timeout]重传信息同一帧连续重传十几次接收端却显示已经收到这一帧并且回了 ACK最后程序崩溃或陷入很长时间的等待。原因TIMEOUT 设置得太小小于实际 RTT。数据帧到达接收端ACK 也在返回途中发送端就率先超时重传。重传的帧到达接收端后接收端仍正常工作回 ACK但发送端每次超时都会重发形成“伪重传风暴”。如果 TIMEOUT 比 RTT 小很多每一帧都会被重传传输时间成倍拉长。解决把 TIMEOUT 设置为平滑 RTT 的 23 倍以上。最简单的办法是先跑一次无丢包基线实验打印出每帧的确认耗时取平均值再乘以 3。组 4 的对比数据也来自这个坑。5.3 校验和失效只算 payload 漏了头部现象文件能传输完大小也对但内容里偶尔有几个字节和源文件不同打开 CRC 日志发现crc error很少甚至没有。原因很多人写的校验只对 payload 做求和比如sum(payload) % 256这种校验对“字节位置交换”和“一个字节变成另一个同 mod 值字节”完全无感更严重的问题是没有把 seq、frame_type、length 这三块头部纳入校验头部在链路中被篡改后接收端可能把帧解析到错误位置数据全错但校验依然通过。解决统一用zlib.crc32并把整个帧头crc 字段置 0 payload 一起计算校验值。接收端也必须按同样顺序重新计算再比对任何字节不一致都会导致 CRC 不匹配。5.4 用 TCP 套壳做停等抓包根本看不到重传现象代码逻辑完全正确程序也能跑但用 Wireshark 抓包发现网络里根本没有重传把丢包率调高也没有实验报告里写“实现了超时重传”答辩时却拿不出证据。原因用的是 TCP 套接字。TCP 协议栈自己在做确认和重传应用层看起来是在一问一答但链路上一旦丢包是系统内核在重传不会触发你写的应用层 TIMOUT 逻辑。你在应用层实现的“停止等待”只是个等待循环真正的停止等待协议并没有参与可靠性保障。解决换用SOCK_DGRAM即 UDP把“确认—超时—重传”的可靠性逻辑真正放到应用层代码里。如果课程要求必须基于 TCP至少要在应用层自定义确认和重传机制并禁用 TCP_NODELAY 之外的自动重传但这种做法绕开了实验本意不推荐。5.5 文件恰好是 CHUNK_SIZE 整数倍收尾永远等不来现象传一个大小正好是 1024KB 的文件时发送端把文件读完了程序却不退出接收端也一直不结束两边干等。原因发送端判断文件结束用的是“最后一块长度小于 CHUNK_SIZE”但 1024KB 的文件最后一块正好是满块不满足结束条件。发送端会继续读一次文件流读到空字符串 b如果此时没有专门发 EOF 帧的逻辑程序就会卡在读空串后的等待状态接收端自然等不到结束信号。解决不要用数据块长度判断文件是否结束而是用read()的返回值是否为空判断。读完空串后单独发送一个frame_type2的 EOF 帧接收端以类型而不是长度来识别结束。代码在第 3 章已经是这个写法如果你自己的实现卡在这重点检查没读空串时是否跳过了 EOF 逻辑。6. 验证实验效果重传统计、窗口对比与抓包留证实验跑通只是起点能向别人证明“我真的理解了停等协议”才是终点。我的建议是给收发两端加统计埋点再抓一次包最后做一个对照组。6.1 在收发两端埋点统计重传次数与耗时在发送端加三个变量总发送帧数、重传次数、开始时间。超时重传时retry_count 1成功传输一个块后sent_count 1结束前打印耗时和吞吐率。有了这些数字第 4 章的四组实验表格就有了数据来源。接收端可以在收到重复帧和 CRC 错误时打印日志方便对照发送端重传日志验证“重传的帧到底有没有被接收端正确识别”。6.2 做一组最小 GBN 对比证明窗口的价值停等协议效率低的根因是窗口为 1。为了证明这一点可以实现一个最简化的回退 N 帧GBN协议把发送端的“每发一帧等一个 ACK”改成“连续发 4 帧再统一收 ACK”。代码改动很小但吞吐率差异会非常大。这个对照组能让实验报告从“我复现了教材”升级为“我验证了教材的结论”。6.3 用 Wireshark 抓包留证据Wireshark 过滤条件用udp.port 8888就能看到全部数据帧、ACK 帧和重传帧。观察 Time 列的间隔正常传输时帧间隔稳定超时重传时会出现一个明显的跳变间隔约等于你的 TIMEOUT 值。把抓包截图放实验报告里比写一百句“实现了重传机制”都有说服力。我做这个实验时也翻过车。第一次图省事用 TCP 套了个一问一答报告里理直气壮写“实现了停等协议”答辩时老师问我重传在哪我说不清楚全场沉默。后来换成 UDP 拆帧、加 CRC、把超时时间调到比 RTT 还短看着 Wireshark 里整齐的重传记录才真正明白确认和定时器不是两个孤立参数而是一套配套的可靠性机制。这个坑如果你也踩过那换 UDP 重跑一遍体会会完全不同。希望帮到你。本文还有配套的精品资源点击获取