news 2026/9/16 10:00:38

Qt实现Ymodem串口固件升级:帧格式、状态机与联调实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt实现Ymodem串口固件升级:帧格式、状态机与联调实战

简介:这是一套基于Qt框架的Ymodem协议通信实现源码,面向需要掌握串口文件传输技术的C++/Qt开发者,适用于桌面、嵌入式及移动终端的串口通信场景。资源共21个文件,压缩包仅586KB,包含5个cpp、4个h源码文件,以及pro工程、ui界面、协议参考PDF(XMODEM-YMODEM-Protocol-Reference)、README说明与多份zbak备份,并内置一个备份zip便于回归对比,目录结构清晰,适合对照学习和二次开发。Ymodem.cpp、YmodemFileTransmit/Receive等模块演示了数据分块、编号、校验重传、文件属性传递与批量传输等核心机制,通过多个类封装串口通信与协议处理逻辑,并考虑异常恢复策略,可帮助读者深入理解Qt下QSerialPort的配置、数据流监控与事件循环管理。目前已有112人学习,适合正在做串口升级、固件传输或数据采集项目的开发者参考借鉴。

1. 串口固件升级场景里,为什么是 Qt 加 Ymodem 协议

给 STM32F411 这类 MCU 做串口固件升级,Qt 加 Ymodem 协议几乎是固定搭配:bootloader 端已经把 Ymodem 的握手和帧格式写死在 ROM 里,上位机没有讨价还价的余地;而 Qt 的 QSerialPort 恰好把串口读写封装得足够干净,剩下的核心工作就集中在按协议拼帧、拆帧、确认和超时处理上。多数人一开始以为难点在界面和进度条,实际最花时间的是协议状态机——什么时候发 'C'、什么时候回 ACK、超时重发几次,这些规则不遵守,bootloader 就永远停在等待状态。下面按发送端、接收端两条线讲透帧格式、CRC16、超时重传与源码关键路径,最后给出对接 STM32 系列 bootloader 的调参经验。适合要在短时间内交付升级工具,或者想自己实现协议而不是拼凑第三方组件的 Qt 开发者。

2. Ymodem 协议帧格式与状态机:先把通信骨架立起来

2.1 SOH 与 STX 帧的区别,以及序号反码的双重校验

Ymodem 继承了 Xmodem 的基本帧结构,但把数据块长度扩展出两种。128 字节块用 SOH(0x01)作帧头,1024 字节块用 STX(0x02)作帧头。一帧的完整布局是:帧头、块序号、序号反码、数据负载、CRC16 校验值。序号从 0 开始逐块递增,到 255 之后回绕到 0;因为序号只有 8 位,协议用反码字段做完整性兜底,反码等于 0xFF 减去序号,接收端把两者相加,等于 0xFF 才继续解析。

字段长度说明
帧头1 字节0x01 表示 128 字节块,0x02 表示 1024 字节块
序号1 字节从 0 开始累计,255 后回绕
序号反码1 字节0xFF - 序号,接收端校验两者相加等于 0xFF
数据128 或 1024 字节短块用于数据块 0,长块用于正文
CRC162 字节CCITT 多项式 0x1021,初值 0,高字节在前

CRC16 的算法是按位迭代的 CCITT 变体,和 Modbus 的 CRC16、以太网的 CRC32 都不是一回事。写实现时每个字节要先左移 8 位异或进当前 CRC,再迭代 8 次,初值固定为 0x0000。发送端算一遍填进帧尾,接收端对同一段负载再算一遍比对,不一致就说明这帧在串口线上被干扰了。

