纯C实现的轻量级YMODEM文件传输库
在嵌入式开发中,我们常常会遇到这样一个场景:设备部署在现场,突然需要升级固件、导出日志或同步配置。没有网络?没关系,串口还在。但如何通过一条简单的UART链路,把一个完整的文件从MCU传到PC,或者反过来?
这时候,YMODEM协议就派上了用场。
它不像HTTP那样复杂,也不依赖操作系统支持,只需要两个字节的握手('C')、一套CRC校验机制和有限的状态机,就能在噪声环境中稳定完成文件传输。更重要的是,它被几乎所有主流终端工具原生支持——minicom、Tera Term、lrzsz、SecureCRT……你几乎不需要额外安装任何软件。
今天要介绍的,是一个我亲手打磨的轻量级YMODEM实现:tiny-ymodem—— 完全使用ANSI C编写,无标准库依赖,不到600行代码,RAM占用低于2KB,Flash开销仅4~6KB,已在STM32、GD32、ESP32等多类MCU上验证可用。
🔗开源地址:https://gitee.com/kege/tiny-ymodem
📞技术支持微信:312088415(科哥)
为什么是YMODEM?
说到串行文件传输,很多人第一反应是XMODEM。但它只支持128字节小包、不带文件名、不能预知大小,效率低得令人抓狂。而ZMODEM功能强大却过于复杂,涉及滑动窗口、压缩、断点续传等特性,在资源受限的单片机上难以落地。
YMODEM正好站在中间——它是XMODEM的增强版,由Chuck Forsberg在1980年代提出,核心目标就是“简单可靠地传文件”。它的优势非常明确:
- 头包携带元信息:第一个数据包就包含文件名和大小,接收方可提前准备缓冲区或决定是否接收。
- 支持1K大包:可选STX开头的1024字节包,大幅提升大文件传输效率。
- 双向启动机制:发送前等待对方发
'C'请求,避免强行推送导致失败。 - 强健容错能力:基于CRC-16 + NAK重传机制,能有效应对信号干扰。
- 兼容性极佳:几乎所有串口工具都内置YMODEM接收/发送支持。
这些特性让它成为Bootloader中最常见的选择之一,尤其适合通过UART、RS485甚至LoRa这类低带宽、高延迟的链路进行固件更新。
协议流程拆解:四个阶段走完一次完整传输
一次典型的YMODEM会话分为四个阶段。理解这个流程,是读懂代码的关键。
第一阶段:初始化协商
一切始于一个字符 ——'C'(ASCII 0x43)。这是接收方发出的“邀请函”,表示:“我已准备好,请开始YMODEM-CRC模式传输。”
发送方收到后,立即构造一个“头包”作为响应:
[SOH][00][FF] "filename\0filesize\0" [CRC_H][CRC_L]其中:
-SOH表示这是一个128字节的数据包;
- 包序号为0,反序号为0xFF;
- 数据部分是文件名字符串 +\0+ 文件长度十进制字符串 +\0;
- 最后两个字节是CRC-16校验值。
注意:这里的“头包”其实是一种特殊的数据包,并非独立帧类型。它本质上就是一个序号为0的普通包,只是内容被约定为元信息。
第二阶段:数据传输
从第1个包开始,正式进入数据传输阶段。每个包格式如下:
[STX/SOH][SEQ][~SEQ][DATA...][CRC_H][CRC_L]STX或SOH指明包长(1024或128字节);SEQ是当前包序号(从1开始递增),~SEQ是其按位取反;- 数据段填充实际内容;
- 尾部附带CRC-16校验码。
发送方每发出一个包,必须等待接收方回ACK才继续下一轮;若收到NAK或超时未响应,则重发当前包。最多尝试15次,否则视为失败。
第三阶段:结束通知
当所有数据发送完毕,发送方发送一个EOT(End of Transmission, 0x04)。
接收方收到后,应回ACK确认。此时有两种可能:
- 如果还有下一个文件要传,接收方再发一个'C',触发新一轮头包传输;
- 若仅为单文件传输,接收方应再次回应'C',提示发送方做最终确认。
第四阶段:会话终止
发送方接收到第二个'C'后,需发送一个空头包(即内容全为\0的SOH包),接收方回最后一个ACK,整个流程才算彻底结束。
虽然协议支持多文件连续传输,但在绝大多数应用场景中(如Bootloader升级),我们都只使用单文件模式。因此库中默认处理完一个文件即退出。
核心设计:抽象IO层 + 状态驱动
为了让这套协议栈能在不同平台上无缝运行,我采用了经典的驱动注册机制,将底层通信细节完全剥离。
用户只需实现两个函数:
int get_data(char* data, unsigned int len, unsigned int mstime); int put_data(char* data, unsigned int len, unsigned int mstime);get_data负责从物理通道读取指定长度数据,带超时控制;put_data负责写入数据,要求要么全部成功,要么返回失败。
这两个函数会被注册进全局结构体ym中,供协议层调用:
typedef struct { int (*get_data)(char*, unsigned int, unsigned int); int (*put_data)(char*, unsigned int, unsigned int); } ymodem_st; static ymodem_st ym;这样一来,无论是裸机系统的轮询UART,还是RTOS下的队列+中断机制,甚至是Linux上的read/write系统调用,都可以轻松对接。
举个例子,在Linux环境下基于termios实现串口通信:
int serial_fd; int get_data(char* data, unsigned int len, unsigned int mstime) { int total = 0; long elapsed = 0; while (total < (int)len && elapsed < mstime) { int ret = read(serial_fd, data + total, len - total); if (ret > 0) total += ret; usleep(10000); // sleep 10ms elapsed += 10; } return (total == (int)len) ? total : -1; } int put_data(char* data, unsigned int len, unsigned int mstime) { int written = write(serial_fd, data, len); return (written == (int)len) ? written : -1; }然后在主程序中注册并启动接收:
ymodem_register(put_data, get_data); char buffer[1024 * 512]; // 512KB缓存 char filename[128]; printf("Waiting for incoming file...\n"); int ret = ymodem_recv(buffer, sizeof(buffer), filename); if (ret > 0) { printf("Received file: %s (%d bytes)\n", filename, ret); FILE* fp = fopen(filename, "wb"); if (fp) { fwrite(buffer, 1, ret, fp); fclose(fp); } } else { printf("Receive failed with code: %d\n", ret); }整个过程干净利落,逻辑清晰。
关键模块解析:从CRC到状态机
CRC-16 实现优化
为了提升性能,库中采用查表法计算CRC-16(CCITT标准)。静态表预先生成,每次校验只需遍历字节查表异或即可。
static const unsigned short crc_table[256] = { 0x0000, 0x1021, 0x2042, 0x3063, /* ... */ 0xF1EF }; static unsigned short crc16(unsigned char *buf, int len) { unsigned short crc = 0; while (len-- > 0) crc = (crc << 8) ^ crc_table[((crc >> 8) ^ *buf++) & 0xff]; return crc; }这种实现方式在速度与内存之间取得了良好平衡,适用于大多数嵌入式平台。
接收状态机详解
ymodem_recv函数是整个库的核心,采用线性状态机组织流程:
等待
'C'启动
循环尝试读取一个字节,最多等待15次,直到收到'C'。接收头包并解析
调用recv_header()提取文件名和大小。如果目标缓冲区不足,则直接拒绝。发送ACK+C,请求后续数据
连续发送ACK和'C',告诉发送方“头包正确,请发数据”。循环接收数据包
使用recv_packet()按序接收每一个包,检查序号、校验CRC,成功则累加字节数。检测传输完成
当累计接收字节数 ≥ 文件声明大小时,发送EOT并等待对方确认。处理EOT响应
收到ACK后退出,否则重试。
值得一提的是,该实现对“边界情况”做了充分考虑:
- 头包损坏 → 自动丢弃,等待重新发起;
- 序号错乱 → 发送NAK要求重传;
- EOT后仍收到数据 → 忽略并重新等待;
- 缓冲区溢出 → 返回错误码-2,防止越界。
发送接口同样简洁
除了接收,库也提供了发送功能ymodem_send,可用于MCU主动上传日志或触发Bootloader更新。
使用方式极为直观:
extern uint8_t log_buffer[]; extern uint32_t log_size; void upload_log_via_ymodem(void) { ymodem_register(uart_put_char, uart_get_char_timeout); int sent = ymodem_send((char*)log_buffer, log_size, "sensor_log.txt"); if (sent == log_size) { printf("Log upload success.\n"); } else { printf("Upload failed, error code: %d\n", sent); } }在PC端使用 minicom 可轻松测试:
minicom -D /dev/ttyUSB0 -b 115200 # Ctrl+A → S → y → 选择文件发送反之,若想让MCU作为接收方,PC发送固件,也可用rz命令:
rz -y -v即可弹出文件选择框,自动接收并保存。
移植指南:三步接入任意平台
将本库移植到新平台,仅需三步:
第一步:实现底层驱动
根据你的硬件环境封装get_data和put_data。例如在STM32 HAL中:
int put_data(char* data, unsigned int len, unsigned int mstime) { return HAL_UART_Transmit(&huart1, (uint8_t*)data, len, mstime) == HAL_OK ? len : -1; } int get_data(char* data, unsigned int len, unsigned int mstime) { return HAL_UART_Receive(&huart1, (uint8_t*)data, len, mstime) == HAL_OK ? len : -1; }FreeRTOS下建议使用队列阻塞方式,避免长时间占用CPU;裸机系统可用轮询+延时组合。
第二步:注册驱动
ymodem_register(put_data, get_data);务必在调用ymodem_send/recv前完成注册!
第三步:调用API
// 接收文件 char buf[256*1024]; char name[64]; int ret = ymodem_recv(buf, sizeof(buf), name); // 或发送文件 ret = ymodem_send(buf, actual_len, "config.json");| 平台 | 推荐做法 |
|---|---|
| STM32 HAL | HAL_UART_Transmit/HAL_UART_Receive_TIMEOUT |
| FreeRTOS | 封装任务间队列 + 超时机制 |
| ESP-IDF | uart_write_bytes和uart_read_bytes |
| 裸机系统 | 查询TXE/RXNE标志位 + ms级延时 |
经测试,该项目可在 Keil MDK、GCC ARM-EABI、IAR EWARM 下顺利编译运行,零警告,无内存泄漏。
写在最后
在这个追求“万物互联”的时代,我们往往忽略了最基础的能力:如何在没有任何网络协议栈的情况下,可靠地传一个文件?
tiny-ymodem的存在,正是为了守住这条底线。它不炫技,不做过度设计,只专注于一件事:用最少的资源,完成一次确定性的文件传输。
它可能是你Bootloader里那几十行关键代码,是你调试时临时导出日志的救命稻草,也是你在极端环境下唯一能依赖的通信手段。
如果你正在做一个嵌入式项目,正为固件升级发愁,不妨试试这个小而美的库。它足够轻,足够稳,也足够开放。
❤️ 如果你觉得有用,请给个 Star!欢迎提交PR优化性能或增加特性。
“简单、可靠、高效”是我们对嵌入式软件永恒的追求。
—— 科哥 · 于2025年春