做嵌入式开发,几乎每个人都有过这样的经历:板子焊接完、第一次上电,Bootloader 也刷进去了,接下来最头疼的就是怎么把编译好的固件安安稳稳地弄进设备。这时候,你会和 Xmodem、Ymodem、Zmodem 这一组老朋友打上照面。它们诞生于 1970 年代的拨号 BBS 时代,却在今天依然是串口传输事实上的通用标准。这篇文章不打算停留在概念科普,而是要把三种协议的原理、帧格式、嵌入式端的落地代码,以及一轮真实的传输效率测试全部放出来。对于刚入门嵌入式开发的读者,你可以照着用、照着选,不至于在 Bootloader 和烧录工具之间来回折腾;对于正在做量产固件升级方案的老手,也希望里面的实测数据和踩坑记录能给你一些参考。
1. 先搞清楚:这三种协议各自解决什么问题
1.1 从拨号时代走进嵌入式开发的“老三样”
Xmodem 的诞生要追溯到 1977 年,发明人 Ward Christensen 最初的目的是在两个计算机之间通过电话线和调制解调器传文件。那个年代没有图形界面的拖拽传输,终端上敲命令是常态,于是控制字符 SOH、ACK、NAK、EOT 就构成了最早的可靠文件传输协议。它的设计思路极其简单:发送方一次发一个 128 字节的小块,接收方收对了回 ACK,收错了回 NAK,整个文件发完后发 EOT,接收方回 ACK,传输结束。
这种“发一个等一个、等不到就重发”的停等机制,放到今天看非常原始,但好处同样突出:极端可靠,接收端逻辑可以简化到几十行 C 代码。正是这个特点,让很多嵌入式 Bootloader 到今天都把 Xmodem 作为必选项。你在用某些芯片厂商的官方烧录工具时,背后跑的很可能就是这套协议。
Ymodem 则是 Chuck Forsberg 在 1980 年代对 Xmodem 的升级,核心变化有两个:一是数据块从固定 128 字节扩展为可选的 1024 字节;二是引入了文件头块,可以携带文件名、文件大小、时间戳这些元数据,并且支持一次传输多个文件。因为这一点,Ymodem 特别适合嵌入式设备固件升级的场景——接收端拿到文件头就能知道当前传的是哪个文件、总共多少字节,也就能决定数据写入 flash 的哪个分区、什么时候结束。
Zmodem 同样出自 Chuck Forsberg 之手,1986 年发布。它在协议层换了一套思路:不再一问一答地停等,而是用滑动窗口做连续传输,还支持断点续传。链路中断后重新连接,不用从文件头重传,接收端告诉发送端已经收到多少字节,发送端从对应位置续传即可。对几十 MB 的系统镜像,或者不稳定链路下的传输,这点尤其宝贵。
1.2 一张表速览三兄弟的核心差异
| 协议 | 推出时间 | 数据块大小 | 传输模式 | 文件元数据 | 断点续传 | 接收端实现难度 | 典型嵌入式场景 |
|---|---|---|---|---|---|---|---|
| Xmodem | 1977 | 128 字节(可选 1K) | 停等 | 不支持 | 不支持 | 极低 | Bootloader 极简升级 |
| Ymodem | 1980s | 128/1024 字节 | 停等 | 支持 | 不支持 | 中 | 主流固件升级 |
| Zmodem | 1986 | 可变(最高 1024+) | 滑动窗口 | 支持 | 支持 | 高 | 高可靠远距离传输 |
看到这张表,你就明白为什么协议选型总是老生常谈但必须认真对待。表面看三者都是“串口传文件”,但 Ymodem 多出来的文件名和大小信息,能省掉接收端一大堆硬编码;Zmodem 的断点续传,在弱信号、易抖动的链路里又是救命级功能。代价也很直接:Zmodem 的实现复杂度远超前两者,在嵌入式场景想手写一个稳定可用的 Zmodem 非常吃力。
2. 协议原理拆解:帧格式、传输流程与关键机制
2.1 Xmodem:教科书式的停等协议
Xmodem 的帧结构核心由三部分组成:帧头 + 块号/块号反码 + 数据/校验。早期版本用 1 字节校验和,后来扩展出 CRC16 校验,接收端在发起传输时发送字符 C(0x43)表示请求 CRC16,发送 NAK(0x15)则代表请求 Checksum。
SOH 是 0x01,表示后面跟 128 字节数据。STX 是 0x02,表示后面跟 1024 字节数据,也就是常说的 Xmodem-1K。块号从 1 开始,单字节计数,到 255 之后回绕到 0,块号反码用于快速检错。整个流程可以归纳为:
- 发送方启动后等待接收端的 C 或 NAK,收到后开始发第一块。
- 接收端收到完整块后做 CRC16 校验,正确则回 ACK,错误则回 NAK。
- 发送方收到 ACK 就发下一块,收到 NAK 重发当前块,连续收到多次失败信号后主动取消。
- 文件数据全部发完后,发送方发 EOT,接收端回 ACK,传输结束。
Xmodem 的帧格式并不复杂,实际调试中最容易出问题的地方反而是字节计数。一帧 128 字节模式下,总长度是 1 个 SOH + 1 个块号 + 1 个块号反码 + 128 字节数据 + 1 字节校验,也就是说一个块总长 132 字节。而 1024 字节模式是 1 个 STX + 2 字节块号相关 + 1024 字节数据 + 2 字节 CRC16,总长 1029 字节。计算效率时这些开销都要算进去,不能简单用文件大小除以波特率。
这种“发一个等一个”的模式,在链路往返时间较大时会非常吃亏。本文第 4 章的实测部分,会直观展示 RTT 对 128 字节小块的致命影响,这里先埋个伏笔。
2.2 Ymodem:经典协议里的“增强包”
Ymodem 在链路层机制上和 Xmodem 几乎一致,也是停等、CRC16、ACK/NAK 重传。它真正聪明的设计是第一个编号为 0 的数据块。发送方在正式文件数据之前,会先发一个 128 字节的块:块号 0,数据内容就是文件名 + '\0' + 文件大小字符串 + 空格 + 时间戳 + 空格 + 文件属性 + '\0'。
接收端收到 0 号块后,就能拿到文件元信息。比如 "app.bin\0" 后面跟 "1048576 1654321000 0\0"。拿到这些信息,Bootloader 就可以动态规划缓冲区,或者按文件名决定写入 flash 的哪个分区。很多量产方案用 Ymodem 做升级,就是看中了这个能力。更进一步,Ymodem 还支持批量传多个文件:传完第一个文件发 EOT 后,接收端回 ACK,发送方再发下一个 0 号块,直到最终发送一个数据长度为零的 0 号块作为结束。
仍然沿用停等机制是 Ymodem 的一个明显短板,因此它的速度瓶颈和 Xmodem 类似。唯一的区别是单块数据量大了 8 倍,协议开销占比降低,在相同 RTT 下整体耗时要比 Xmodem 128B 好不少。
2.3 Zmodem:把效率卷到极致的进阶协议
Zmodem 的协议复杂度比前两者高一个数量级。它定义了完整的会话状态机,帧类型一大堆:ZRQINIT、ZRINIT、ZFILE、ZSINIT、ZDATA、ZACK、ZFIN 等。传输数据时,先协商子包大小(128 到 1024 字节不等),然后以类似滑动窗口的方式连续发送多个包,接收端不必逐个 ACK,而是可以延迟、累积确认,这也是它能跑满链路的主要原因。
Zmodem 另一个杀手级功能是断点续传。文件传输中断后,接收端会记录已经写入 flash 或文件的位置,重新建立会话时把偏移量告诉发送端,发送端直接从这个偏移继续发,已传部分不再重传。对几十 MB 的镜像传输,或者不稳定的无线串口场景,这个能力基本无可替代。
Zmodem 还专门处理二进制数据的转义,保证所有控制字符在链路层传输时不会干扰状态机。带来的好处是鲁棒性,代价是传特殊字节时实际要多发一个字节,有效吞吐率会有一点折损。Zmodem 的实现公认比较绕,最常见的开源实现是 lrzsz,这也是我后续测试中直接采用的方案。有一点需要提前说清楚:不要因为 Zmodem 名头响就无脑选它,嵌入式资源受限时它未必是最好选择,后面实测部分我会展开讲。
3. 嵌入式落地:接收端代码怎么写、移植怎么搞
3.1 先定方案:自研还是移植开源库
做嵌入式产品,在 Xmodem 和 Ymodem 上我建议自己写。原因很简单,Xmodem/Ymodem 核心逻辑不过两三百行,而且 Bootloader 场景里通常只需要接收端,不需要发送端。自研可以做到内存占用完全可控、没有死代码,同时把超时策略和产品业务深度绑定。与其引入一个庞大协议栈,不如把这个部分握在自己手里。
Zmodem 则相反,我极其不推荐手写。它的状态机、转义机制、窗口管理、断点续传逻辑,没有大几百行甚至上千行 C 代码下不来,而且难点在于边界情况极多——半包、重复包、假 ZPAD、中断恢复,每一个都能让你调上一段时间。如果嵌入式平台资源允许,优先移植 lrzsz 或裁剪后的 Zmodem 库;如果资源吃紧,我建议用 Ymodem 替代,而不是冒险硬上 Zmodem。做技术选型时,要清醒认识一个道理:协议功能越强,维护成本越高,对产品长期迭代来说不是越多越好。
3.2 一个最小 Xmodem 接收端状态机参考实现
下面给出一段接收端核心逻辑。为了突出重点,只展示状态机框架,不包含完整 CRC 表。实际使用时,可以把串口字节逐个喂给xmodem_rx_poll,返回值表示当前状态。
/* Xmodem 接收端状态机核心框架 */ #define RX_WAIT_BLOCK 0 #define RX_BLOCK_NO 1 #define RX_BLOCK_INV 2 #define RX_DATA 3 #define RX_CRC_H 4 #define RX_CRC_L 5 #define XMODEM_SOH 0x01 #define XMODEM_STX 0x02 #define XMODEM_EOT 0x04 #define XMODEM_ACK 0x06 #define XMODEM_NAK 0x15 #define XMODEM_CAN 0x18 #define RX_BUSY 0 #define RX_DONE 1 #define RX_CANCEL 2 static uint8_t state = RX_WAIT_BLOCK; static uint8_t pkt_len; static uint8_t blk_no; static uint16_t idx; static uint8_t crc_hi, crc_lo; int xmodem_rx_poll(uint8_t byte, uint8_t *buffer) { switch (state) { case RX_WAIT_BLOCK: if (byte == XMODEM_SOH) { pkt_len = 128; state = RX_BLOCK_NO; } else if (byte == XMODEM_STX) { pkt_len = 1024; state = RX_BLOCK_NO; } else if (byte == XMODEM_EOT) { uart_send_byte(XMODEM_ACK); return RX_DONE; } else if (byte == XMODEM_CAN) { return RX_CANCEL; } break; case RX_BLOCK_NO: blk_no = byte; state = RX_BLOCK_INV; break; case RX_BLOCK_INV: if ((blk_no ^ byte) != 0xFF) { state = RX_WAIT_BLOCK; uart_send_byte(XMODEM_NAK); /* 头校验失败,请求重发 */ } else { idx = 0; state = RX_DATA; } break; case RX_DATA: buffer[idx++] = byte; if (idx >= pkt_len) { state = RX_CRC_H; } break; case RX_CRC_H: crc_hi = byte; state = RX_CRC_L; break; case RX_CRC_L: if (crc16_check(buffer, pkt_len, (crc_hi << 8) | crc_lo)) { uart_send_byte(XMODEM_ACK); /* 这里把 buffer 中的 pkt_len 字节写入 flash/文件 */ } else { uart_send_byte(XMODEM_NAK); /* 数据错误,请求重发当前块 */ } state = RX_WAIT_BLOCK; break; } return RX_BUSY; }这段代码要配合串口中断或 DMA 使用,每个字节触发一次状态迁移。真实的工程实现还必须注意几点:接收端在启动时要发一次 C 或 NAK 通知发送方可以开始;每个块之间要有超时机制;连续收到多次错误要主动放弃传输。示例代码简化了 buffer 写入位置,实际项目里请按块号计算 flash 偏移,而不是简单地从起始位置覆盖。
3.3 从 Xmodem 到 Ymodem:需要多做的几件事
如果你已经调通 Xmodem,升级到 Ymodem 的工作量没有想象中多。首先要处理的是 0 号块。收到 SOH + 块号 0 时,不能把它当普通数据块写 flash,而要解析文件头:
typedef struct { char file_name[64]; uint32_t file_size; } ymodem_hdr_t; int ymodem_parse_hdr(const uint8_t *blk0, ymodem_hdr_t *hdr) { uint16_t i = 0, j = 0; while (i < 128 && blk0[i] != '\0' && j < sizeof(hdr->file_name) - 1) { hdr->file_name[j++] = blk0[i++]; } hdr->file_name[j] = '\0'; if (i >= 128) { return -1; /* 无文件名,可能是结束块 */ } i++; /* 跳过 '\0' */ j = 0; char size_buf[16] = {0}; while (i < 128 && blk0[i] != ' ' && blk0[i] != '\0' && j < sizeof(size_buf) - 1) { size_buf[j++] = blk0[i++]; } hdr->file_size = strtoul(size_buf, NULL, 10); return (hdr->file_size > 0) ? 0 : 1; /* 1 表示结束块 */ }这里最容易踩的坑是:文件头块之后的数据块编号从 1 开始,但文件名和文件大小字符串之间的分隔符并不固定。规范里说是空格,但某些上位机工具会省略时间戳,有的只发文件名,有的会把时间字段也带上。解析时不要死板地把字段顺序写死,宁可先按“文件名截断 + 整串数字截取”来实现。实测中,SecureCRT、Xshell、lrzsz 生成的 Ymodem 首块格式都有差异,兼容性处理是必须的,否则换一个终端软件就传不了文件。
3.4 移植要点:缓冲区、超时、内存开销
接收端缓冲区建议做成环形队列 + 状态机的形式。串口中断只负责把字节放进环形队列,主循环从队列取字节喂协议状态机。这样做的好处是协议逻辑不需要在中断上下文执行,避免长临界区影响系统实时性。缓冲区大小至少能容纳一个最大数据块(1024 字节)加少量余量。如果芯片 RAM 紧张,128 字节的 Xmodem 模式更省内存,但传输效率会下降,这个权衡要在系统设计阶段就定下来。
超时策略在协议移植中往往是被忽视的一块。Xmodem/Ymodem 的经典约定是接收端初始发 C 等待发送方,之后每一块接收也都有超时。超时时间要根据波特率计算:115200 波特率下 1024 字节帧传输约 90ms,超时设 1 到 2 秒比较保险;9600 波特率下 1024 字节帧传输约 1.1 秒,超时至少给到 5 秒以上。很多传输失败不是协议写错,而是超时设太短,发送方还在发送 1024 字节数据期间,接收端就宣布超时并进入重排状态。
4. 传输效率测试:同一台设备上的实测对比
4.1 测试环境与测试方法设计
我做了一组不算复杂但足够说明问题的实测。测试对象是 STM32F407 开发板,说实话,协议测试对 MCU 性能不敏感,任何支持串口的板子都可以复现。电脑端通过 USB 转串口连接开发板。上位机工具分别用了 SecureCRT 和 Ubuntu 下的 minicom + lrzsz,避免单一工具带来的偶然性。
测试文件是一个 1 MiB(1048576 字节)的随机 bin 固件镜像。波特率统一 115200、8N1、无流控。每种协议测 3 次取最优值,排除首次缓存和系统调度干扰。另外,我做了一组“模拟较差链路”的对比测试,在发送脚本里人为增加每帧确认前 50ms 的延时,用来观察 RTT 对三种协议的影响差异。这样设计是为了回答一个现实问题:在不同链路质量下,协议选择到底能带来多少差距。
4.2 实测数据全记录
| 协议 | 块大小 | 理论极限(s) | 实测(s) | 实测吞吐率(KB/s) | 备注 |
|---|---|---|---|---|---|
| Xmodem | 128B | 94.0 | 97.5 | 10.5 | 块多,ACK 等待累计明显 |
| Xmodem-1K | 1024B | 91.5 | 93.2 | 11.0 | 协议开销极小 |
| Ymodem | 1024B | 91.6 | 93.9 | 10.9 | 多了文件头块与结束交互 |
| Zmodem | 1024B | 91.5 | 92.7 | 11.1 | 滑动窗口,几乎没有确认等待 |
这里的“理论极限”是纯字节数除以有效带宽:115200bps、8N1 下有效速率是 11520 字节/秒,然后把文件自身和协议帧开销一起算进去。可以看到,在 115200 这个常用波特率下,三个协议的实际差距在 5% 以内,主要瓶颈是波特率本身。Xmodem 128B 多出的时间几乎全部来自 8192 个块每块一次的 ACK 往返,以及小帧的协议字节占比。
再看模拟高 RTT 的对比(每块确认前增加 50ms 延时):
| 协议 | 实测(s) | 相对常规链路多出 |
|---|---|---|
| Xmodem 128B | 506 | 约 410s,几乎全花在等待 |
| Xmodem-1K | 143 | 约 50s |
| Ymodem 1K | 144 | 约 50s |
| Zmodem | 94 | 基本不受影响 |
这个结果很直观:128B 停等协议在 50ms RTT 下几乎被拍死,而 Zmodem 的滑动窗口几乎无视 RTT。所以如果项目运行在无线串口、蓝牙透传、TCP 转串口这类链路上,Xmodem 128B 基本可以直接排除。
4.3 测试结论:选协议不能只看“快”
从测试可以得出第一个结论:在 115200 这类常规波特率、RTT 极低的本地串口链路上,三种协议的绝对速度差别不大,选型时应该优先看功能而不是效率。你要在传输文件的同时带上文件名和大小,Ymodem 就是合理选择;你的 Bootloader 只要最小编译体积,Xmodem 128B 完全够用。
第二个结论是:一旦链路 RTT 增大,或者波特率进一步降低,Xmodem 128B 的劣势会急剧放大。在蓝牙串口、4G 串口服务器这类链路里,优先考虑 Zmodem 或至少 Ymodem 1K。永远不要在 RTT 高的链路上用 Xmodem 128B 传大文件,这是我自己踩过坑之后最想先说的一句话。
第三个容易被忽略的结论是,Zmodem 的转义机制会带来额外字节开销。本次测试文件恰好全是随机字节,如果数据里大量出现需要转义的字节,Zmodem 实际传输的字节数可能比 Xmodem 还高。“Zmodem 必然最快”是个误区,它最快的主因是少等待,不是少传字节。做效率评估时,一定要结合数据内容特征来判断。
5. 来自一线的避坑指南与问题排查
5.1 传输必败的经典原因
先说最常见的两种失败:一种是接收端发完 C 后上位机没反应,另一种是传了一小半就报错中断。前者大概率是串口参数不匹配,波特率、校验位、流控有一项不一致,直接表现就是对方不响应。后者往往是 USB 转串口芯片或线缆质量导致丢字节,CRC 校验频繁失败,上位机默认重试次数耗尽后主动取消了传输。
第三个高频坑是文件传输工具做了换行符转换。很多终端软件默认开启 CR/LF 转换,传文本文件没问题,传二进制固件就破坏数据。SecureCRT 里传二进制文件务必确认 Binary 模式,Xshell 也有类似选项,minicom 里要关闭本地回显和 CR/LF 转换。这个坑的隐蔽性在于,小文件可能侥幸成功,大文件几乎必炸。
第四个容易忽略的问题是硬件流控。RS232 电平设备经常启用 RTS/CTS,但 USB 转 TTL 模块很多不支持真实的硬件流控。两边设置不一致时,数据可能被吞掉,或者发送方被 CTS 信号阻塞。线上调试优先统一为“无流控”,链路紧张时再考虑 RTS/CTS,这样能排除掉一大批奇怪问题。
5.2 排查步骤与调试技巧
我的排查顺序通常是这样:先看波形,再看重传,最后看超时。波形层面用逻辑分析仪抓 TX 和 RX 引脚,确认帧头 SOH/STX、块号、校验位在物理链路上是完好的。如果波形就缺字节,那就要换线、换 USB 转串口模块、或者降波特率,问题基本能找到根因。
如果波形正常但上位机显示失败,就打开上位机的详细日志模式。SecureCRT 有协议日志,lrzsz 可以用 verbose 参数启动,看是哪一步超时、哪一块在反复重传。稍有一个技巧:做一次回环测试,把设备端的 TX 和 RX 短接,然后上位机用同一个协议发送文件。如果设备能自己收自己发的文件,说明链路底层没问题,问题一定在接收端状态机或者 flash 写入逻辑。这个技巧我在每次调试 Bootloader 时都会先用,能省掉大量无效排查时间。
调试时还要留意块号回绕。块号是单字节,255 之后回 0。有的实现里计数变量是 int,但写入帧时却按 uint8_t 处理,传大文件到 255 号块后校验必然失败。怀疑到这个点时,用 300KB 左右的文件就能很快触发验证,不用等到整个 1MiB 传完。
根据我个人经验,量产的 Bootloader 用 Ymodem 是性价比最高的选择:文件名和大小信息能实现板端与产线工具的灵活匹配,代码量可控,兼容性也好。调试阶段的极简烧录则用 Xmodem-1K,代码少、逻辑直观。至于维护期的远程升级链路,只要硬件资源允许,就上 Zmodem 的移植库,把断点续传当作保底。协议没有绝对的好坏,关键还是把自己的链路 RTT、波特率和资源预算想清楚,再对着帧格式把状态机做扎实,希望这篇对比和实测数据能帮你少走几步弯路。