quint16 crc16(const QByteArray &data) { quint16 crc = 0x0000; // Ymodem 初值为 0 for (int i = 0; i < data.size(); ++i) { crc ^= static_cast<quint8>(data.at(i)) << 8; for (int j = 0; j < 8; ++j) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; // CCITT 多项式 else crc <<= 1; } } return crc; }

这里初值必须是 0x0000,不能换成 Modbus 常用的 0xFFFF,否则和 bootloader 端校验永远对不上。串口波特率不高时按位实现完全够用,代码还便于和 bootloader 的 C 实现逐行比对。

数据块 0 的特殊身份:文件名与大小的载体

数据块 0 是整个传输的第一帧,负载固定 128 字节,但内部有字段约定:开头是文件名字符串,紧跟一个 0x00,然后是文件大小的十进制字符串,再一个 0x00,剩余字节全部补 0x00。bootloader 从这一帧里解析出固件文件名和长度,所以构造时用 QFileInfo 单独取 basename,不能带目录路径。

2.2 握手与 EOT 收尾:接收方先发 'C',结束要答两遍

Ymodem 的握手规则决定了接收端处于主导地位。接收端上电后反复发送 0x43(字符 'C'),表示“我用 CRC 模式,准备好了”,发送端收到 'C' 后才开始发数据块 0。这里最容易犯的错是把发送端当主动方,程序一启动就 open 串口发数据,结果 bootloader 端已经等超时放弃了。

传输结束的序列也不简单。发送端发完最后一个数据块并收到 ACK 后,发出 EOT(0x04);接收端回 ACK;发送端再发一次 EOT;接收端这次回 ACK 之后还要补发一个 'C';发送端收到这组信号后发出空数据块 0(128 字节全零),接收端回 ACK,整个会话才关闭。有的 bootloader 对第二遍 EOT 的处理略有出入,但主流实现都是这个序列,联调时把每个字节打出来看比只看“传输完成”四个字靠谱得多。

2.3 五个状态覆盖 Ymodem 传输生命周期

无论发送端还是接收端,把整条链路画成状态机都逃不开五个基本状态。发送端是 Idle、WaitC、SendBlock0、SendData、SendEOT;接收端对应成 WaitBlock0、WaitData、WaitEOT、Finish,外加各自初始的空闲态。状态机的好处是让超时和错误处理有明确归属:任何状态卡住,都能知道该重发什么。

发送端状态触发条件动作
Idle收到 start 指令打开文件,清空日志
WaitC串口收到 'C'发送数据块 0
SendBlock0收到 ACK校验文件名长度,进入正文分块
SendData每块收到 ACK读下一块;文件读完则发 EOT
SendEOT收到 ACK + 'C'发空块 0,等待最终 ACK

接收端状态机里多一个分支:约定时间内没收到完整帧,就把当前应发的响应字符再补发一次——首帧前补发 'C',数据阶段补发 NAK,而不是让会话死等。这是 Ymodem 能扛住串口干扰的关键机制,第 4 章给出具体实现。

3. Qt 发送端实现:QSerialPort 分块发送与 CRC16 计算

3.1 串口初始化参数与发送端类骨架

发送端的第一步是配置串口。bootloader 侧的 Ymodem 通常固定 115200-8-N-1,无流控,这个配置要和 bootloader 源码保持一致,改波特率必须两端同时改。QSerialPort 的初始化放在 YmodemSender 类里,构造时传入端口名和波特率,避免把串口读写散落在界面代码里。

class YmodemSender : public QObject { Q_OBJECT public: explicit YmodemSender(const QString &portName, qint32 baudRate = 115200, QObject *parent = nullptr); bool openSerial(); bool sendFile(const QString &filePath); // 阻塞式发送,建议在 QThread 中调用 void abort(); // 置取消标志,发送循环里检测 private: bool writeAndWaitAck(const QByteArray &frame, int retries = 10); QByteArray buildBlock0(const QString &fileName, qint64 fileSize); QSerialPort *serial = nullptr; QByteArray rxBuffer; bool aborted = false; };
bool YmodemSender::openSerial() { serial->setBaudRate(115200); serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::NoParity); serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl); return serial->open(QIODevice::ReadWrite); }

setBaudRate、setDataBits、setParity、setStopBits、setFlowControl 五件套分别对应波特率、数据位、校验位、停止位和流控。流控这里必须关掉,STM32 的 bootloader 一般不做 RTS/CTS 握手,开着流控会导致发送端写完数据后等不到对方腾出缓冲区,卡在第一次读取。

3.2 构造数据块 0:把文件名和文件大小塞进 128 字节

数据块 0 的负载固定 128 字节,但有效信息只有文件名和大小两段。构造时关键点有三个:文件名只取 basename 不带目录;大小用十进制字符串而不是二进制整数;三段之间用 0x00 分隔,尾部补零到 128 字节。

