4、固件升级协议设计:自定义升级指令集、CRC32校验机制、数据包分片与重组、超时重传策略

好,咱们进入正题。固件升级协议,说白了就是让设备听懂「你要更新了」这句话。我做过不少Type-C设备的OTA项目,踩过的坑比代码行数还多。今天就把这些经验掰开揉碎,跟你聊聊。

4.1 自定义升级指令集:让设备听懂你的话

为什么不用现成的协议?你想想看,Type-C设备资源有限,通用协议太臃肿。我习惯自己定义一套轻量级指令集,干净利落。

指令集的核心就三个动作:开始升级、传输数据、结束升级。但实际项目中,我还会加一个「查询状态」指令。为什么?有一次我在产线上批量升级,结果有一半设备卡死了,就是因为没有状态查询,我根本不知道设备当前在干嘛。

下面是我常用的指令定义:

// 指令码定义
#define CMD_START_UPDATE    0x01    // 开始升级
#define CMD_DATA_TRANSFER   0x02    // 传输数据块
#define CMD_END_UPDATE      0x03    // 结束升级
#define CMD_QUERY_STATUS    0x04    // 查询升级状态
#define CMD_ACK             0x80    // 应答标志位

// 指令帧结构
typedef struct {
    uint8_t  cmd;           // 指令码
    uint16_t seq;           // 序列号
    uint16_t length;        // 数据长度
    uint8_t  data[256];     // 数据载荷
    uint32_t crc32;         // CRC32校验
} __attribute__((packed)) upgrade_frame_t;

重要:指令码的最高位留给应答标志。这样设计的好处是,接收方收到指令后,直接置位最高位就能返回应答,省去一次查表操作。我在STM32上实测,每条指令能省下约5微秒。

每个指令帧都带序列号,这是为了后面做超时重传准备的。序列号从0开始,每发一帧加1,收到应答后确认。嗯,这里要注意,序列号不能重复使用,否则重传时会出现歧义。

4.2 CRC32校验机制:别让一个比特毁了整个升级

CRC32,说白了就是给数据打个指纹。Type-C通信环境其实挺复杂的,线缆质量、电磁干扰,都可能让数据出错。我曾经遇到过一批线缆,10次升级有3次失败,最后查出来是CRC校验没做好。

我推荐使用硬件CRC32模块。大多数MCU都内置了,比如STM32的CRC外设。为什么?软件算CRC32太慢了,尤其是大数据包。我记得有一次用软件算,升级512KB固件,光CRC校验就花了3秒多,用户都以为死机了。

// 硬件CRC32计算示例(STM32)
uint32_t calculate_crc32(uint8_t *data, uint32_t len)
{
    // 复位CRC外设
    CRC->CR = CRC_CR_RESET;
    
    // 按32位写入数据
    uint32_t *p32 = (uint32_t *)data;
    uint32_t words = len / 4;
    for (uint32_t i = 0; i < words; i++) {
        CRC->DR = p32[i];
    }
    
    // 处理剩余字节
    uint8_t *p8 = data + words * 4;
    uint32_t remaining = len % 4;
    if (remaining) {
        uint32_t last_word = 0;
        memcpy(&last_word, p8, remaining);
        CRC->DR = last_word;
    }
    
    return CRC->DR;
}

个人经验:CRC校验要覆盖整个帧,包括指令码和序列号。我见过有人只校验数据部分,结果指令码被篡改了都不知道。另外,发送方和接收方要使用相同的CRC多项式,这个一定要在文档里写清楚。

校验失败怎么办?直接丢弃,等待重传。不要尝试纠错,CRC32不是纠错码,强行纠错只会引入更多错误。

4.3 数据包分片与重组:大固件怎么塞进小管道

Type-C的通信包大小有限制,比如USB CDC类通常一次只能传64字节。但固件动辄几百KB,怎么办?分片。

