
简介计算机网络实验是高校信息技术教育的重要环节实验二聚焦停止等待协议与BSC协议帮助学生在串行口上实现可靠文件传输。这份doc文档面向网络相关专业学生及需要复习协议原理的初学者内容覆盖实验目的、设备连接、超时重传、ACK/NAK确认、数据包编号以及面向字符的BSC控制字符与报文格式。压缩包内共1个doc文件大小81KB便于阅读打印或对照实验环境使用。已有87人学习/下载适合正在完成同类实验或准备网络实验报告的人群。文档不仅解释了停止等待协议的工作过程还给出数据包丢失、确认丢失等问题场景并提供简化stop-and-wait协议的报文格式和编程提示。通过阅读读者能理解定时器、序号、BCC校验等关键机制掌握串口编程方法和BSC协议实际操作为完成实验和后续网络编程打下基础。1. 停止等待协议一个看似“发一帧等一帧”的计算机网络实验卡住了多少人计算机网络课讲到可靠传输时停止等待协议经常被一句话带过“发一帧等确认再发下一帧”。真等到做实验——把一个文件用停止等待协议从 A 传到 B——你才会发现这句话漏掉了几乎所有难点丢了怎么办确认丢了怎么办帧损坏了怎么办文件最后一块不满怎么办。这个实验的本质是在用户态用 UDP 模拟一条不可靠链路然后在应用层把序号、确认、超时重传、校验和这四件事串成一个状态机。它适合正在做课程设计、或者想把书上的状态机真正跑一遍的同学做完之后你对 TCP 为什么需要三次握手和滑动窗口会理解得更扎实。2. 停止等待协议的帧格式设计切帧、1 位序号与 CRC2.1 为什么切帧从 MTU 到单块重传的代价常见做法是先把文件按固定大小切成若干帧一帧一帧传。很多同学第一次写文件传输时会把整个文件读进内存一发就是几 MB 的字节串。这样做不是不能跑但有两个直接问题一是接收端要一次性分配同样大的缓冲区文件稍微大一点内存就吃紧二是只要中间丢一个字节整个大帧的 CRC 校验失败发送端必须把整个文件重传一遍代价高得离谱。链路层的视角更本质以太网的 MTU 是 1500 字节数据链路层帧的 payload 本来就被限制在这个量级。停止等待协议模拟的正是链路层的可靠传输所以帧大小往 5121024 字节靠而不是按 UDP 的 64K 上限来。我一般会把帧的 payload 设成 1024 字节兼顾吞吐和单帧重传成本如果实验环境里有真实路由器且路径 MTU 不确定降到 512 字节更稳否则容易在 IP 层分片而分片后只要一片丢失整个 UDP 数据报都到不了对端——这在后面排查坑的时候会再提。切帧还有个隐藏的好处协议能给出明确的进度。每传完一帧发送端就知道“哪一块已经确认”接收端也能按偏移把文件写回。停止等待协议虽然吞吐不高但它是把“可靠传输”这件事拆到最小单元的实验载体。2.2 帧结构设计头尾固定CRC 覆盖整个帧体停止等待协议只需要 1 位序号因为发送方在收到确认之前只能发一帧网络中最多同时存在一个旧帧和一个新帧用 0、1 交替就能区分。帧头我会设计成下面这样固定 6 字节字节偏移字段长度说明01Magic2固定 0xAA55用来快速判断是不是本协议帧2Type10DATA1ACK2END3Seq10 或 1每确认一帧后翻转45Length2payload 长度网络字节序66LenPayload可变实际文件数据末尾 4CRC324对整个帧体头payload计算Type 字段里的 END 帧用来告知文件传完接收端收到 END 就落盘收工。有的同学会省掉 END用“文件大小”字段来判断收尾但在停止等待协议的框架下END 帧更直观也方便接收端处理“最后一块不满一帧”的情况。Length 字段则是为了让接收端能从字节流里精确截出 payload——UDP 的 recvfrom 返回的其实是完整数据报所以多余的数据不会出现但保留 Length 仍然是个好习惯后续如果改成 TCP 字节流这个字段就是必需的。CRC32 覆盖范围必须是“帧头payload”不能只算 payload。发送和接收共用同一套编解码函数就不会出现收端校验永远失败的问题。计算机网络基础课上讲的差错检测落地到代码里就是这一行。2.3 frame.py把编解码和 CRC 放在同一个模块里我会把帧的编码、解码、校验放到同一个frame.py发送端和接收端都 import 它保证两边逻辑永远一致import struct import zlib MAGIC 0xAA55 TYPE_DATA 0 TYPE_ACK 1 TYPE_END 2 HEADER_SIZE 6 CRC_SIZE 4 MAX_PAYLOAD 1024 # 帧数据区上限可调整 def build_frame(seq: int, ftype: int, payload: bytes b) - bytes: 构造一帧6字节头 payload 4字节CRC32 header struct.pack(!HBBH, MAGIC, ftype, seq, len(payload)) body header payload crc zlib.crc32(body) 0xffffffff return body struct.pack(!I, crc) def parse_frame(raw: bytes): 解析一帧返回 (type, seq, payload)校验失败返回 None 和错误原因 if len(raw) HEADER_SIZE CRC_SIZE: return None, frame too short body raw[:-CRC_SIZE] expected_crc struct.unpack(!I, raw[-CRC_SIZE:])[0] if (zlib.crc32(body) 0xffffffff) ! expected_crc: return None, crc mismatch magic, ftype, seq, payload_len struct.unpack(!HBBH, body[:HEADER_SIZE]) if magic ! MAGIC: return None, bad magic payload body[HEADER_SIZE:HEADER_SIZE payload_len] return (ftype, seq, payload), None这里的关键点是struct.pack(!HBBH, ...)感叹号表示网络字节序大端保证跨主机传输时多字节字段能被正确解析。CRC 计算用的是zlib.crc32返回的 Python 整数可能超过 32 位所以 0xffffffff把它归一成无符号 32 位再用!I打包成 4 字节。接收端先把末尾 4 字节摘出来对前边的 body 重新算 CRC两边一致才收。2.4 chunk_file()按帧大小切片避免整个文件进内存文件分块用一个生成器函数最省事不需要read()整个文件def chunk_file(filepath: str, payload_size: int MAX_PAYLOAD): 按 payload_size 分块读取文件生成二进制块 with open(filepath, rb) as f: while True: data f.read(payload_size) if not data: break yield dataread(payload_size)在文件末尾可能返回不足一帧的数据也可能返回空串。if not data: break这一行必须有否则文件大小恰好是帧倍数时最后一次read()会返回b生成器会把空块发出去接收端就会写进一个 0 字节的“假帧”后面排查 END 帧收尾时这个问题还会出现。参数payload_size要和接收端的recvfrom(bufsize)匹配我习惯给recvfrom传MAX_PAYLOAD HEADER_SIZE CRC_SIZE 64留足余量避免 UDP 数据报被截断。3. 发送端怎么实现停止等待状态机、socket 超时与重传参数3.1 状态机就三步发送、等待、超时重传发送端的逻辑用状态机描述很简单但写代码时最容易漏的是“停留在等待确认”这个状态。它的状态变迁只有三种就绪从文件读一块数据配上当前序号构造 DATA 帧发出进入等待确认。等待确认此时已经有一个帧在网络上飞发送端必须停在这里不能再发下一帧。收到确认且序号匹配翻转序号0 变 11 变 0读下一块回到就绪如果超时重传当前帧继续停在等待确认。“停止等待”四个字里“停止”是重点。为什么滑动窗口在《计算机网络自顶向下》和谢希仁的教材里都放在后面讲就是因为窗口大于 1 之后序号不再是 1 位能解决的——接收端需要区分“新帧”和“重传帧”1 位序号在网络里同时只有两帧在飞时才够用。这个实验先把窗口焊死成 1后面再扩窗时你才会明白序号位数的意义。发送端的代码骨架我不喜欢写得花哨用最直白的循环就能说明问题import socket import sys import time from frame import * HOST 127.0.0.1 PORT 9000 TIMEOUT 0.5 # 初始超时单位秒 MAX_RETRY 10 # 单帧最大重传次数防死循环 PAYLOAD_SIZE 1024 # 帧数据区大小 def send_file(sock, addr, filepath): seq 0 total_sent 0 total_retrans 0 for block in chunk_file(filepath, PAYLOAD_SIZE): retry 0 acked False while not acked: frame build_frame(seq, TYPE_DATA, block) sock.sendto(frame, addr) total_sent 1 try: ack, _ sock.recvfrom(256) parsed, err parse_frame(ack) if err is not None: # 收到坏帧按没收到处理继续等 continue ack_type, ack_seq, _ parsed if ack_type TYPE_ACK and ack_seq seq: acked True # 序号不匹配的确认直接忽略不翻转状态 except socket.timeout: retry 1 total_retrans 1 if retry MAX_RETRY: raise RuntimeError(fframe {seq} max retry exceeded) seq 1 - seq # 确认到达翻转序号 sock.sendto(build_frame(seq, TYPE_END), addr) return total_sent, total_retrans这段代码的逻辑要点在循环结构while not acked把“发当前帧→等确认→超时重发”串成同一个循环只有收到序号匹配的 ACK 才跳出否则永远停在等待确认。MAX_RETRY是保险丝网络彻底断掉时不会无限重传实验里出现问题时也能快速看到报错。seq 1 - seq是 1 位序号翻转的标准写法比seq ^ 1更直白。还要注意ACK 帧本身也有可能损坏所以发送端对recvfrom收到的数据同样要走parse_frame校验失败就当没收到继续等超时。3.2 socket 超时三种写法和一个选型理由发送端等 ACK 必须给 socket 设置超时常见做法有三种socket.settimeout(seconds)、select.select([sock], [], [], timeout)、sock.settimeout(None)配合多线程。做实验我选第一种原因只有一个代码路径最短超时异常直接落在except socket.timeout里重传逻辑顺理成章。socket.settimeout(TIMEOUT)会阻塞至多TIMEOUT秒然后抛socket.timeout。它的粒度足够本次实验使用但注意它会让recvfrom变成“每 TIMEOUT 秒醒一次”如果主循环里还有其他工作要做会受阻塞拖累。需要更高精度时用select比如要同时监听多个 socket或者要在等待 ACK 期间刷新界面那种场景下select的返回列表让你知道“有数据”还是“超时”代码结构也更接近生产环境的做法。发送端完整运行时只需要一个 socket 对象sendto不需要 bind系统会自动分配临时端口。接收端回 ACK 时拿到的是recvfrom返回的 addr直接sendto(ack, addr)就能回到这个临时端口上。3.3 超时参数从固定值到指数退避超时值设多少是发送端唯一需要调的参数。设小了ACK 还在路上你就重传网络里出现重复帧接收端反复回重复确认吞吐反而掉设大了真丢了包要干等很久。局域网实验我用下面这张参照表起步场景建议超时说明本机回环 127.0.0.10.20.5sRTT 几乎为 0超时主要防逻辑死循环同机房局域网0.51.0s交换机转发延迟很小留 23 倍余量跨宿舍 / 跨楼层 Wi-Fi1.02.0sWi-Fi 抖动大太短会引发重传风暴我一开始总把 TIMEOUT 设成 0.1s结果发现重传次数比有效帧还多。后来养成的习惯是先跑一版只收不发的小测试打印每个 ACK 的到达时间差算出 RTT再把 TIMEOUT 设成它的 23 倍。网络一波动就翻车的话可以加上指数退避每次超时后把 TIMEOUT 翻倍最多翻 5 次timeout 0.5 for attempt in range(MAX_RETRY): try: # 等待 ACK break except socket.timeout: timeout min(timeout * 2, 4.0) # 退避上限 4 秒 sock.settimeout(timeout) continue退避的代价是丢包时恢复变慢收益是网络拥塞时不会加重拥塞。实验场景里固定超时够用但理解退避逻辑对后面读 TCP 拥塞控制的代码有直接帮助。4. 接收端怎么实现确认与去重从坏帧丢弃到文件落盘4.1 接收端要分清三个情况新帧、坏帧、重复帧接收端维护一个期望序号expected初始为 0。每收到一个 DATA 帧只有三种处理路径新帧seq expectedCRC 校验通过把 payload 写入文件回一个序号等于seq的 ACK然后把expected翻转。坏帧CRC 校验失败直接丢弃不回任何确认。发送端等不到 ACK 自然会超时重传。重复帧seq ! expected说明这个帧之前已经被接收过只是 ACK 在回程路上丢了导致发送端超时重传。此时接收端必须再回一次 ACK而且序号用收到的seq同时不写文件、不翻转expected。第三种情况最容易写错。很多同学只在seq expected时回 ACK重复帧来了直接沉默。结果就是ACK 丢失→发送端重传→接收端丢弃重传帧但不回 ACK→发送端再次超时重传两边的报文就卡在死循环里。正确做法是“对重复帧也必须确认”让发送端尽早跳出重传循环。计算机网络这门课的期末题里经常考这个场景ACK 丢失时发送端重传的是同一帧接收端靠 1 位序号辨识出来并丢弃重复数据这个去重逻辑就是接收端可靠性的核心。4.2 接收主循环与文件落盘接收端代码比发送端短逻辑却要更谨慎import socket import sys from frame import * HOST 127.0.0.1 PORT 9000 RCV_BUF 2048 # 必须大于 帧头payloadCRC def receive_file(sock, filepath): expected 0 with open(filepath, wb) as f: while True: raw, addr sock.recvfrom(RCV_BUF) parsed, err parse_frame(raw) if err is not None: continue # 坏帧丢弃等发送端超时重传 ftype, seq, payload parsed if ftype TYPE_END: break # 文件传输结束 if ftype ! TYPE_DATA: continue if seq expected: f.write(payload) sock.sendto(build_frame(seq, TYPE_ACK), addr) expected 1 - expected else: # 重复帧必须回 ACK但不写数据 sock.sendto(build_frame(seq, TYPE_ACK), addr)open(filepath, wb)会覆盖同名文件实验里每次重跑前把旧文件删掉或改输出名否则会混淆结果。RCV_BUF我设为 2048比最大帧6 1024 4 1034 字节大一倍因为 recvfrom 的缓冲区太小会把 UDP 数据报截断截断后的帧 CRC 必然失败表现就是“文件传着传着卡住”且发送端一直重传。这个现象在第 5 章还会作为坑单独展开。4.3 END 帧与收尾文件大小不是帧倍数时更要小心END 帧在发送端发完所有数据块后发出接收端收到就break落盘。这样设计有个明显的单点依赖如果 END 帧在 UDP 里丢了接收端会一直recvfrom卡住。实验里解决这道问题的常见做法有两个一是接收端 socket 也设一个总超时比如 30 秒超时后报错退出二是在 END 帧后接收端回一个 FINACK发送端等不到就用超时重发 END。# 接收端在 while 循环里对 END 帧做确认 if ftype TYPE_END: sock.sendto(build_frame(seq, TYPE_ACK), addr) break # 简单实验到这里就够发送端发完 END 后可以立刻sock.settimeout(1)再收一次确认收不到也无所谓文件已经完整落盘。注意 END 帧也带序号但接收端不检查它的序号因为 END 只负责收尾不参与数据去重。如果文件大小恰好是PAYLOAD_SIZE的整数倍chunk_file的if not data: break保证不会发空帧接收端在写完全部数据块后直接收到 END这条路径在多轮实验里最容易踩但也是最稳的。5. 停止等待协议传输文件的 5 个踩坑从 CRC 错位到 UDP 缓冲5.1 CRC 覆盖范围错一位整帧被误杀现象文件传了几 KB 后发送端开始疯狂重传日志里全是超时接收端一条数据都写不进去。原因发送端算 CRC 时把帧体算成header payload接收端却只对 payload 算校验或者发送端在 header 和 payload 之间插入了对齐字节两边对“帧体”的边界认识不一致。CRC 不匹配的帧会被parse_frame判定为坏帧直接丢弃而且丢弃是静默的接收端不回 ACK发送端只能干等超时。解决编解码函数写在一个模块里发送和接收共用build_frame/parse_frame不要各自实现一份。改帧结构后跑一次自测发送端构造一帧调parse_frame解析确认 round-trip 通过再上网络。这个坑我用血泪经验换来一个习惯——任何帧格式的变更先做本地单元测试再做端到端。5.2 TIMEOUT 设太短重传风暴把吞吐打成负数现象重传次数是有效帧数的好几倍接收端日志里全是重复帧的 ACK文件虽然能传完但耗时比预期翻了 5 倍以上。原因TIMEOUT 比实际 RTT 还小ACK 还没回来发送端就超时了。重传的帧和原来的 ACK 在网络上相遇接收端回重复确认发送端又可能再超时网络里全是无用的重复数据。解决把 TIMEOUT 调到 RTT 的 23 倍。判断方法很简单发送端统计total_retrans / total_sent这个比值超过 0.05 就要警惕超过 0.1 基本可以断定是超时设置而非真实丢包。实验里还能看到“越调整超时越小重传越多”的恶性循环遇到这种玄学现象先把超时静止地改大一轮往往自己就好了。5.3 ACK 丢失导致重复写块文件莫名变大现象接收端写出的文件比原文件大或者中间一段内容重复。文件末尾多出内容时才发现错了。原因接收端只在seq expected时回 ACK但回 ACK 之后没有翻转状态当发送端因 ACK 丢失重传同一帧时接收端再次命中seq expected又把同一个 payload 写了一遍。本质是“重复帧”被误判成“新帧”。解决接收端写完数据必须立刻翻转expected 1 - expected这样重传帧的序号就和期望序号相反走进else分支只回 ACK 不写数据。验证方法我在实验里会用一个小脚本故意把接收端回的第一个 ACK 从sendto前拦截掉看第二次收到同序号帧时文件内容是否没有重复。5.4 文件大小恰好是帧大小的整数倍收尾卡死现象文件传完数据块之后发送端显示 END 已发出接收端却一直等程序挂起。文件大小是 1024 的整数倍时稳定复现。原因chunk_file在读取最后一块数据后又执行了一次read(payload_size)返回b。如果生成器代码只写了yield data忘了if not data: break就会把空块当普通帧发出去。接收端收到 payload 为空的 DATA 帧写入 0 字节翻转了序号随后真正的 END 帧到达但此时接收端的 expected 可能已经和发送端错位END 的判断逻辑如果依赖序号就直接卡住。解决生成器里必须有if not data: break且 END 帧的处理不检查序号只要 type 是 END 就收尾。这两条合在一起能把“空块”和“END 收尾”两个边界同时收住。用assert len(block) 0在发送循环开头做一次防御性检查能更快暴露问题。5.5 UDP 接收缓冲溢出底层丢包伪装成应用层超时现象传 10MB 左右的文件时重传率突然飙升网络没断发送端也没报错但速度慢到像蜗牛。加大 TIMEOUT 无效。原因UDP 的接收缓冲区在操作系统内核里默认值一般只有几十 KB。接收端处理慢比如每次write落盘为块设备瓶颈或者发送端一下子灌进来多帧内核的接收缓冲满了之后直接把 UDP 数据报丢掉应用层根本看不到这些帧。表现就是发送端等 ACK 超时但抓包又抓不到任何错误。解决把 socket 的接收缓冲调大顺带把发送缓冲也调大。接收端在 bind 之后加一行sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 262144)262144 字节256KB对课程实验足够再大意义不大。真正的根治还是让接收端消费速度跟上比如用write前加一层内存缓冲或者用线程池处理帧确认。这个坑告诉我们UDP 的“不可靠”不只是丢包缓冲区溢出也是丢包的一种而且更隐蔽。6. 验证协议的可靠性模拟丢包、统计重传率然后走向滑动窗口6.1 在接收端人为制造丢包和损坏把协议逼到绝路协议写完不能只在“完美网络”下跑那验证不了停止等待协议的任何价值。我会在接收端加两个开关用随机数模拟链路的丢包和损坏import random LOSS_RATE 0.2 # 模拟 20% 丢包 CORRUPT_RATE 0.05 # 模拟 5% 帧损坏 raw, addr sock.recvfrom(RCV_BUF) if random.random() LOSS_RATE: continue # 模拟丢帧不处理也不回 ACK发送端会超时重传 if random.random() CORRUPT_RATE: raw raw[:-4] bytes([raw[-1] ^ 0xFF]) # 翻转最后一个字节CRC必然失败丢包模拟是“直接 continue”接收端对这根帧完全沉默发送端必须在超时后重传才能推进损坏模拟是“改坏 CRC”接收端的parse_frame会返回crc mismatch同样走静默丢弃路径。两个开关合起来发送端每传一帧都要过一遍“可能丢、可能坏、可能 ACK 丢”的完整链路。我一般把 LOSS_RATE 开到 0.3 跑一轮如果文件还能完整传完且不重复协议基本就算稳了。6.2 用重传率和有效吞吐验证协议再做滑动窗口停止等待协议是否正常工作用三个数就能评判指标计算方式健康范围重传率(发送帧数 - 数据块数) / 数据块数无丢包时接近 0模拟 20% 丢包时不超过 0.5有效吞吐文件大小 / 总耗时局域网内能达到理论值的 80% 以上校验失败帧数接收端计数器应与损坏概率吻合发送端的send_file返回了total_sent和total_retrans接收端在循环里维护一个bad_frame_count跑完打印出来对照。如果坏了 5% 的帧重传却高达 50%说明超时参数或者去重逻辑有问题不是链路的问题。做完这个实验下一步值得做的是滑动窗口把窗口从 1 扩到 N序号就不够用了需要两位或更多位接收端要有缓存能容忍乱序发送端要能批量发送、批量确认。停止等待协议里“确认一个翻一个序号”的习惯扩窗后要改成“累积确认”。最后说个我自己的教训调这类协议参数永远是第二位的先把状态打印全。每发一帧、每收一帧把seq、expected、耗时打一行出问题时看回放比猜快得多。我也养成了先做几十帧小文件测试、再上大文件压测的习惯这个顺序帮我避开了上面至少三个坑。希望帮到你。本文还有配套的精品资源点击获取