简介:Ymodem协议由Xmodem增强而来,适合在低带宽环境下传输文件;它把文件分割为1024字节的数据块,通过双重CRC校验保证完整性,接收方发现错误可请求重传,并支持多文件连续传输。这份Qt工程示例专门面向串口通信开发者和嵌入式工程师,演示如何在QT框架下实现可靠的批量文件传输。压缩包内共48个文件,约1.69MB,包含C++源代码、Qt界面设计文件、pro工程配置、Makefile以及可直接运行的exe程序,结构完整清晰;资源上线以来已有377人浏览学习。代码按Ymodem发送与接收两个核心模块组织,涵盖串口交互、数据包解析与构建、QFile文件读写等关键实现;借助Qt信号槽机制,还能直观理解异步串口通信和事件驱动编程的落地方式。工程可在Qt Creator中直接打开编译,配合源码注释逐行调试,适合用于串口升级、固件下载等实用场景的二次扩展。 做嵌入式开发的朋友,对“串口在线升级”这个需求应该都不陌生。板子已经装到客户现场,程序发现Bug,总不能拆壳、上编程器重新烧录吧?所以bootloader里加一个串口升级功能,几乎是量产设备的标准动作。而Ymodem协议,就常年出现在这种场景里。
前面说Xmodem,后面说Zmodem,实际项目中嵌得最多的反而是Ymodem。它是Xmodem的扩展版,最大的特点是传输的是“文件”而不是裸数据——文件名、文件大小都会一起发过来,还支持一次连续传多个文件。这在bootloader升级APP、批量烧写字库或者参数分区的时候特别有用。这篇文章不绕弯子,直接讲Ymodem的帧格式、传输状态机、上位机/下位机实现细节,以及我在实际调试中踩过的一堆坑。正在做串口升级或者想把Ymodem彻底搞明白的开发者,建议收藏。
1. Ymodem协议原理与整体设计思路
1.1 为什么串口在线升级认准Ymodem
串口文件传输协议里,经常被拉出来对比的就是Xmodem、Ymodem和Zmodem这三个。Xmodem出现最早,协议最简单,但它一次只能传一个文件,而且标准模式用128字节块传输,发一个几百KB的固件能等到怀疑人生。Zmodem功能最强,支持断点续传和更复杂的命令交互,但在MCU端实现成本高,bootloader里放不下那么多代码,也没必要。
Ymodem正好卡在中间:它兼容Xmodem的1K块模式(1024字节一块),还加入了对文件名的传输。接收端拿到固件之前,就能知道这个文件叫什么、多大,方便做固件版本判断和大小预校验。相比之下Xmodem传过去的东西没有文件名,你都不知道烧进去的是哪一版固件。
我把几个协议放在一起对比过,直接看表更清楚:
| 协议 | 最大块大小 | 文件名/大小 | 多文件批处理 | 断点续传 | MCU实现难度 |
|---|---|---|---|---|---|
| Xmodem | 128B | 不支持 | 不支持 | 不支持 | 低 |
| Xmodem-1K | 1024B | 不支持 | 不支持 | 不支持 | 低 |
| Ymodem | 1024B | 支持 | 支持 | 不支持 | 中 |
| Zmodem | 32KB | 支持 | 支持 | 支持 | 高 |
所以在“MCU资源有限+需要升级固件+还想知道文件名和大小”这个组合下,Ymodem几乎是唯一优雅的选择。它不需要复杂的协议栈,几百行C代码就能实现接收端。
1.2 帧格式拆解:协议的基本单元
要把Ymodem谈透,先得把帧格式搞明白。Ymodem传输过程中所有数据都以“块”(Block)为单位,块分两种:128字节块和1024字节块。
128字节块格式:
- SOH(0x01)1字节,表示这是一个128字节块
- 块号1字节,从0开始递增
- 块号取反1字节,即
0xFF - 块号,用于校验块号正确性 - 数据区128字节
- CRC16高字节、低字节各1字节
1024字节块格式:
- STX(0x02)1字节,表示这是一个1024字节块
- 块号、块号取反
- 数据区1024字节
- CRC16高字节、低字节
除了块之外,Ymodem还有一组单字节控制字符,接收端和发送端靠它们握手。我经常让工程师把这些字符记在代码注释里:
| 字符 | 值 | 含义 |
|---|---|---|
| SOH | 0x01 | 128字节块起始 |
| STX | 0x02 | 1024字节块起始 |
| EOT | 0x04 | 传输结束 |
| ACK | 0x06 | 数据确认 |
| NAK | 0x15 | 请求重发/否定确认 |
| CAN | 0x18 | 取消传输 |
| C | 0x43 | 请求传输/下一个包 |
这里最容易忽略、也最重要的其实是“块0”的设计。Ymodem在传输第一个数据块之前,会先发一个特殊的块0,数据区放的不再是固件内容,而是ASCII字符串形式的文件名、文件大小,用空格分隔。比如发送端传一个firmware.bin、大小131072字节的文件,块0数据区就是:
firmware.bin 131072接收端收到块0,解析出文件名和大小,继续后面的数据块。这就是Ymodem和Xmodem的核心差异所在——Xmodem没有这个“前导信息帧”,Ymodem有,而且这个设计直接支持了多文件批处理:传完一个文件后,发送端再一次发块0,接收端就能知道属于下一个文件。
2. Ymodem传输时序与状态机
2.1 一次完整升级的流程拆解
写Ymodem接收端,本质是写一个状态机。我把一次完整传输掰开揉碎,按接收端视角列一下:
- 接收端上电初始化,发送一个
C(0x43),表示“我已经准备好,用CRC方式接收”。 - 发送端收到
C,发送块0(文件名+文件大小)。 - 接收端收到块0,校验CRC16正确、解析出文件名和大小后,回复
ACK,然后再次发送C,请求数据。 - 发送端收到
C,开始发送数据块。块号从1开始递增,每块发完后等待接收端响应。 - 接收端每收到一个数据块,做CRC16校验。校验通过回复
ACK,失败则回复NAK,发送端收到NAK会重发当前块。 - 数据全部发送结束后,发送端发送
EOT(0x04)。 - 接收端收到
EOT,这里不同实现有差异:标准Ymodem会回复NAK,发送端收到NAK后再发一个EOT,接收端再回复ACK,确认结束。也有实现是直接回复ACK。 - 结束确认后,接收端再发
C,等待下一个文件或者结束块。 - 如果还有下一个文件,发送端发送新的块0,重复步骤4-8。
- 如果所有文件都传完了,发送端会发送一个文件名为空的块0(数据区全是0x00填充),接收端收到后回复
ACK,本轮升级结束。
重点提醒一下:步骤7到步骤10,不同上位机行为很不一样。比如SecureCRT发单个文件时,往往在第一次EOT+ACK后就认为传输结束了,不会再发空文件名块0。而一些国产串口工具会严格按照批处理流程,走完空块0才算完。所以MCU接收端要兼容这两种结束方式——既要能处理EOT之后直接结束,也要能处理EOT之后再来一个空块0。
2.2 Ymodem与Ymodem-G的选择问题
Ymodem还有一个变体叫Ymodem-G,很多初学者会踩坑。Ymodem-G和标准Ymodem的最大区别是:它不要求接收端对每个数据块回复ACK/NAK,而是连续发送数据,只在对方发来CAN(取消)时才中断重发。
这个设计看起来更高效,但有个隐含前提:通信链路必须可靠。所以Ymodem-G一般配合硬件流控(RTS/CTS)使用,防止UART缓冲区溢出。在没有流控的普通USB转串口上,直接上Ymodem-G很容易丢包,而且发送端因为收不到NAK,根本不会重传,文件就永久性地错下去了。
我自己的习惯是:只要是普通串口调试、没有硬件流控,就用标准Ymodem,慢一点但稳;如果链路质量有保证、波特率也高,而且两端都支持RTS/CTS,才考虑Ymodem-G。
3. 实操:从零搭建Ymodem在线升级链路
3.1 上位机发送端:SecureCRT和Python脚本
PC端发送固件,最省事的办法是用现成工具。如果你用的是SecureCRT,操作流程是:设备端进入bootloader并发出C之后,在SecureCRT菜单栏选择“Transfer” -> “YMODEM” -> “Send File”,选中固件bin文件即可。Xshell也差不多,传输方式里选YMODEM。
但工具是图形界面的,不适合自动化测试和产线场景。我一般会写Python脚本,用python-xmodem这个库来发固件。它的用法非常直接:
import serial from xmodem import YMODEM def send_firmware(port, baudrate, filepath): ser = serial.Serial(port, baudrate, timeout=1) def getc(size, timeout=1): return ser.read(size) or None def putc(data, timeout=1): return ser.write(data) modem = YMODEM(getc, putc, mode='y') with open(filepath, 'rb') as f: modem.send(f) if __name__ == '__main__': send_firmware('COM10', 115200, 'firmware.bin')getc和putc是YMODEM库的底层读写钩子,库内部会自动处理握手、CRC校验和重传。需要注意的是mode='y',如果不写这个参数,默认是标准Xmodem,块0文件名信息就发不出去了。这个脚本在Windows和Linux下都能跑,放到产线工具里也完全够用。
3.2 MCU接收端状态机实现
接收端的核心是状态机,我直接给一个框架性的C代码片段。它不依赖具体硬件平台,串口发送和接收函数改成你自己平台的API就行:
typedef enum { YM_WAIT_C, /* 初始状态,发送 'C' 等待块0 */ YM_WAIT_BLOCK0, /* 收到块0,解析文件名和大小 */ YM_WAIT_DATA, /* 接收数据块 */ YM_WAIT_EOT, /* 收到EOT,处理结束流程 */ YM_DONE /* 传输完成 */ } ym_state_t; static ym_state_t state; static uint8_t rx_buf[1024 + 16]; static uint16_t block_no; /* 串口中断里收到一帧数据后调用,data和len是完整一帧 */ void ymodem_on_frame(uint8_t *data, uint16_t len) { switch (state) { case YM_WAIT_BLOCK0: if (data[0] == SOH && data[1] == 0x00 && data[2] == 0xFF) { /* 解析文件名和大小,保存到全局变量 */ uint8_t *file_info = &data[3]; // parse_filename_size(file_info); uart_send(ACK); state = YM_WAIT_DATA; block_no = 1; } break; case YM_WAIT_DATA: if (data[0] == EOT) { uart_send(ACK); /* 有的上位机这里会继续发空块0,所以回到等待块0 */ state = YM_WAIT_BLOCK0; break; } if (data[0] == SOH || data[0] == STX) { uint16_t expect_len = (data[0] == SOH) ? 128 : 1024; uint8_t cur_no = data[1]; uint8_t cur_neg = data[2]; if (cur_no != block_no || cur_neg != (uint8_t)(~block_no)) { uart_send(NAK); /* 块号不对,请求重发 */ break; } if (crc16_buf(&data[3], expect_len) != (data[3 + expect_len] << 8 | data[4 + expect_len])) { uart_send(NAK); /* CRC错误,请求重发 */ break; } /* 先把数据存到临时缓冲区,不要直接在中断里擦写flash */ memcpy(flash_buf, &data[3], expect_len); // write_flash_pending(flash_buf, expect_len); uart_send(ACK); block_no++; } break; default: break; } }这段代码只展示了核心逻辑。实际工程里,串口接收建议用“串口字节中断 + 环形缓冲区”或者“DMA + 空闲中断”,先完整接收一帧,再交给协议层解析。块号处理上要注意:块号是8位,传完255会回绕到0,虽然固件一般到不了这个大小,但状态机要保持无符号8位递增的写法。
3.3 CRC16校验与Flash写入避坑点
Ymodem的CRC用的是CRC-CCITT,多项式0x1021,初值0x0000。网上有很多现成实现,表驱动版速度快,适合MCU:
static uint16_t crc16_ccitt(uint8_t *buf, uint16_t len) { uint16_t crc = 0x0000; for (uint16_t i = 0; i < len; i++) { crc ^= (uint16_t)buf[i] << 8; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1; } } return crc; }这里有个常见的坑:Ymodem协议里CRC16是“高字节在前、低字节在后”发送的,不少人在接收端比对时写反了高低字节,导致明明数据是对的,CRC却一直错误。排查时先用固定的测试数据,比如发128字节全0x00的包,在PC上算出CRC值,再对比MCU收到的字节序。
Flash写入比CRC更隐蔽。很多工程师踩过的坑是:协议层一收到数据块,就在串口中断或者协议处理函数里直接调用Flash擦写操作。擦写一页几百毫秒甚至更久,这时候UART接收缓冲早就溢出了,下一块数据直接丢。正确做法是先把数据复制到RAM缓冲区,等协议收完一帧、返回主循环或者低优先级任务后再执行Flash写入。如果硬件平台支持ping-pong缓冲,可以在后台写上一块数据的同时,串口收下一块,升级速度能提升不少。
4. 常见问题与排查技巧实录
4.1 接收端发了一堆‘C’,上位机就是没反应
这个问题90%出在串口参数上。Ymodem要求数据位8位、无校验、停止位1位(8N1),波特率两端必须一致。其次是硬件流控:上位机里如果误开了RTS/CTS,而板子没有接流控线,发送端会一直等RTS电平,自然不发数据。还有少量情况是USB转串口芯片驱动有问题,建议先用串口助手手动发一个C,确认PC能收发再跑协议。
4.2 块0能收到,但文件名解析出来是乱码
块0里文件名是ASCII字符串,如果解析乱码,一般不是Ymodem的问题,而是接收端在块0处理时,把块0前面的SOH、块号、取反这些头字节也算进数据区了。注意块0的数据区从第4个字节开始(data[3]),文件名和大小之间是一个空格,如果实现里按\0截断字符串,先确认发送端是用的空格分隔,而不是\0。SecureCRT发送时,文件名和大小之间就是空格。
4.3 传输中途频繁NAK、或者进度卡在某个百分比不动
这几乎是嵌入式升级最典型的故障。先看波特率,115200以下一般问题不大,上了460800或921600,如果线材质量差、地线没接好,误码率会明显升高。Ymodem有重传机制,但重传超过一定次数就放弃了。
另一个被忽略的点是:在串口中断里做Flash擦写,导致串口数据丢失。前面说过,擦写Flash要放到后台执行。还有些平台串口中断优先级不够,被高优先级定时器长期抢占,也会丢字节。你可以用串口助手抓数据,如果发现NAK重传后,发送端重发的数据长度变短或帧头不对,基本就是接收端丢字节了。
4.4 传完EOT之后设备不结束、一直等
这个问题在文章前面提过,是结束流程兼容性造成的。处理办法是把结束状态机写得宽容一些:收到第一次EOT后回复ACK,开启一个超时窗口;如果上位机继续发空块0,就处理空块0后结束;如果超时没有新数据,也直接结束整个升级流程,跳转到APP执行。这种兼容逻辑能同时适配SecureCRT、Xshell和Python脚本。
4.5 问题排查速查表
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
| 上位机不传文件 | 串口参数不对、硬件流控开启 | 改8N1,关闭流控 |
| 块0收不到 | 数据位/校验位配置不一致 | 检查UART配置 |
| 文件名乱码 | 数据区起始地址错误 | 确认偏移为data[3] |
| 频繁NAK | 波特率太高、丢字节、CRC实现不对 | 降波特率、后台写Flash、检查CRC字节序 |
| 收完EOT不结束 | 结束流程兼容问题 | 用超时窗口兼容空块0流程 |
| 升级后程序不跑 | APPCRC校验失败、跳转地址错误 | 全片校验固件CRC,检查跳转条件 |
最后说一点我自己的心得
Ymodem这东西,看协议文档总觉得简单,真正调试起来全是细节。我记得第一次调升级功能,卡在CRC字节序上整整两天,最后还是用逻辑分析仪抓出来的,那一刻才真正理解什么叫“协议规范写得很清楚,但实现各有各的脾气”。如果你也在做串口升级,我的建议是:先把状态机画清楚,再写代码;上位机工具多备几个,SecureCRT、Xshell、Python脚本轮流测,别只盯着一个工具的行为。最后在接收端加一套日志打印,把收到的帧头、块号、CRC都打出来,对比协议文档慢慢核对,Ymodem其实没有想象中那么难缠。
本文还有配套的精品资源,点击获取