分片策略其实很简单:把固件切成小块,每块带一个偏移地址。接收方收到后,按偏移地址写入Flash。但这里有个坑——分片大小怎么定

我建议分片大小取Flash页大小的整数倍。比如STM32的Flash页是2KB,那分片就设成2KB。为什么?因为Flash写入是按页擦除的,如果你分片大小不是页的整数倍,就会出现跨页问题,处理起来非常麻烦。

// 分片数据结构
typedef struct {
    uint32_t offset;        // 在固件中的偏移地址
    uint16_t length;        // 本片数据长度
    uint8_t  data[2048];    // 分片数据(最大2KB)
    uint32_t crc32;         // 本片CRC
} fragment_t;

// 重组逻辑
void reassemble_firmware(fragment_t *frag)
{
    // 检查偏移地址是否合法
    if (frag->offset + frag->length > FIRMWARE_SIZE) {
        // 错误处理
        return;
    }
    
    // 写入Flash
    flash_write(frag->offset, frag->data, frag->length);
    
    // 更新已接收位图
    bitmap_set(received_bitmap, frag->offset / FRAGMENT_SIZE);
}

警告:分片必须按顺序发送吗?不一定。但接收方必须能处理乱序到达的分片。我建议用位图记录哪些分片已收到,最后检查位图是否全1。如果某个分片丢了,只重传那一片,不要全部重传。

重组时还有一个关键点:校验完整性。所有分片收齐后,对整个固件再做一次CRC32校验。这个校验值在升级开始时就由主机发过来了,设备端算完比对一下就行。

4.4 超时重传策略:别让设备死等

通信不可能100%可靠。线缆松动、干扰、设备忙,都可能导致丢包。超时重传就是解决这个问题的。

我常用的策略是滑动窗口 + 超时重传。主机连续发送多个分片,设备收到后回复ACK。如果主机在超时时间内没收到ACK,就重传那个分片。

超时时间怎么设?太短了容易误判,太长了影响速度。我一般这样算:

// 超时时间计算
#define BASE_TIMEOUT_MS     100     // 基础超时100ms
#define RETRY_COUNT_MAX     3       // 最大重试3次

// 动态调整超时时间
uint32_t calculate_timeout(uint32_t rtt_avg)
{
    // RTT是往返时间,动态更新
    return (rtt_avg * 2) > BASE_TIMEOUT_MS ? 
           (rtt_avg * 2) : BASE_TIMEOUT_MS;
}

实际项目中,我还会加一个指数退避策略。第一次重传等100ms,第二次等200ms,第三次等400ms。为什么?因为如果网络真的拥堵了,你拼命重传只会让情况更糟。

避坑指南:我曾经遇到过一个问题——设备回复了ACK,但主机没收到。结果主机重传,设备收到重复的分片。所以设备端必须能识别重复分片,直接丢弃并重新回复ACK。序列号就是干这个用的。

还有一个细节:升级超时。整个升级过程不能无限期进行下去。我一般设一个总超时,比如5分钟。如果5分钟还没升级完,就判定失败,设备回滚到旧固件。这个总超时要在升级开始指令里告诉设备。

4.5 实战经验总结

说了这么多,最后给你几个我踩过的坑:

  1. Flash写入期间不能响应中断。有一次我升级到一半,设备收到USB中断,结果Flash写入被打断,整个设备变砖了。解决方案:写入Flash时关中断,或者用DMA。
  2. 掉电保护。升级过程中突然断电怎么办?我习惯用双备份区,先写备份区,全部写完后交换指针。这样即使断电,设备还能从旧固件启动。
  3. 版本兼容性。新固件可能改了协议,那旧设备怎么升级?我建议在升级开始前先交换版本号,如果协议不兼容,用最基础的指令集完成升级。

好了,这一章的内容就这些。协议设计看起来简单,但实际做起来细节很多。你如果按照我说的这套方案去做,基本不会出大问题。下一章我们聊聊具体的实现代码,到时候我会把整个升级流程串起来。