简介:围绕QT框架实现Ymodem文件传输协议的完整示例工程,面向需要掌握串口通信、嵌入式设备固件升级或低带宽环境下可靠文件传输的开发者。Ymodem在Xmodem基础上将传输数据块扩大至一千零二十四字节,并引入双循环冗余校验,本工程以C++与QT完整实现了发送端、接收端以及图形操作界面。压缩包内共四十八个文件,整体约一点六九兆字节,其中包括十一个C++源码文件、七个头文件、一个界面描述文件,还有工程配置文件、构建脚本、调试与发布目录以及可直接运行的执行程序,目录结构符合常规Qt项目布局。代码覆盖串口通信类库操作、Ymodem数据包解析与构造、循环冗余校验、信号与槽异步收发、文件读写等关键模块,发送与接收两个核心类清晰分割了传输流程,注释与工程配置便于二次开发。已有三百八十人学习下载,对研究Ymodem协议在QT中的实际落地与嵌入式升级场景很有参考价值。
1. 用 QT 重写 Ymodem 传输:从串口助手到可靠固件升级的必修课
做嵌入式上位机的开发者,迟早会遇到 Ymodem 协议。无论是给 Bootloader 灌固件、给 4G 模组升级,还是给 FPGA 配置程序,Ymodem 几乎是串口传输的默认选项。这套 QT Ymodem 资源是一个完整可编译的 Qt Widgets 工程,把协议帧解析、CRC 校验、超时重传、进度反馈做成了现成类,拖进项目就能用。
它解决的核心问题很直接:串口助手只能手动选文件、没有校验、没有断点续传,量产时根本顶不住。而 Ymodem 自带 16 位 CRC、应答机制和文件名/大小协商,尤其适合给 Bootloader 烧录这种必须保证完整性的场景。适合正在写上位机、或者打算把串口传输从「能跑」改造成「可靠」的 C++/Qt 开发者。这份资源把协议细节封装得比较干净,拿到手先看透,再决定是自己改还是直接集成。
2. Ymodem 协议核心:帧结构、状态机与握手时序
2.1 帧格式:SOH/STX 区分短帧长帧,序号块协议必须按位吞
Ymodem 协议本身不复杂,但帧格式的细节决定了你能不能稳定通信。协议规定每个数据块以 SOH(0x01)开头代表 128 字节净荷,以 STX(0x02)开头代表 1024 字节净荷。帧头后面紧跟两个字节:一个是包序号,范围 0-255 循环;另一个是序号的按位取反值,接收端用它校验序号是否被破坏。然后是净荷数据,最后跟两个 CRC16 字节,低字节在前。
这个格式里最容易被新手忽略的是:Ymodem 的第一个文件信息包固定用 SOH(128 字节)发送,而后续数据包可以用 STX(1024 字节)提高吞吐。收到 CRC 字节时,必须把两个字节按「低字节在前」拼成 16 位值再比较,顺序搞反了就是每包都报 CRC 错误。
帧头的关键宏可以这么定义:
#define P_SOH 0x01 // 128 字节短帧 #define P_STX 0x02 // 1024 字节长帧 #define P_EOT 0x04 // 结束传输 #define P_ACK 0x06 // 确认 #define P_NAK 0x15 // 否认 #define P_C 0x43 // 'C',请求发送这些字节构成了全部交互原语。实际写解析器时,我是用一个 QByteArray 做接收缓冲,然后按状态机逐字节消费,而不是一次性期望读满整帧。原因很实际:串口底层会把 Ymodem 帧拆成若干 TCP 段或串口数据块到达,你不能假设一次 read 就拿到完整帧。
2.2 状态机:握手、传输、结束三段流程决定代码结构
Ymodem 的交互逻辑可以拆成源端状态机:
- 等待接收端发出字符 C(0x43),表示接收端支持 CRC 校验。如果收到 NAK,表示接收端只支持校验和,这时源端必须改用 checksum 模式。
- 源端发送「文件信息包」,内容包括文件名、文件大小,用空字节填充到 128 字节。这时 packet number 固定为 0。
- 接收端回复 ACK 后再回复一个 C,代表「信息包有效,请发数据」。
- 源端按 1024 字节分包发送数据,每包等 ACK。如果收到 NAK 就重发当前包。
- 数据发完后,源端发送 EOT。接收端回复 NAK 表示「请确认结束」,源端再发一次 EOT,接收端回复 ACK,源端发一个空的信息包(文件名为空),接收端回复 ACK,整个会话结束。
第二步里接收端回 ACK 后又回 C,这个「双字节确认」不是协议强制的,但绝大多数 Ymodem 实现都会按这个模式走。你的时序逻辑必须兼容:发完数据包后可能收到 ACK,也可能收到 C。如果只带着 ACK 一个状态去等,很容易在收完最后一个数据块后卡死。
2.3 代码落地:Qt 串口类封装与逐字节帧解析
在 QT 里,我一般用 QSerialPort 的 readyRead 信号配合 QByteArray 缓冲,而不是用 waitForReadyRead 阻塞读。Ymodem 传输是长耗时操作,阻塞读会卡死 UI 线程。推荐写法是把串口读写全部丢到工作线程里,用信号与槽把进度传回主线程。
解析帧的示例代码:
void YmodemSession::onReadyRead() { buffer.append(serial->readAll()); while (buffer.size() >= 3) { // 至少能读帧头+序号+反码 quint8 head = (quint8)buffer.at(0); quint8 seq = (quint8)buffer.at(1); quint8 seqComp = (quint8)buffer.at(2); int payloadLen = 0; if (head == P_SOH) payloadLen = 128; else if (head == P_STX) payloadLen = 1024; else { // 处理 ACK/NAK/EOT/C 等控制字节 handleControlByte(head); buffer.remove(0, 1); continue; } if (buffer.size() < 3 + payloadLen + 2) { return; // 数据没到齐,等下一次 readyRead } if ((quint8)(seq ^ seqComp) != 0xFF) { sendByte(P_NAK); buffer.remove(0, 3 + payloadLen + 2); continue; } QByteArray payload = buffer.mid(3, payloadLen); quint16 crcRecv = (quint8)buffer.at(3 + payloadLen) | ((quint8)buffer.at(3 + payloadLen + 1) << 8); quint16 crcCalc = qCrc16(payload, payloadLen); if (crcRecv != crcCalc) { sendByte(P_NAK); buffer.remove(0, 3 + payloadLen + 2); continue; } emit packetReceived(seq, payload); buffer.remove(0, 3 + payloadLen + 2); } }这段代码里 seq 和 seqComp 的异或检查是用来拦截序号被干扰的包;CRC 算出来不符就回 NAK,让源端重发。有个容易被忽视的边界点:如果缓冲区开头是一个完整帧的中间部分,直接按字节找帧头是会误判的。实际工程里更好的做法是先同步帧头,再在这个基础上逐字节移位找合法头。Ymodem 帧里数据区也可能出现 0x01 或 0x02,所以必须依赖序号和反码同时成立才判定帧头有效。上面这份代码在可对抗正常线路噪声的前提下,结构清晰度优先,适合作为教学基线。
3. 传输实现:QT 串口收发、CRC 校验与文件分包
3.1 文件分包与进度计算:QFile 读取与包序号管理
Ymodem 发送端把文件切成若干 1024 字节块,每个块拥有一个包序号。序号从 1 开始计数,超过 255 回到 0(按 uint8 自然回绕)。包序号回绕是个常被忽略的坑:对于大文件,第 256 块的序号是 0,接收端如果按「序号必须递增」来做校验,就会把正常帧误判为错误。
发送分包代码:
bool YmodemSender::sendFileChunk(QFile &file, int chunkIndex, quint8 &seq) { QByteArray chunk = file.read(CHUNK_SIZE_1024); if (chunk.isEmpty()) return false; QByteArray frame; quint8 header = (chunk.size() == CHUNK_SIZE_1024) ? P_STX : P_SOH; frame.append((char)header); frame.append((char)seq); frame.append((char)(~seq)); frame.append(chunk); if (chunk.size() < CHUNK_SIZE_1024) { frame.append(QByteArray(CHUNK_SIZE_1024 - chunk.size(), 0x00)); } quint16 crc = qCrc16(frame.constData() + 3, CHUNK_SIZE_1024); frame.append((char)(crc & 0xFF)); frame.append((char)((crc >> 8) & 0xFF)); lastSeq = seq; sendFrame(frame); seq = (seq + 1) & 0xFF; return true; }进度计算不按「已发字节/总字节」直接乘 100,因为最后一个块通常不满 1024,而且源端还要等 ACK,所以我在实现里用「已确认块数 / 总块数」作为进度。flush 到进度条的信号频率控制在每 2% 一次,否则大文件会上位机 UI 刷新过于频繁,QProgressBar 反而显得卡顿。
3.2 CRC 校验:CRC16/CRC32 选择与 QT 实现
Ymodem 协议标准使用 CRC-16(CCITT),多项式 0x1021,初始值 0x0000。注意它和 CRC-32(用于 ZIP 等)完全不同,两者混用会导致每帧都校验失败。工程实现里我用查表法,256 项表静态生成,运行时避免每字节按位循环,速度能差到 5-10 倍。
查表法实现:
static quint16 crc16_table[256] = {0}; static void crc16_init() { for (int i = 0; i < 256; ++i) { quint16 crc = i << 8; for (int j = 0; j < 8; ++j) { crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) : (crc << 1); } crc16_table[i] = crc; } } quint16 qCrc16(const char *data, int len) { quint16 crc = 0x0000; for (int i = 0; i < len; ++i) { crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ data[i]) & 0xFF]; } return crc; }两个容易翻车的地方。第一,多媒体文件、压缩包这类 0x00 密集的数据,CRC16 碰撞概率比文本高,但实际固件场景里 CRC16 仍够用,只要加上包序号和反码校验,基本能挡住线路翻转错误。第二,CRC 计算要覆盖整个净荷区(含填充字节),不能只算文件有效字节。拿末块举例:文件大小恰好 1024 整数倍时没有填充;不整除时,填充的 0x00 必须参与 CRC 计算,接收端才能得到一致结果。
3.3 重传与超时:QTimer 驱动的错误处理逻辑
Ymodem 的可靠传输建立在超时重传上。源端发出数据块后启动一个定时器,典型值 1000-3000ms。超时未收到 ACK/NAK,就重发当前块。超过最大重试次数(常用 10 次)则报错终止会话。
超时处理代码:
void YmodemSender::onTimeout() { if (retryCount >= MAX_RETRIES) { emit errorOccurred(tr("重传超限,传输中止")); stopSession(); return; } ++retryCount; sendFrame(pendingFrame); timeoutTimer.start(currentTimeoutMs); }接收端的接收等待也要有超时保护。我见过不少上位机卡死的场景:等待接收端发 C 时没有超时,源端一直等,用户只能拔线重来。正确做法是分阶段设定不同的超时——等待握手 C 用 3000ms,等待 ACK 用 1000ms。这样出错时能在 UI 上给用户明确的「握手失败」或「ACK 超时」提示,而不是转圈圈。
4. Ymodem 在 QT 里的避坑清单:串口缓存、ACK 丢包与文件名编码
4.1 串口缓存区残留导致首包解析失败
现象:程序启动后第一次传输,往往收到「CRC 校验失败」或「帧头错误」,但再次重试就成功了。
原因:串口缓冲区在会话建立前可能残留上一次传输的数据,或者接收端上电时发出了一些调试打印。Ymodem 初始化后,源端发出的第一个 C 被这些垃圾数据干扰,接收端解析乱了。
解决:在进入 Ymodem 会话前,做两件事。第一,清空串口缓冲:serial->flush(); serial->clear(QSerialPort::AllDirections);。第二,在发送第一个 C 之前加 100-200ms 延时,然后先读走缓冲区里所有能读到的字节。这套操作我写在了beginSession()里,每次开始传输前强制走一遍。具体延时长度取决于你的串口驱动行为,我一般按 200ms 保底。
4.2 EOT 结束握手中的 NAK 陷阱
现象:文件数据全部确认完毕后,源端发 EOT,接收端会先回一个 NAK,再等源端发第二个 EOT。如果上位机在收到第一个 NAK 后把会话判定为「出错」,就直接中断了,明明数据都已经传完。
原因:这是 Ymodem 协议故意设计的双重确认流程,对端会先发 NAK 来确认源端真正想结束,避免误触发。
解决:在状态机里把「收到 NAK」和「当前阶段是等待结束确认」区分开。数据阶段的 NAK 触发重传,结束阶段的 NAK 触发二次 EOT。二次 EOT 后对端回 ACK,再发一个空文件名信息包,收到 ACK 才算完整结束。漏掉最后那个空包的对端会一直卡在等待状态,这是我在联调时踩得比较深的坑。
4.3 文件名编码:中文文件名在部分终端下乱码
现象:文件名含中文时,接收端显示的进度条信息是乱码,严重时接收端直接拒绝进入数据阶段。
原因:接收端 Bootloader 的 Ymodem 解析器可能只支持 ASCII,特别是某些 MCU 端开源 Bootloader,按字节解析文件名,不处理 UTF-8 多字节。
解决:上位机做一层文件名转换,发送信息包时优先使用纯 ASCII 文件名(如fw_20241015.bin),并把大小写在包内。如果业务上必须显示中文,把中文放到 UI 层展示,实际发送的名字仍用 ASCII。这个策略能避开百分之九十的终端乱码问题。另外,文件路径里的中文会导致 QFile 打开失败,所以打开文件时最好用QFileInfo::absoluteFilePath()并确保路径编码走系统本地编码。
4.4 CRC 字节序:低字节在前还是高字节在前
现象:接收端算出的 CRC 和收到的 CRC 总是不一致,但把接收到的两个字节交换顺序后就能匹配。
原因:Ymodem 标准写入帧时是先写 CRC 低 8 位,再写高 8 位。有些上位机实现为了省事直接把 uint16 用大端写进 QByteArray,设备端解析用的是小端拼法,于是永远对不上。
解决:统一以「发送时低字节在前,接收时低字节拼接」为准。如果对端 Bootloader 是按大端处理的,那就调整发送端的字节序,诀窍是抓一帧数据,手动按两种字节序算一遍,看到哪个能匹配就对哪个做适配。实际写代码时我会单独抽一个buildCrcField(crcValue)函数,方便切换字节序,而不是在发送逻辑里直接写死。
5. 从 Ymodem 到量产:上位机、Bootloader 与日志审计
5.1 用状态码驱动 UI:进度条、取消操作与会话占位
Ymodem 会话是异步长跑流程,UI 层不能用裸的成员变量标记当前状态,因为槽函数重入会把状态搅乱。我习惯用一个枚举状态码驱动 UI:
enum class SessionState { Idle, // 空闲 Handshaking, // 等待 C SendingMeta, // 发送文件名包 SendingData, // 发送数据 EndHandshake, // 等待 EOT 二次确认 Done, // 完成 Error // 异常 };- 状态每次变化时,发一个
stateChanged(state)信号给 UI 层,UI 根据状态启用/禁用按钮。 - 取消操作不是直接 kill 线程或调用
serial->close()。正确做法是置cancelRequested标志,协议栈在每个等待点检查这个标志,然后做干净退出——发一个 CAN 字节(0x18)告诉对端取消,再关闭串口。直接关串口会让对端一直等 ACK,直到超时。
进度条更新建议放在独立的 Slot 里,用队列连接(Qt::QueuedConnection)从工作线程传回主线程,避免跨线程 UI 崩溃。
5.2 批量升级:多文件传输与会话日志
实际量产场景很少只升级一个文件,Bootloader 往往需要烧录多个分区镜像,比如 Boot、App、参数区、字库。Ymodem 协议天然支持一个会话传多个文件:上一个文件结束的空信息包之后,对端如果继续发 C,源端就可以发下一个文件的信息包,文件名非空即可。
批量升级的工程要点:
- 文件列表用 JSON 或 INI 配置,按顺序逐个传输。
- 每传完一个文件记录 SHA256(注意这是额外措施,Ymodem 本身不提供全文件校验),烧录完成后上位机把接收端回报的校验值和本地算的做比对。
- 日志用带时间戳的文本输出,每个状态变化一行,每个重传记录一行。日志落盘用异步写文件,不要在 UI 线程同步 IO。
- 配套做一个发送端「整包重传」功能:某一段传输中途失败,可以跳过已完成的文件段,只重新发送未完成部分。Ymodem 没有断点续传语义,但把文件拆小成段,就能用文件级跳过实现类似效果。
5.3 与 Bootloader 联调:超时参数怎么设
对端 Bootloader 不一样,超时参数不能照搬。常见现象:对端在等待文件名包时,如果 500ms 内没收到就回 NAK,而你的源端在等 C 时可能因为串口驱动延迟,500ms 内没把 C 发出来,于是反复 NAK。
我在联调时用了一组经验参数:
| 阶段 | 超时值 | 重试次数 | 备注 |
|---|---|---|---|
| 等待握手 C | 3000ms | 10 | 给对端足够时间初始化 |
| 等待信息包 ACK | 3000ms | 5 | 对端可能在做 flash 擦除 |
| 等待数据块 ACK | 1000ms | 10 | 正常工作数据包耗时短 |
| 等待结束确认 | 2000ms | 3 | 对端需要后处理 |
flash 写时间长的对端,数据块 ACK 超时要延长到 3000ms,否则对端还在擦除扇区,你的超时先触发重发,重发帧和正在写 flash 的行为冲突,很容易把对端状态机搞乱。鉴别办法:看对端日志里写 flash 的耗时,如果接近 900ms,超时设 1000ms 就是临界值,果断提到 2000ms。
6. 验证与压测:模拟对端、异常注入与收尾习惯
拿到 Ymodem 工程后,第一件值得做的事不是接真机,而是写一个模拟对端做闭环验证。用 Python 的 pyserial 起一个虚拟接收端,按协议回 ACK/NAK,跑一遍完整传输,把收到的数据存成本地文件,再与源文件做二进制对比。这样可以快速验证帧封装、CRC、序号回绕逻辑,而不需要反复烧 Bootloader。
异常注入测试我会做三件事:第一,任意丢弃一个数据块的 ACK,观察源端是否在超时后重发且序号一致;第二,在传输中途拔掉串口线再插回,检查会话能否在超时后报出明确错误,而不卡死;第三,故意篡改一帧里的一个字节,模拟强干扰,确认接收端能通过 CRC 拒绝错误块。这三大场景跑完,协议栈的可靠性基本有底。
日志习惯上,我会在每次传输结束后输出一行摘要,包含总包数、重发包数、耗时、平均传输速率。这个摘要比进度条更能反映真实质量。如果重发率超过 1%,基本能断定串口参数或物理链路有问题。从那以后,我每次做 Ymodem 联调都强制把原始收发包序列抓下来,存成十六进制文本,哪怕当场没有异常,之后排查玄学问题时这些日志就是唯一的后悔药。
这份 QT Ymodem 资源不一定每个细节都符合你的设备端行为,但用上面的验证流程先跑通,再针对对端表现调整超时和字节序,整个集成就顺畅多了。希望帮到你。
本文还有配套的精品资源,点击获取