QByteArray YmodemSender::buildBlock0(const QString &fileName, qint64 fileSize) { QByteArray payload(128, 0x00); QByteArray info = fileName.toUtf8(); info.append(char(0)); info.append(QByteArray::number(fileSize)); // 十进制字符串 info.append(char(0)); payload.replace(0, qMin(info.size(), 128), info); QByteArray frame; frame.append(char(0x01)); // SOH,128 字节块 frame.append(char(0x00)); // 序号 0 frame.append(char(0xFF)); // 反码 0xFF - 0 frame.append(payload); quint16 crc = crc16(payload); frame.append(char((crc >> 8) & 0xFF)); // CRC 高字节在前 frame.append(char(crc & 0xFF)); return frame; }

payload.replace 的第三个参数是替换长度,这里用 qMin 保证信息段超长时不越界。文件名带中文时建议统一用短 ASCII 名,很多 bootloader 的解析器只按字节找 '\0',中文 UTF-8 多字节序列不报错,但固件清单里会显示乱码,排查时容易误判。

3.3 ACK/NAK 驱动的发送循环与 EOT 收尾

发送循环的核心是“写一帧、等 ACK、未确认则重发”。waitForReadyRead 是阻塞调用,放 GUI 线程里会卡界面,所以 sendFile 整体丢到 QThread 里跑,主线程通过信号更新进度条;嫌线程麻烦也可以把发送逻辑拆进 QTimer 槽函数,按块驱动。发送端关键参数见表:

参数推荐值说明
帧间超时3000 ms等待 ACK 的最长时间
重试上限10 次单帧连续失败后终止会话
正文块大小1024 字节首块和块 0 用 128 字节
填充字节0x1A最后一块不足时补满
bool YmodemSender::writeAndWaitAck(const QByteArray &frame, int retries) { for (int i = 0; i < retries; ++i) { if (aborted) return false; serial->write(frame); serial->flush(); if (waitForAck(3000)) return true; // 收到 ACK qDebug() << QString::fromLatin1(frame.toHex(' ')); } return false; } bool YmodemSender::waitForAck(int timeout) { while (serial->waitForReadyRead(timeout)) { rxBuffer += serial->readAll(); for (int i = 0; i < rxBuffer.size(); ++i) { const char c = rxBuffer.at(i); if (c == 0x06) { rxBuffer.remove(0, i + 1); return true; } if (c == 0x15) { rxBuffer.remove(0, i + 1); return false; } // NAK 重发 if (c == 0x18) { aborted = true; return false; } // CAN 取消 } } return false; }

waitForAck 对 ACK、NAK、CAN 三个响应字节做了区分:0x06 直接返回成功,0x15 返回 false 触发重发,0x18 视为对端取消。串口是流式数据,一次 waitForReadyRead 可能只收到半个响应,所以必须先累积到 rxBuffer 再逐字节解析,不能假设一次 readAll 就是一帧完整数据。

正文发送时每块读 1024 字节,最后一块不足 1024 字节用 0x1A 填充,序号从 1 开始,由 buildFrame 内部做 & 0xFF 回绕。全部数据块收到 ACK 后,依次处理:发 EOT 等 ACK、再发 EOT 等 ACK 和 'C'、最后发 128 字节全零空块等 ACK。两遍 EOT 的间隔不要超过 3 秒,部分 bootloader 对第二遍 EOT 的等待窗口很短。

提示:发送端收到 CAN 后不要再发任何数据,直接关闭串口并向上层上报“对端取消”。继续写只会让对端以为还在传输中。

4. Qt 接收端实现:超时重传、CRC 校验与源码关键路径

4.1 readyRead 缓冲累积与三秒超时重发机制

接收端通常放在 QObject worker 里,靠 readyRead 信号驱动,不能像发送端那样阻塞读。核心问题和发送端一样是字节对齐:一包串口数据可能只到半个帧,必须攒够了再解析。做法是在 readyRead 槽里把所有可读字节 append 进 rxBuffer,再调 processBuffer;processBuffer 根据当前状态判断完整帧长度,长度不够就 return,等下一次 readyRead 到来。

void YmodemReceiver::onReadyRead() { rxBuffer += serial->readAll(); processBuffer(); } void YmodemReceiver::processBuffer() { while (true) { const int need = (waitingBlockSize == 1024) ? 1029 : 133; if (rxBuffer.size() < need) return; currentFrame = rxBuffer.left(need); const quint8 type = quint8(currentFrame.at(0)); if (type != 0x01 && type != 0x02 && type != 0x04) { rxBuffer.remove(0, 1); // 帧头错,丢一个字节重新对齐 continue; } handleFrame(); // 按状态机处理 rxBuffer.remove(0, need); } }

1029 是 1024 字节帧的总长(1 帧头 + 1 序号 + 1 反码 + 1024 数据 + 2 CRC),133 是 128 字节帧的总长。帧头对不上时只丢一个字节而不是整帧,是为了应对串口线上前一个字节丢失导致的整体错位。超时机制用 QTimer:每收到完整帧并成功处理就重启定时器,定时器超时说明对端没回应,此时补发当前状态应发的字符。

4.2 CRC16 校验失败怎么回应:NAK 重传还是 CAN 放弃

接收端对每一帧做三重检查:帧头是 SOH/STX、序号加反码等于 0xFF、CRC16 匹配。前两项是格式检查,第三项是数据完整性检查。CRC 必须在整个负载上算,包括发送端填的 0x1A 填充字节,因为这些字节同样参与了发送端的计算。

bool YmodemReceiver::verifyFrame(const QByteArray &data) { const quint8 type = quint8(data.at(0)); const quint8 seq = quint8(data.at(1)); const quint8 comp = quint8(data.at(2)); if (type != 0x01 && type != 0x02) return false; if (quint8(seq + comp) != 0xFF) return false; const int payloadSize = (type == 0x02) ? 1024 : 128; const QByteArray payload = data.mid(3, payloadSize); const quint16 expect = (quint16(quint8(data.at(3 + payloadSize))) << 8) | quint16(quint8(data.at(4 + payloadSize))); return crc16(payload) == expect; }

校验失败的回应策略按阶段区分。数据块 0 阶段失败,回 NAK 让发送端重发。正文阶段失败同样回 NAK,但接收端要保持当前期望序号不变,直到收到序号正确的帧。同一帧连续失败超过 10 次,回两个 CAN(0x18)终止会话——连续失败基本说明链路质量已不可用,继续重试只会无限循环;两个 CAN 是协议约定的放弃信号,对端收到后也会主动退出。接收端的响应映射可以汇总成一张表:

接收端阶段成功响应失败响应超时补发
等待块 0ACK + 'C'NAK'C'
等待数据ACK + 'C'NAKNAK
收到第一遍 EOTACK-ACK
收到第二遍 EOTACK + 'C'-ACK
收到空块 0ACK--

4.3 从 'C' 到落盘:接收端源码走读

接收端完整路径按五个节点走:启动发 'C' 进入等待块 0;收到块 0 解析文件名和大小,创建 QFile;回 ACK 和 'C' 进入正文;循环收数据帧,CRC 通过就写盘并回 ACK;处理 EOT 序列。handleFrame 是这段逻辑的中枢,下面是关键分支。

void YmodemReceiver::handleFrame() { const quint8 type = quint8(currentFrame.at(0)); if (type == 0x04) { // EOT serial->write(QByteArray(1, char(0x06))); if (receivedEot) { serial->write(QByteArray(1, char(0x43))); // 第二次 EOT 后补发 'C' return; } receivedEot = true; return; } if (!verifyFrame(currentFrame)) { serial->write(QByteArray(1, char(0x15))); // NAK 请求重发 return; } const quint8 seq = quint8(currentFrame.at(1)); const int payloadSize = (type == 0x02) ? 1024 : 128; const QByteArray payload = currentFrame.mid(3, payloadSize); if (seq == 0) { if (payloadIsAllZero(payload)) finishTransfer(); // 全零空块,会话结束 else parseBlock0(payload); // 提取文件名与大小,创建文件 } else { file->write(payload); // 非空块直接落盘 bytesWritten += payload.size(); emit progressChanged(bytesWritten, totalSize); } serial->write(QByteArray(1, char(0x06))); // ACK serial->write(QByteArray(1, char(0x43))); // 请求下一块 }

parseBlock0 内部把 payload 按 '\0' 切开,第一段是文件名,第二段是大小,随后 QFile 以 WriteOnly 打开。正文最后一块如果刚好是 1024 字节的整数倍,发送端不会发填充块,直接进 EOT,所以不能在收到 ACK 后立刻 close 文件,必须等空块 0 或 EOT 序列结束。finishTransfer 里做 fflush 等价操作(调用 file->flush())再关闭文件,避免 Qt 缓冲导致固件文件尾缺字节。进度条上报放在 file->write 之后,用 bytesWritten 除以块 0 解析出的 totalSize,这就是 Qt 自定义进度条与 Ymodem 数据帧之间的衔接点。

5. 双端联调技巧:帧对齐日志、超时参数与 bootloader 约定

5.1 用十六进制日志对齐收发双方帧边界

联调时第一件事是让收发两端打印全量十六进制流,而不是打印“发送成功”“接收完成”这类状态文字。QSerialPort 侧加两行日志就能定位绝大多数问题:

qDebug().noquote() << "TX:" << QString::fromLatin1(frame.toHex(' ').toUpper()); qDebug().noquote() << "RX:" << QString::fromLatin1(rxBuffer.toHex(' ').toUpper());

对着日志逐帧数:块 0 应该是01 00 FF开头、末尾 4 位是 CRC 高字节在前;正文块 1024 字节对应02 01 FE开头。帧边界对不齐时,优先怀疑接收端把单个字节当成一帧处理了,改成像第 4 章那样先攒缓冲再解析。CAN 字符 0x18 和 CAN 总线协议无关,日志里看到连续两个 0x18 说明有一侧主动放弃了。

5.2 三个必调参数:超时、重试上限与块大小回退

三个参数按优先级调:超时 3000 ms 是通用值,短距离 USB 转串口可以压到 1000 ms,走蓝牙透传或无线模块则要放宽到 5000 ms,否则正常延迟也会触发 NAK 重传。重试上限 10 次是针对干扰严重的产线环境,实验室里可以降到 3 次,快速暴露链路问题。块大小回退是兼容老 bootloader 的关键:如果连续收 NAK 且日志显示发送端只发 0x02 开头的帧,改成 128 字节 SOH 块重试,部分早期 bootloader 只实现了 Xmodem 短块接收。

5.3 与 bootloader 对接:固件大小、文件补零与串口缓冲

和 bootloader 对接时最容易翻车的是固件大小校验。bootloader 会拿块 0 里的十进制大小和实际接收字节数比对,所以 QFile 打开的固件必须真实可读,不能发完文件还在 QFile 缓冲里没落盘。有页面擦除限制的 MCU(NXP 的 FlexSPI、STM32 的双 Bank 场景)还可能要求固件大小是 4 KB 或 64 KB 的整数倍,上位机构造块 0 时直接上报补齐后的大小,并读文件时补 0xFF 填充。串口缓冲方面,关闭流控后还要注意 Windows 下 CH340/CP2102 的驱动缓冲,发送端连续 write 大块数据后要 flush 并等待 ACK,接收端则要保持 readyRead 槽函数轻量,避免在解析期间丢字节。

对接完成后的验证方法很直接:升级一遍旧固件,再把升级后的版本号通过另一个协议读回来比对;如果 bootloader 支持回滚,再升一次旧版本确认双向链路对称。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 10:00:37

AI实战项目:智能会议纪要生成器开发全流程

1. 项目背景与动机去年夏天&#xff0c;我在LinkedIn上看到一份令人心动的AI工程师招聘启事&#xff0c;要求栏里赫然写着"至少2个完整AI项目经验"。作为刚转行数据科学的fresh graduate&#xff0c;我的简历上只有几个课程作业和Kaggle比赛。那一刻我突然意识到&…

作者头像 李华
网站建设 2026/9/16 10:00:13

Win10下用conda搭建PyTorch GPU环境完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 10:00:06

C++/Qt/OpenCV实战:RNAM灰度图像分割器与区域生长算法

简介&#xff1a;C结合Qt与OpenCV实现的RNAM灰度图像分割器&#xff0c;是一款面向计算机专业学生的高分课程设计/毕业设计源码项目&#xff0c;适合正在完成期末大作业或希望练习图像分割实战的初学者。项目基于真实课程设计流程开发&#xff0c;经导师指导并获99分评价&#…

作者头像 李华
网站建设 2026/9/16 9:58:04

Java集合索引映射优化:Stream API与并行流实践

1. 项目概述&#xff1a;构建基于集合元素值的索引映射数组在Java开发中&#xff0c;我们经常需要处理集合与数组之间的转换和映射操作。特别是在数据处理、算法实现和系统优化场景下&#xff0c;建立集合元素与其索引位置的映射关系是一种常见需求。这种技术能够显著提升元素查…

作者头像 李华
网站建设 2026/9/16 9:58:01

SpringBoot+SSM实战:苍穹外卖项目环境搭建指南

1. 苍穹外卖项目环境搭建概述作为Java开发者接触企业级项目的第一步&#xff0c;环境配置往往决定了后续开发的顺畅程度。苍穹外卖作为黑马程序员推出的SpringBootSSM实战项目&#xff0c;其环境搭建涉及前后端分离架构下的多项技术栈配置。与常见的教学项目不同&#xff0c;这…

作者头像 李华
网站建设 2026/9/16 9:56:37

5G-MIMO下NOMA与OMA性能对比仿真:原理与MATLAB实现

简介&#xff1a;面向5G通信方向学习者与科研人员&#xff0c;这份MATLAB仿真资源聚焦NOMA非正交多址和OMA正交多址在5G-MIMO系统中的性能对比&#xff0c;适合课程设计、毕业设计或预研验证。基于MATLAB 2022a环境&#xff0c;代码包含MIMO-NOMA与MIMO-OMA可达速率的完整实现&…

作者头像 李华