4. 固件升级协议设计:自定义YModem协议实现
固件升级,说白了就是给设备换脑子。Bootloader 的核心任务之一,就是安全、可靠地把新固件从外部搬进内部 Flash。我做过不少升级方案,从最简单的串口裸传,到带校验的 XModem、YModem,再到后来用 TCP/IP 做 OTA。但说实话,在资源受限的嵌入式设备上,YModem 协议一直是我个人最偏爱的选择——它够轻量,又带文件名传输和批量能力,非常适合 Bootloader 场景。
4.1 为什么选 YModem?
YModem 是 XModem 的升级版。它保留了 XModem 的 128 字节数据包结构,但增加了几个关键特性:
- 文件名传输:第一个包可以携带文件名和文件大小,接收端能据此做校验和进度管理。
- 批量传输:一次会话可以传多个文件(比如先传固件,再传配置文件)。
- CRC16/CRC32 可选:原始 YModem 用 CRC16,但我们可以在自定义实现中升级为 CRC32。
我在一个工业网关项目里用过标准 YModem,当时发现 CRC16 在长距离 RS485 上偶尔会漏检。后来我改成了 CRC32,再没出过问题。嗯,这里要记住:协议是死的,但实现可以灵活调整。
4.2 自定义 YModem 数据包格式
标准 YModem 的包结构是这样的:
| 字节偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | SOH / STX | 1 | SOH=0x01 (128字节包), STX=0x02 (1024字节包) |
| 1 | 包序号 | 1 | 从 1 开始,循环 0-255 |
| 2 | 包序号取反 | 1 | ~包序号,用于校验 |
| 3 ~ N+2 | 数据 | 128 或 1024 | 实际固件数据 |
| N+3 ~ N+4 | CRC16 | 2 | 标准 YModem 用 CRC16 |
但在我自定义的版本里,我把尾部改成了 CRC32,并且增加了几个控制包:
- EOT (0x04):文件传输结束
- ACK (0x06):确认
- NAK (0x15):否认,请求重发
- CAN (0x18):取消传输
- C (0x43):接收端请求发送
自定义包格式(CRC32 版)
// 数据包结构体
typedef struct {
uint8_t soh; // SOH=0x01 或 STX=0x02
uint8_t seq; // 包序号
uint8_t seq_inv; // 包序号取反
uint8_t data[1024]; // 数据区,最大1024字节
uint32_t crc32; // CRC32 校验值,4字节
} YModem_Packet_t;
4.3 CRC32 校验实现
CRC32 比 CRC16 强在哪?简单说,CRC16 的碰撞概率在 1/65536,而 CRC32 是 1/4.3e9。对于固件升级这种关键操作,我建议直接用 CRC32。我曾经在一个电力终端项目里,因为用了 CRC16,结果一条干扰刚好让错误包通过了校验,设备刷成了砖。从那以后,所有 Bootloader 的升级协议我都强制用 CRC32。
下面是我常用的 CRC32 查表法实现,速度比逐位计算快 10 倍以上:
// CRC32 查表法实现
static const uint32_t crc32_table[256] = {
0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA,
// ... 省略中间 252 个表项,实际代码中需要完整表
0x2D02EF8D
};
uint32_t crc32_calculate(const uint8_t *data, uint32_t len) {
uint32_t crc = 0xFFFFFFFF;
for (uint32_t i = 0; i < len; i++) {
uint8_t index = (crc ^ data[i]) & 0xFF;
crc = (crc >> 8) ^ crc32_table[index];
}
return crc ^ 0xFFFFFFFF;
}
小技巧:查表法的表可以预生成,用 const 放在 Flash 里,不占 RAM。我习惯在 PC 上先算好表,直接复制到代码里,省去运行时生成的开销。
4.4 自定义 YModem 会话流程
整个升级会话的流程,我画了一张图帮你理解:
流程其实不复杂,核心就是三步:
- 握手阶段:接收端发 'C',发送端回起始包(含文件名和大小)。
- 数据传输阶段:发送端发数据包,接收端校验 CRC32,正确就 ACK,错误就 NAK。
- 结束阶段:发送端发 EOT,接收端 ACK 确认,会话结束。
注意:超时处理很关键。我一般设 10 秒超时,如果接收端没收到任何包,就自动退出升级模式,回到 Bootloader 主循环。防止设备卡死在升级状态。
4.5 接收端状态机实现
接收端的逻辑,我习惯用状态机来写。这样代码清晰,也容易加超时和错误处理:
typedef enum {
STATE_IDLE,
STATE_WAIT_START,
STATE_RECEIVE_DATA,
STATE_WAIT_EOT,
STATE_COMPLETE,
STATE_ERROR
} YModem_State_t;
YModem_State_t ymodem_state = STATE_IDLE;
void ymodem_process(uint8_t byte) {
switch (ymodem_state) {
case STATE_IDLE:
// 发送 'C' 请求起始包
uart_send_byte('C');
ymodem_state = STATE_WAIT_START;
break;
case STATE_WAIT_START:
if (byte == SOH) {
// 接收起始包,解析文件名和大小
parse_start_packet();
ymodem_state = STATE_RECEIVE_DATA;
} else if (byte == CAN) {
ymodem_state = STATE_ERROR;
}
break;
case STATE_RECEIVE_DATA:
if (byte == SOH || byte == STX) {
// 接收数据包,计算 CRC32
uint32_t crc = crc32_calculate(data_buf, data_len);
if (crc == received_crc) {
uart_send_byte(ACK);
write_to_flash(data_buf, data_len);
} else {
uart_send_byte(NAK);
}
} else if (byte == EOT) {
uart_send_byte(ACK);
ymodem_state = STATE_COMPLETE;
}
break;
default:
break;
}
}
个人经验:写 Flash 的操作一定要放在 ACK 之后。我曾经犯过先写 Flash 再发 ACK 的错误,结果接收端收到 ACK 后立刻发下一个包,而 Flash 写入还没完成,数据就丢了。正确的顺序是:先校验 CRC32,再发 ACK,最后写 Flash。
4.6 避坑指南
做 YModem 升级,有几个坑我踩过,分享给你:
- 包序号回绕:YModem 的包序号只有 1 字节,到 255 后会回绕到 0。接收端要正确处理回绕,不能简单用大于小于判断。
- 1024 字节包 vs 128 字节包:发送端可以动态切换。我建议在固件较大时用 1024 字节包,小文件用 128 字节。接收端要能同时处理两种。
- CRC32 的初始值和异或值:不同标准用的初始值不一样。我统一用 0xFFFFFFFF 做初始值,最后异或 0xFFFFFFFF,这是最常见的 CRC-32 标准。
- 串口缓冲区:接收端要开足够大的环形缓冲区,防止数据丢失。我一般设 2KB,配合 DMA 使用。