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 实战经验总结
说了这么多,最后给你几个我踩过的坑:
- Flash写入期间不能响应中断。有一次我升级到一半,设备收到USB中断,结果Flash写入被打断,整个设备变砖了。解决方案:写入Flash时关中断,或者用DMA。
- 掉电保护。升级过程中突然断电怎么办?我习惯用双备份区,先写备份区,全部写完后交换指针。这样即使断电,设备还能从旧固件启动。
- 版本兼容性。新固件可能改了协议,那旧设备怎么升级?我建议在升级开始前先交换版本号,如果协议不兼容,用最基础的指令集完成升级。
好了,这一章的内容就这些。协议设计看起来简单,但实际做起来细节很多。你如果按照我说的这套方案去做,基本不会出大问题。下一章我们聊聊具体的实现代码,到时候我会把整个升级流程串起来。