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 会话流程

整个升级会话的流程,我画了一张图帮你理解:

自定义 YModem 升级会话流程 发送端 (PC/主机) 接收端 (Bootloader) 发送 'C' (0x43) 发送起始包 (SOH) ACK + 再次发送 'C' 发送数据包 (SOH/STX) ACK (成功) / NAK (重发) 发送 EOT (结束) ACK (升级完成) 起始包包含:文件名 + 文件大小 数据包使用 CRC32 校验,失败则 NAK 重发

流程其实不复杂,核心就是三步:

  1. 握手阶段:接收端发 'C',发送端回起始包(含文件名和大小)。
  2. 数据传输阶段:发送端发数据包,接收端校验 CRC32,正确就 ACK,错误就 NAK。
  3. 结束阶段:发送端发 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 使用。