Qt实现Ymodem串口固件升级:帧格式、状态机与联调实战
简介这是一套基于Qt框架的Ymodem协议通信实现源码面向需要掌握串口文件传输技术的C/Qt开发者适用于桌面、嵌入式及移动终端的串口通信场景。资源共21个文件压缩包仅586KB包含5个cpp、4个h源码文件以及pro工程、ui界面、协议参考PDFXMODEM-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 字节块用 SOH0x01作帧头1024 字节块用 STX0x02作帧头。一帧的完整布局是帧头、块序号、序号反码、数据负载、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_castquint8(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 后发出 EOT0x04接收端回 ACK发送端再发一次 EOT接收端这次回 ACK 之后还要补发一个 C发送端收到这组信号后发出空数据块 0128 字节全零接收端回 ACK整个会话才关闭。有的 bootloader 对第二遍 EOT 的处理略有出入但主流实现都是这个序列联调时把每个字节打出来看比只看“传输完成”四个字靠谱得多。2.3 五个状态覆盖 Ymodem 传输生命周期无论发送端还是接收端把整条链路画成状态机都逃不开五个基本状态。发送端是 Idle、WaitC、SendBlock0、SendData、SendEOT接收端对应成 WaitBlock0、WaitData、WaitEOT、Finish外加各自初始的空闲态。状态机的好处是让超时和错误处理有明确归属任何状态卡住都能知道该重发什么。发送端状态触发条件动作Idle收到 start 指令打开文件清空日志WaitC串口收到 C发送数据块 0SendBlock0收到 ACK校验文件名长度进入正文分块SendData每块收到 ACK读下一块文件读完则发 EOTSendEOT收到 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)); // SOH128 字节块 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再调 processBufferprocessBuffer 根据当前状态判断完整帧长度长度不够就 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 CRC133 是 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 次回两个 CAN0x18终止会话——连续失败基本说明链路质量已不可用继续重试只会无限循环两个 CAN 是协议约定的放弃信号对端收到后也会主动退出。接收端的响应映射可以汇总成一张表接收端阶段成功响应失败响应超时补发等待块 0ACK CNAKC等待数据ACK CNAKNAK收到第一遍 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 缓冲里没落盘。有页面擦除限制的 MCUNXP 的 FlexSPI、STM32 的双 Bank 场景还可能要求固件大小是 4 KB 或 64 KB 的整数倍上位机构造块 0 时直接上报补齐后的大小并读文件时补 0xFF 填充。串口缓冲方面关闭流控后还要注意 Windows 下 CH340/CP2102 的驱动缓冲发送端连续 write 大块数据后要 flush 并等待 ACK接收端则要保持 readyRead 槽函数轻量避免在解析期间丢字节。对接完成后的验证方法很直接升级一遍旧固件再把升级后的版本号通过另一个协议读回来比对如果 bootloader 支持回滚再升一次旧版本确认双向链路对称。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →