简介:本资源是一套基于Qt Creator实现Ymodem串口文件传输协议的完整C++工程,面向嵌入式开发初学者与固件升级功能开发者,解决设备通过串口安全、可靠地进行固件升级或小文件传输的实际需求。项目代码结构清晰、注释充分,重点封装了Ymodem协议核心逻辑(如128字节块传输、CRC校验、NAK/ACK重传机制、文件名与结束帧处理),并依托Qt的QSerialPort类完成跨平台串口通信控制。压缩包共10个文件,含3个cpp源文件(主窗口、发送线程、协议逻辑)、2个头文件(接口定义)、1个UI界面文件、1个pro工程配置、以及ico、rc、user等辅助资源,总大小仅28KB,轻量易集成。已有846人学习下载,读者可直接编译运行,快速掌握Ymodem协议在Qt环境下的落地实现,获取可复用的串口升级模块、线程安全的收发框架及典型错误处理范例。
1. 为什么在 STM32 固件升级场景下,Ymodem 仍是串口传输的「稳态选择」?
你手头有一块 STM32F103C8T6 开发板,Bootloader 已烧写,但每次更新固件都要拆外壳、接 ST-Link、开 STM32CubeProgrammer —— 耗时 3 分钟,还容易碰歪排针。而现场设备(比如工业温控器、智能电表)根本没预留调试接口,只留一个 DB9 串口。这时,Ymodem 就不是“怀旧协议”,而是唯一能绕过硬件烧录器、仅靠一根 USB-to-CH340 线完成整包固件交付的方案。它不依赖 TCP/IP 栈,不挑波特率(支持 9600~115200),CRC16 校验+重传机制在 485 噪声环境下仍能稳定跑通 2MB bin 文件。Qt Creator 实现的这套代码,本质是把串口通信从“收发原始字节”升维到“可验证、可中断、可反馈”的文件级交互层:发送端点击“选择固件→开始升级”,接收端 Bootloader 解析 Ymodem 包头里的文件名和长度,校验通过后直接 memcpy 到 Flash 指定地址。新手能 30 分钟看懂 sendthread.cpp 里每行write()的触发逻辑;老手则会关注sendBlock()中QTimer::singleShot(10, this, &SendThread::sendNextBlock)的超时控制粒度——这决定了在 9600 波特率下能否避开 UART FIFO 溢出。它不解决局域网文件传输,也不替代 Qt 绘图,但当你面对的是没有 WiFi 模块、只有 RS232 的嵌入式终端时,这套代码就是产线 OTA 的最后一道保险。
2. Ymodem 协议核心状态机与 Qt SerialPort 的精准对齐
Ymodem 不是简单地把文件按 128 字节切片发出去。它的可靠性来自严格的状态跃迁:发送方必须等待接收方 ACK 才发下一帧,而接收方必须在收到 SOH + 文件名帧后,才允许进入数据帧接收流程。Qt 的QSerialPort提供了底层字节流,但协议逻辑必须由开发者用状态机显式建模。本项目采用三态循环设计:WaitForInit→TransferData→WaitForEof,每个状态对应明确的帧结构和超时策略。
2.1 协议帧结构解析:从 SOH 到 EOT 的字节级定义
Ymodem 使用固定帧格式,所有帧均以SOH(0x01)或STX(0x02)开头,后跟 2 字节包序号(含反序号)、128/1024 字节数据、2 字节 CRC16。关键帧类型如下:
| 帧类型 | 起始字节 | 数据内容 | 作用 |
|---|---|---|---|
| 初始化帧 | SOH(0x01) | [0x00][0xFF]+"firmware.bin\0\0\0..."+CRC16 | 发送文件名、大小、时间戳,启动传输 |
| 数据帧 | SOH(0x01) 或STX(0x02) | [0x01][0xFE]+128B data+CRC16 | 传输实际文件内容(SOH 用于 128B 块,STX 用于 1024B 块) |
| 结束帧 | EOT(0x04) | — | 表示文件传输完毕 |
| 响应帧 | ACK(0x06) /NAK(0x15) | — | 接收方确认/拒绝当前帧 |
注意:
SOH后的包序号为0x00时,反序号必须为0xFF(即0x00 + 0xFF == 0xFF),这是 Ymodem 的校验要求。若序号递增到0xFF,则下一个序号为0x00,反序号变为0xFF—— 项目中sendthread.cpp的m_blockNumber = (m_blockNumber + 1) & 0xFF;正是实现该逻辑。
2.2 Qt SerialPort 初始化:规避 CH340 驱动兼容性陷阱
Windows 下 CH340 串口驱动常导致QSerialPort::open()成功但bytesAvailable()始终为 0。根本原因是驱动未正确设置 DTR/RTS 电平,导致部分 Bootloader 拒绝响应。解决方案是在mainwindow.cpp中强制控制硬件流控:
// mainwindow.cpp 初始化串口片段 m_serial->setPortName(ui->portComboBox->currentText()); m_serial->setBaudRate(QSerialPort::Baud115200); m_serial->setDataBits(QSerialPort::Data8); m_serial->setParity(QSerialPort::NoParity); m_serial->setStopBits(QSerialPort::OneStop); m_serial->setFlowControl(QSerialPort::NoFlowControl); // 关键:禁用 RTS/CTS m_serial->open(QIODevice::ReadWrite); // 强制拉高 DTR,唤醒 Bootloader(适配多数 STM32 Ymodem Bootloader) m_serial->setDataTerminalReady(true); QThread::msleep(100); // 等待 Bootloader 进入接收态此段代码必须在open()后立即执行,且setDataTerminalReady(true)不能省略。Ubuntu 用户若使用 CH340,需确认/dev/ttyUSB0权限已加入 dialout 组,并加载ch341内核模块(sudo modprobe ch341)。若lsusb能识别设备但QSerialPortInfo::availablePorts()无输出,大概率是 udev 规则缺失,需创建/etc/udev/rules.d/99-ch340.rules并重启 udev。
2.3 状态机驱动的数据收发循环:避免 QTimer 误触发
项目未使用QSerialPort::readyRead()信号直接处理数据,而是采用轮询+超时机制,原因在于 Ymodem 要求严格的时间窗口:接收方发NAK后,发送方必须在 1 秒内重发,否则 Bootloader 退出等待态。readyRead()信号延迟不可控,故sendthread.cpp中采用QTimer::singleShot()配合waitForBytesWritten()构建确定性时序:
// sendthread.cpp 中 sendNextBlock() 片段 void SendThread::sendNextBlock() { if (m_file.atEnd()) { sendEotFrame(); // 发送 EOT 结束帧 return; } QByteArray block = buildBlockData(); // 构建 SOH + 序号 + 128B 数据 + CRC16 qint64 written = m_serial->write(block); if (written != block.size()) { emit errorOccurred("串口写入不完整"); return; } // 等待写入完成,超时 500ms(适配低波特率) if (!m_serial->waitForBytesWritten(500)) { emit errorOccurred("串口写入超时"); return; } // 启动响应等待定时器(Ymodem 要求 10s 内收到 ACK/NAK) m_responseTimer->start(10000); }此处waitForBytesWritten(500)是关键安全阀:它确保 UART TX FIFO 清空后再启动响应等待,避免因底层驱动缓冲区堆积导致readyRead()收到乱序字节。m_responseTimer超时设为 10 秒,覆盖 9600 波特率下最大帧传输时间(128B * 10bit / 9600 ≈ 133ms)+ Bootloader 处理延迟,比硬编码QThread::msleep(1000)更鲁棒。
3. 文件传输全流程实现:从 UI 触发到 Flash 写入的端到端链路
Ymodem 的价值不在协议本身,而在它如何串联起用户操作、Qt 事件循环与嵌入式端 Flash 操作。本项目将整个流程拆解为四个可验证环节:UI 参数配置 → 串口帧构造 → 接收端协议解析 → 固件落地。每个环节均有明确的失败出口和日志锚点。
3.1 UI 层:MainWindow 对文件选择与串口参数的约束校验
mainwindow.ui中的QFileDialog并非简单调用getOpenFileName(),而是增加了二进制文件头校验,防止用户误选文本文件导致 Bootloader 解析失败:
// mainwindow.cpp 中 on_selectFileButton_clicked() QString filePath = QFileDialog::getOpenFileName( this, "选择固件文件", "", "Binary Files (*.bin *.hex);;All Files (*)" ); if (filePath.isEmpty()) return; QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { QMessageBox::warning(this, "错误", "无法打开文件:" + file.errorString()); return; } QByteArray header = file.read(4); file.close(); // STM32 常见 bin 文件头部应为有效 Flash 地址(如 0x08000000) quint32 startAddr = qFromBigEndian<quint32>(header); if (startAddr < 0x08000000 || startAddr > 0x081FFFFF) { QMessageBox::warning(this, "警告", "文件头部地址 " + QString::number(startAddr, 16) + " 不在 STM32 Flash 区域,可能不是有效固件"); } ui->fileLabel->setText(QFileInfo(filePath).fileName()); m_firmwarePath = filePath;此校验利用了 STM32 bin 文件的典型特征:前 4 字节为复位向量(即主函数入口地址),正常固件该值落在0x08000000~0x081FFFFF范围内。若用户误选 PNG 图片,qFromBigEndian<quint32>会读出0x89504E47(PNG 签名),立即触发警告,避免后续传输失败后归因为“串口不稳定”。
3.2 发送线程:SendThread 中的 CRC16 计算与帧组装
Ymodem 的 CRC16 使用 CCITT 标准(多项式0x1021),非简单累加校验。sendthread.cpp中calculateCrc16()函数必须严格遵循字节序和初始值:
// sendthread.cpp quint16 SendThread::calculateCrc16(const QByteArray &data) { quint16 crc = 0x0000; // 初始值为 0x0000(Ymodem 要求) for (int i = 0; i < data.size(); ++i) { crc ^= (quint16)(data[i] << 8); for (int j = 0; j < 8; ++j) { if (crc & 0x8000) { crc = (crc << 1) ^ 0x1021; // 多项式 0x1021 } else { crc <<= 1; } } } return crc; } QByteArray SendThread::buildBlockData() { QByteArray block; block.append(0x01); // SOH block.append(m_blockNumber); block.append(0xFF - m_blockNumber); // 反序号 // 填充 128 字节数据(不足补 0x1A) QByteArray payload = m_file.read(128); payload.resize(128); block.append(payload); // 计算 CRC16(对序号+数据共 130 字节计算) QByteArray crcInput; crcInput.append(m_blockNumber); crcInput.append(0xFF - m_blockNumber); crcInput.append(payload); quint16 crc = calculateCrc16(crcInput); block.append((char)(crc >> 8)); block.append((char)(crc & 0xFF)); return block; }注意crcInput的构造:Ymodem CRC 计算范围是包序号 + 反序号 + 128 字节数据(共 130 字节),不包含SOH字节。若错误地将SOH纳入 CRC 计算,接收端校验必失败。payload.resize(128)确保末尾用0x00填充(Ymodem 允许),而非随机内存值。
3.3 接收端 Bootloader 适配要点:STM32F411 的关键配置
本项目发送端代码需与接收端 Bootloader 协同工作。以 STM32F411RE 为例,其官方 Ymodem Bootloader(ST 提供)要求:
- USART 时钟源:必须启用
RCC_APB2Periph_USART1(若用 USART1),且RCC_APB2PeriphClockCmd()在SystemInit()后调用; - 中断优先级:
USART1_IRQn优先级需 ≥NVIC_EncodePriority(NVIC_GetPriorityGrouping(), 0, 0),否则HAL_UART_RxCpltCallback()可能丢失字节; - 缓冲区大小:
huart1.RxXferSize至少为 132(SOH+2+128+CRC2),否则HAL_UART_Receive_IT()会截断帧; - 超时处理:
HAL_UART_Receive_IT()的Timeout参数建议设为1000(ms),覆盖最坏情况下的帧间隔。
若出现“串口烧写失败”,90% 源于 Bootloader 未正确进入 Ymodem 等待态。调试时可用逻辑分析仪抓取USART1_TX线:正常流程应看到 PC 发送SOH后,MCU 在 50ms 内回NAK;若无响应,检查HAL_UART_Receive_IT()是否被其他中断阻塞,或__HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE)是否遗漏。
4. 常见故障定位与性能优化:从 9600 波特率到 115200 的实测调优
Ymodem 传输失败极少源于协议实现错误,多因物理层与时序配合失当。以下为现场高频问题的根因分析与量化修复方案,全部基于本项目代码可直接修改。
4.1 串口烧写失败的三大根因与验证方法
| 现象 | 根因 | 验证方法 | 修复代码位置 |
|---|---|---|---|
| 始终收不到 ACK,一直重发 Block 0 | Bootloader 未响应SOH帧 | 用串口调试助手发送01 00 FF 66 69 72 6D ...(手动构造初始化帧),观察是否回06 | mainwindow.cpp中setDataTerminalReady(true)后加QThread::msleep(200) |
| 传输中途卡住,某 Block 重复 NAK | 接收端 CRC 校验失败 | 抓取该 Block 的 132 字节原始数据,用 Pythoncrcmod.predefined.mkCrcFun('crc-ccitt-false')计算 CRC,对比发送端值 | sendthread.cpp中calculateCrc16()输入数据是否含SOH |
| 进度条走到 99% 卡死,EOT 不被识别 | 发送端未发送EOT或接收端未处理0x04 | 监听串口,确认最后 1 字节为0x04;若无,则检查sendthread.cpp中sendEotFrame()是否被m_file.atEnd()逻辑跳过 | sendthread.cpp的sendNextBlock()末尾if (m_file.atEnd()) { sendEotFrame(); } |
提示:
sendEotFrame()必须发送0x04单字节,且之后需等待ACK。若 Bootloader 收到EOT后未回ACK,说明其 Ymodem 实现不完整,需升级 Bootloader 固件。
4.2 波特率自适应优化:动态调整超时参数表
不同波特率下,waitForBytesWritten()和responseTimer的阈值需线性缩放。本项目默认按 115200 设计,但在 9600 下易超时。实测推荐参数如下:
| 波特率 | waitForBytesWritten()超时(ms) | responseTimer超时(ms) | 适用场景 |
|---|---|---|---|
| 115200 | 100 | 1000 | 调试阶段,PC 直连开发板 |
| 38400 | 300 | 3000 | 工业现场,RS485 长线(>50m) |
| 9600 | 1200 | 10000 | 老旧设备,电磁干扰强环境 |
修改方式:在mainwindow.cpp的on_startButton_clicked()中,根据用户选择的波特率动态设置:
int baudRate = ui->baudComboBox->currentText().toInt(); int writeTimeout = 100; int responseTimeout = 1000; if (baudRate <= 9600) { writeTimeout = 1200; responseTimeout = 10000; } else if (baudRate <= 38400) { writeTimeout = 300; responseTimeout = 3000; } m_sendThread->setWriteTimeout(writeTimeout); m_sendThread->setResponseTimeout(responseTimeout);SendThread类需新增setWriteTimeout()和setResponseTimeout()方法,将值存入成员变量并在sendNextBlock()中使用。此举使同一套代码适配从实验室到产线的全场景。
4.3 Qt 自定义进度条:实时反映 Flash 编程进度
原项目 UI 仅显示“传输中”,但用户更关心“固件写入 Flash 进度”。可在mainwindow.h中添加QProgressBar* m_flashProgress;,并在SendThread的blockSent()信号中更新:
// sendthread.h 新增信号 signals: void blockSent(qint64 totalBytes, qint64 currentBlock); // sendthread.cpp 发送完一帧后 emit emit blockSent(m_totalSize, m_bytesSent); // mainwindow.cpp 连接信号 connect(m_sendThread, &SendThread::blockSent, this, [=](qint64 total, qint64 sent){ int percent = (int)((double)sent / total * 100); m_flashProgress->setValue(percent); ui->statusLabel->setText(QString("写入 Flash: %1%").arg(percent)); });此处m_totalSize为文件总字节数,m_bytesSent为已发送数据字节数(不含帧头/CRC)。进度值真实反映 Flash 编程负载,而非串口发送量,提升用户信任感。
本文还有配套的精品资源,点击获取