第三章 固件升级协议设计:私有协议 vs 标准协议、数据包结构定义、校验与重传机制
好,咱们进入第三章。这一章聊的是固件升级的“通信语言”——协议设计。
说实话,协议这块儿是很多工程师容易忽视的坑。我见过不少项目,硬件调通了,应用层写好了,结果升级协议设计得稀烂,导致量产之后升级失败率居高不下。嗯,咱们今天就把这块儿掰开揉碎了讲清楚。
3.1 私有协议 vs 标准协议,怎么选?
先问个问题:你是自己造轮子,还是用现成的?
说白了,私有协议就是你自己定义的一套数据格式和交互流程。标准协议呢,比如 YMODEM、XMODEM、HTTP OTA 这些现成的方案。
我个人习惯:小项目、资源受限的 MCU,我倾向于私有协议。为什么?因为轻量。你想想看,一个血压计用的 Cortex-M0,RAM 才 8KB,Flash 64KB,你塞个 HTTP 协议栈进去,资源就快见底了。
但标准协议也有它的好处——成熟、稳定、调试工具多。我记得有一次帮客户排查升级失败问题,对方用的私有协议,没有现成的抓包工具,我只能自己写个串口监听器,费了好大劲。
我的建议:
- 资源紧张(Flash < 128KB,RAM < 16KB):用私有协议,精简至上
- 有现成通信链路(如蓝牙、Wi-Fi):可以考虑标准协议,比如用 BLE 的 DFU 服务
- 团队经验不足:别自己造协议,用 YMODEM 这种成熟方案,省心
我在项目中遇到过一件事:有个同事非要自己设计一套复杂的私有协议,加了各种状态机、超时重传、滑动窗口。结果代码量比应用层还大,最后调试了两个月才稳定。你想想看,一个血压计升级而已,需要这么复杂吗?
3.2 数据包结构定义——核心中的核心
不管用私有还是标准,数据包结构是绕不开的。我一般把数据包分成三层:
- 帧头(Header):固定长度,用于同步和识别
- 数据载荷(Payload):真正的固件数据
- 帧尾(Trailer):校验信息
下面是我在血压计项目里常用的一种结构,你可以参考:
// 数据包结构定义
typedef struct {
uint8_t sync_byte1; // 同步头 0xAA
uint8_t sync_byte2; // 同步头 0x55
uint16_t packet_len; // 数据载荷长度(不含头尾)
uint8_t packet_seq; // 包序号(0-255,循环使用)
uint8_t packet_type; // 包类型:0x01=数据包,0x02=结束包,0x03=应答包
uint8_t payload[256]; // 数据载荷,最大256字节
uint16_t crc16; // CRC16校验,覆盖从sync_byte1到payload末尾
} __attribute__((packed)) FirmwarePacket;
这里有几个关键点,我踩过坑的:
- 同步头别用单字节:0xAA 或 0x55 单独用容易误判。我习惯用 0xAA 0x55 组合,降低误同步概率
- 包序号一定要有:不然你没法做重传和去重。我曾经见过一个设计,没有包序号,结果丢包后整个固件就废了
- CRC 覆盖范围要全:从同步头开始算,别漏了包头字段。我见过有人只校验 payload,结果包头被篡改了也不知道
警告:包类型字段别用 0x00。为什么?因为很多串口在空闲时发的是 0x00,容易误触发。我一般从 0x01 开始定义。
3.3 校验机制——别让坏数据写进 Flash
校验这事儿,说白了就是保证“收到的和发送的一样”。
常用的校验方式有几种:
| 校验方式 | 长度 | 强度 | 适用场景 |
|---|---|---|---|
| 累加和(Checksum) | 1字节 | 低 | 对安全性要求极低,资源极度受限 |
| CRC8 | 1字节 | 中 | 小数据包,简单场景 |
| CRC16 | 2字节 | 高 | 大多数嵌入式场景,推荐 |
| CRC32 | 4字节 | 极高 | 对安全性要求高,如医疗设备 |
我个人习惯用 CRC16-CCITT。为什么?因为它在嵌入式里很常见,查表法实现起来快,而且碰撞概率足够低。血压计这种产品,CRC16 完全够用。
这里给一段我常用的 CRC16 实现:
// CRC16-CCITT 查表法
uint16_t crc16_ccitt(uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++) {
crc = (crc >> 8) | (crc << 8);
crc ^= data[i];
crc ^= (crc & 0xFF) >> 4;
crc ^= (crc << 12) & 0xFFFF;
crc ^= (crc & 0xFF) << 5;
}
return crc;
}
小技巧:如果你用 CRC 查表法,记得把表放在 Flash 里,别放 RAM。我见过有人把 512 字节的 CRC 表放 RAM 里,结果 RAM 直接爆了。
3.4 重传机制——丢了怎么办?
无线通信(比如蓝牙)丢包是常态。有线通信(比如 USB 串口)也会因为干扰丢数据。所以重传机制必须要有。
我常用的策略是 超时重传 + 选择性重传:
- 发送方:每发一个包,启动一个定时器(比如 500ms)。如果在超时前收到 ACK,就发下一个包。如果超时了,重传当前包。
- 接收方:收到一个包后,校验 CRC。如果正确,回复 ACK(带上包序号)。如果错误,回复 NAK,或者不回复(让发送方超时重传)。
这里有个细节:ACK 包本身也可能丢。所以发送方收到重复的 ACK 怎么办?我一般这样处理:如果收到 ACK 的序号等于当前正在发的包序号,说明是重复的 ACK,直接忽略。如果收到的 ACK 序号大于当前包序号,说明接收方跳包了,这时候要回退重传。
我曾经在一个项目里犯过傻:重传次数设成了 255 次。结果通信环境差的时候,一个包重传了 200 多次,升级时间从 2 分钟变成了 20 分钟。后来我改成最多重传 5 次,5 次都失败就报错退出。你想想看,与其死磕,不如让用户换个环境再试。
重传策略总结:
- 超时时间:根据通信速率和包大小来定。串口 115200bps,256 字节包,超时设 200ms 就够了
- 最大重传次数:3-5 次为宜
- ACK 包要轻量:只包含包序号和状态码,别带多余数据
3.5 实战中的避坑指南
最后,分享几个我踩过的坑,希望能帮你省点时间:
- 我曾经把包序号设计成 16 位,结果发现根本用不到。256 个包序号循环使用完全够,因为重传不会超过 5 次
- 我曾经忘了处理“重复包”。接收方收到重复的包,直接写进 Flash,结果固件被写坏了。后来加了去重逻辑:如果收到的包序号等于上一个已处理的序号,直接丢弃
- 我曾经把 CRC 校验放在接收中断里做,结果中断服务函数执行时间太长,导致丢数据。后来改成在任务里做校验,中断只负责收数据
嗯,协议设计这块儿,说白了就是“简单可靠”四个字。别追求花哨,别过度设计。你想想看,用户拿着血压计,升级失败一次,可能就觉得你这产品不靠谱。所以,稳一点,再稳一点。
下一章咱们聊 Flash 分区管理和双备份机制,这是保证“升级失败还能启动”的关键。到时候见。