简介:这份文档资料面向高校计算机网络课程的学生与实验指导教师,聚焦实验二「利用停止等待协议传输数据文件」,帮助读者在串行口编程基础上理解可靠传输机制。内容围绕停等协议的基本工作过程展开,涵盖数据包丢失、确认信息丢失或出错时的定时器重传与0/1编号去重策略,并延伸介绍面向字符的BSC协议,包括SOH、STX、ETX、EOT、DLE等控制字符的功能、数据报文与控制报文格式、透明数据传输及链路建立、传输、拆除三阶段流程,最后给出简化停等协议的报文格式与编程要点。资源包内含1个doc文档,约81KB,结构紧凑,适合作为实验预习、课堂讲解与报告撰写的参考。目前已有87人学习,便于读者快速掌握协议原理并完成可靠文件传输的实验实现。
1. 停止等待协议到底在解决什么问题:从串口丢包说起
你在串口调试助手里敲下发送,对面设备回了“OK”,可文件内容却少了两百多字节——这种“明明发出去了,对方却没收到”的情况,在串行口文件传输里几乎每个工程师都遇到过。停止等待协议(Stop-and-Wait)就是为这种场景准备的最小可靠传输方案:发送方每发一帧数据,必须等接收方回一个确认帧(ACK),收到确认后才发下一帧;如果超时没等到确认,就重发当前帧。它不追求吞吐量,追求的是“每一帧都确认到达”。这篇文章围绕“利用停止等待协议传输数据文件”这个实验目标,把协议状态机、串口配置、文件分帧、BSC 控制字符、CFile 读写这几个环节串起来,让你能在本地把一条串口链路上的文件传输跑通,并且知道参数该设多少、翻车时该看哪里。适合正在做计算机网络实验、需要手写可靠传输逻辑的读者。
2. 停止等待协议的状态机与帧结构:为什么不能只发不等
停止等待协议看起来简单,但真正动手写的时候,很多人第一版代码就是“循环发送、不等确认”,结果在串口这种半双工或带缓冲的链路上直接翻车。要让它可靠工作,必须先把状态机和帧结构定清楚,再谈串口和文件读写。
2.1 发送方与接收方的四个核心状态
停止等待协议的本质是一个两状态交替的有限状态机。发送方只有两个稳定状态:等待上层数据(Ready)和等待确认(Wait for ACK)。接收方也只有两个:等待数据帧(Wait for Frame)和校验并回确认(Send ACK)。
发送方在 Ready 状态下从文件读一块数据,组帧后发出,同时启动超时定时器,进入 Wait for ACK。在 Wait for ACK 状态下收到正确 ACK,回到 Ready;收到错误 ACK 或超时,重发当前帧,定时器重置。接收方在 Wait for Frame 状态下收到帧,先校验帧头和校验和,正确则写入文件并回 ACK,错误则回 NAK 或直接丢弃等待重传。
这里有一个容易被忽略的点:ACK 本身也可能丢。如果接收方回了 ACK 但发送方没收到,发送方超时重发,接收方会收到重复帧。所以帧结构里必须带一个 1 位序号(0 和 1 交替),接收方据此判断是新帧还是重复帧。重复帧直接丢弃并重发 ACK,不能重复写文件。
提示:序号只有 1 位是停止等待协议的特点,因为任意时刻链路上最多只有一个未被确认的帧,0/1 交替足够区分。
2.2 帧格式设计与 BSC 控制字符的对应关系
很多教材会提到 BSC(Binary Synchronous Communication)控制字符,停止等待实验里常用的几个是 SOH、STX、ETX、ACK、NAK。它们的作用是给帧定界和确认。
一个可落地的帧格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| SOH | 1 字节 | 帧起始,0x01 |
| 序号 | 1 字节 | 0 或 1 |
| STX | 1 字节 | 数据段起始,0x02 |
| 数据 | 变长 | 文件分块,建议 128~256 字节 |
| ETX | 1 字节 | 数据段结束,0x03 |
| 校验和 | 1 字节 | 对序号+数据做累加和取反 |
| ACK/NAK | 1 字节 | 接收方回送,0x06 或 0x15 |
数据块大小不是越大越好。串口接收缓冲区常见是 256 字节或 4096 字节,如果一帧数据超过缓冲区,接收方还没处理完就被下一帧覆盖,表现为“随机丢帧”。我一般把单帧数据控制在 128 字节,配合 9600 或 115200 波特率都够用。
2.3 用 Python 写一个最小停止等待发送端
下面这段代码只保留协议核心逻辑,串口用 pyserial,文件用内置 open 模拟 CFile 的读写行为。你可以直接改成 C++ 的 CFile 或 FILE* 版本。
import serial import time SOH, STX, ETX, ACK, NAK = 0x01, 0x02, 0x03, 0x06, 0x15 TIMEOUT = 1.0 # 超时秒数 MAX_RETRY = 5 # 最大重传次数 BLOCK_SIZE = 128 # 每帧数据字节数 def checksum(seq, data): s = seq + sum(data) return (~s) & 0xFF def build_frame(seq, data): return bytes([SOH, seq, STX]) + data + bytes([ETX, checksum(seq, data)]) def send_file(port, filepath): ser = serial.Serial(port, 115200, timeout=TIMEOUT) seq = 0 with open(filepath, 'rb') as f: while True: block = f.read(BLOCK_SIZE) if not block: break frame = build_frame(seq, block) retry = 0 while retry < MAX_RETRY: ser.write(frame) resp = ser.read(1) if resp and resp[0] == ACK: seq ^= 1 # 序号翻转 break retry += 1 if retry >= MAX_RETRY: raise RuntimeError('重传次数超限,链路可能断开') ser.close()逻辑说明:build_frame按帧格式拼字节;send_file每次读 128 字节,发帧后阻塞等 1 字节响应。收到 ACK 才翻转序号并读下一块;收到 NAK 或超时则重发同一帧。MAX_RETRY防止链路彻底断开时死循环。
参数说明:TIMEOUT要大于“一帧传输时间 + 接收方处理时间 + ACK 回传时间”。115200 波特率下 128 字节约 11ms,1 秒超时非常宽松。BLOCK_SIZE如果调到 1024,在 9600 波特率下单帧就要 1 秒以上,超时必须同步加大,否则会误重传。
2.4 接收端如何避免重复写文件
接收端比发送端多一个判断:收到帧后先看序号是否等于期望序号。如果等于,写入文件、翻转期望序号、回 ACK;如果不等于,说明是重复帧,直接回 ACK 但不写文件。
def recv_file(port, savepath): ser = serial.Serial(port, 115200, timeout=TIMEOUT) expected_seq = 0 with open(savepath, 'wb') as f: while True: head = ser.read(3) if len(head) < 3 or head[0] != SOH: continue seq = head[1] data = b'' while True: ch = ser.read(1) if not ch: break if ch[0] == ETX: break data += ch chk = ser.read(1) if not chk: continue if checksum(seq, data) != chk[0]: ser.write(bytes([NAK])) continue if seq == expected_seq: f.write(data) expected_seq ^= 1 ser.write(bytes([ACK]))这段代码里expected_seq就是接收方的状态。重复帧到达时seq != expected_seq,仍然回 ACK,因为发送方需要这个 ACK 才能继续。如果不回,发送方会一直重传,链路卡死。
3. 串行口配置与文件分帧:参数不对,协议白写
协议逻辑写对只是第一步,串口参数和文件分帧策略直接决定实验能不能一次跑通。这一章把串口配置、流控、文件读写和 CFile 的对应关系讲清楚。
3.1 串口参数怎么设:波特率、数据位、停止位、校验位
串口两端必须完全一致,否则收到的全是乱码。常见配置是 115200-8-N-1,即波特率 115200、数据位 8、无校验、停止位 1。如果你用的是老式设备或单片机,9600-8-N-1 更稳。
| 参数 | 常用值 | 注意事项 |
|---|---|---|
| 波特率 | 9600 / 115200 | 两端必须相同,误差超过 2% 会丢帧 |
| 数据位 | 8 | 7 位在传二进制文件时会丢高位 |
| 校验位 | None | 协议层已有校验和,串口校验可关 |
| 停止位 | 1 | 部分设备要求 2,以对端手册为准 |
| 流控 | 无 | 文件传输建议关流控,由协议层控制节奏 |
流控这里有个血泪经验:如果开了 RTS/CTS 但接线不对,串口会直接阻塞,表现为ser.write不返回或超时。实验环境下建议先关流控,确认协议跑通后再按需开启。
3.2 文件分帧与 CFile 读写:二进制模式不能省
文件传输必须用二进制模式打开。如果用文本模式,Windows 下会把0x0D 0x0A转换成0x0A,接收端写出的文件和原文件大小不一致,校验和也对不上。
C++ 里用 CFile 的写法:
CFile srcFile(_T("source.bin"), CFile::modeRead | CFile::typeBinary); CFile dstFile(_T("dest.bin"), CFile::modeCreate | CFile::modeWrite | CFile::typeBinary); BYTE buffer[128]; UINT nRead = srcFile.Read(buffer, 128); while (nRead > 0) { // 组帧发送 buffer 中前 nRead 字节 // 等待 ACK 后继续 nRead = srcFile.Read(buffer, 128); }CFile::typeBinary是关键,不加这个标志,MFC 默认按文本处理,遇到 0x1A 还会当 EOF。Python 里对应的是open(path, 'rb')和open(path, 'wb'),不要用'r'和'w'。
分帧时最后一块可能不足 128 字节,接收端按实际收到的数据长度写入即可,不需要补齐。但帧头里的长度字段如果设计了,要按实际长度填。
3.3 超时与重传次数的工程取值
超时时间不是拍脑袋定的。按公式:TIMEOUT > 帧传输时间 + 接收处理时间 + ACK 传输时间 + 余量。
以 115200 波特率、128 字节数据为例:一帧约 134 字节(含帧头帧尾),传输时间约 11.6ms;接收方处理加写文件约 1ms;ACK 1 字节约 0.09ms。总时间不到 13ms。工程上取 200ms 到 1s 都合理。如果取 10ms,会因为接收方还没处理完就超时重传,导致大量重复帧。
重传次数一般取 3 到 5 次。超过这个次数还没收到 ACK,说明链路已经断开或对端程序崩溃,继续重传没有意义,应该报错退出。
注意:超时时间设得太短会让协议“假重传”,吞吐量暴跌;设得太长则链路断开后要等很久才报错。建议先用示波器或串口助手观察单帧往返时间,再定超时。
4. 避坑与排查:停止等待协议实验里最容易翻车的 5 个点
这一章按“现象 → 原因 → 解决”整理我在串口文件传输实验里踩过的坑,每条都对应真实调试场景。
4.1 现象:接收端文件比源文件大,尾部多出重复数据
原因:接收方没有判断序号,把重传的重复帧也写入了文件。发送方超时重传后,接收方收到相同序号的帧,仍然执行了写操作。
解决:接收方维护expected_seq,只有seq == expected_seq时才写文件并翻转序号;重复帧只回 ACK,不写文件。上面 2.4 的代码已经处理了这一点。
4.2 现象:发送方一直重传,接收方毫无反应
原因:串口参数不一致,最常见的是波特率不同或数据位不同。接收方收到的字节全是乱码,帧头 SOH 匹配不上,直接丢弃。
解决:两端打印各自的串口配置,逐项核对。用串口助手手动发一帧,看接收端能否正确解析。如果串口助手能通,说明是程序配置问题;如果串口助手也不通,检查线序和地线。
4.3 现象:传输到一半卡死,双方都在等
原因:ACK 丢失后发送方超时重传,但接收方已经翻转了expected_seq,把重传帧当重复帧处理,回了 ACK。如果这个 ACK 又丢失,发送方继续重传,接收方继续回 ACK,理论上能恢复。但如果接收方在等数据时收到了自己发的 ACK 回显(串口短接或回环),状态机会混乱。
解决:确认串口没有本地回环。如果是 RS485 半双工,发送和接收要分时切换,不能同时使能。软件上增加“收到非预期字节直接丢弃”的逻辑。
4.4 现象:小文件能传,大文件传到固定位置就出错
原因:文件分帧时用了文本模式,遇到 0x1A(Ctrl+Z)被当作 EOF,或者 0x0D 0x0A 被转换。固定位置出错通常对应文件中某个特殊字节。
解决:发送端和接收端都用二进制模式打开文件。CFile 加typeBinary,Python 用'rb'/'wb'。传输前可以先算源文件 MD5,接收完再算一次对比。
4.5 现象:校验和偶尔对不上,重传后又能过
原因:串口线过长或波特率过高导致误码。停止等待协议能通过重传纠正,但频繁重传说明物理层质量差。
解决:降低波特率到 9600 试试;缩短串口线;检查地线是否接好。如果必须高速传输,考虑加屏蔽线或改用带校验位的串口配置。协议层的校验和只能发现错误,不能纠正,物理层稳定才是根本。
5. 进阶技巧:用日志和 MD5 验证传输完整性
停止等待协议跑通之后,真正要交付的是一个“可验证可靠”的传输结果。我一般会在发送端和接收端各加一份日志,记录每一帧的序号、重传次数和时间戳,最后用 MD5 做端到端校验。
日志格式可以很简单:
import hashlib, time def log_frame(logfile, direction, seq, retry): with open(logfile, 'a') as lf: lf.write(f"{time.time():.3f} {direction} seq={seq} retry={retry}\n") def md5_of_file(path): h = hashlib.md5() with open(path, 'rb') as f: while True: chunk = f.read(4096) if not chunk: break h.update(chunk) return h.hexdigest()发送前算源文件 MD5,接收完算目标文件 MD5,两者一致才认为传输成功。日志里如果发现某一帧retry次数超过 2,说明链路质量在下降,需要检查串口线或降低波特率。
一个具体技巧:把BLOCK_SIZE从 128 逐步调到 256、512,观察重传率变化。如果调大后重传率明显上升,说明接收方缓冲区或处理速度跟不上,应该退回小分块。停止等待协议的吞吐量本来就低,分块大小对总时间的影响远小于重传带来的开销。
我自己的习惯是:任何串口文件传输实验,先跑 1KB 小文件验证协议,再跑 1MB 文件看稳定性,最后用 MD5 确认。不要一上来就传大文件,出了问题连日志都来不及看。希望帮到你。
本文还有配套的精品资源,点击获取