3. 通信协议设计:自定义应用层协议、数据包结构定义与分包重组机制
各位同学,咱们今天聊点硬核的——通信协议设计。说实话,很多做嵌入式开发的朋友,一听到「协议」两个字就觉得头大,觉得那是搞网络的人该操心的事。但做电子标签远程升级,协议这块要是没设计好,后面有你哭的。
我当年做第一个远程升级项目时,就犯过傻。想着「简单点,直接发原始数据不就行了?」结果呢?数据错位、校验失败、设备变砖……嗯,那段时间我几乎天天被售后同事追着骂。从那以后,我养成了一个习惯:任何远程通信,必须先定义好协议,再写一行代码。
3.1 为什么需要自定义应用层协议?
你可能会问:「我用现成的Modbus、MQTT不行吗?」行,当然行。但电子标签升级有个特殊场景——数据量大、信道窄、设备资源受限。现成协议往往太「胖」了,头部长、冗余多,对只有几KB RAM的MCU来说,负担不小。
说白了,自定义协议就像给自己量身定做一套西装。合身、省料、干活利索。我个人的习惯是:能省则省,但关键字段一个不能少。
3.2 数据包结构定义
先看一个我常用的数据包结构。这个结构我在三个项目里验证过,稳定可靠。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定值 0xAA 0x55,用于同步 |
| 版本号 | 1 | 协议版本,当前为0x01 |
| 包类型 | 1 | 0x01=升级请求,0x02=数据包,0x03=结束包 |
| 数据长度 | 2 | 有效载荷的长度,小端模式 |
| 包序号 | 2 | 当前包在整个文件中的序号,从0开始 |
| 有效载荷 | N | 实际数据,最大1024字节 |
| CRC16校验 | 2 | 对整个包(从帧头到有效载荷末尾)的校验 |
这个结构总共固定头部占了8个字节。对于1024字节的载荷来说,开销不到1%,我觉得挺划算的。
关键点:帧头用两个字节是为了降低误同步的概率。0xAA 0x55这个组合在二进制里是10101010 01010101,交替的比特模式不容易被噪声模拟出来。我见过有人只用单字节做帧头,结果在强干扰环境下频繁误触发,后来不得不改。
3.3 帧头与版本号的设计考量
帧头这块,我再多说两句。你想想看,接收端怎么知道「哦,这是一个新包的开始」?就是靠帧头。所以帧头必须足够「独特」。我个人建议用0xAA 0x55或者0x5A 0xA5这种有规律的交替模式。
版本号呢?很多人觉得「一个协议写死了还要版本号干嘛?」——别天真了。产品迭代过程中,协议一定会改。我经历过一次惨痛的教训:产品卖出去5000台,突然要加一个新功能,协议字段得扩展。没有版本号,老设备和新设备根本没法区分,升级包发过去直接解析错误。从那以后,版本号成了我协议里的铁打字段。
3.4 长度字段与CRC校验
长度字段我用了2个字节,最大能表示65535字节。但实际载荷我限制在1024字节以内。为什么?两个原因:
- MCU的RAM有限,一次处理太多数据容易溢出
- 无线信道不稳定,包越长,出错概率越大
CRC校验我选的是CRC16-CCITT,多项式0x1021。这个算法在嵌入式领域很成熟,计算速度快,检错能力也够用。我见过有人用累加和做校验,说实话,那玩意儿对付噪声还行,对付突发错误基本等于裸奔。
小技巧:CRC计算可以用查表法,速度比逐位计算快10倍以上。我一般把表放在Flash里,256个uint16_t,也就512字节,不占什么空间。
3.5 分包与重组机制
好,现在到了重头戏——分包与重组。为什么需要分包?因为一个固件文件可能几百KB,甚至几MB。而我们的数据包最大才1024字节。所以必须把大文件切成小块,一块一块发。
分包逻辑其实不复杂,核心就三点:
- 固定包大小:除了最后一个包,其他包大小固定。比如每个包1024字节载荷。
- 包序号递增:从0开始,每发一个包序号+1。接收端靠序号来排序。
- 结束标记:最后一个包的类型字段设为0x03,告诉接收端「发完了」。
重组呢?接收端要做的事:
- 收到一个包,先校验CRC。通不过?直接丢弃,等重传。
- 校验通过,检查包序号。如果是期望的序号,写入缓冲区。
- 如果序号跳了,说明中间有包丢了,记录丢失的序号,等发送端重传。
注意:我曾经遇到过一个坑——接收端的缓冲区不够大。假设固件有200KB,而MCU只有16KB RAM,你不可能把所有包都收完再重组。正确的做法是边收边写Flash,或者用双缓冲区乒乓操作。我建议用后者,一个缓冲区收数据,另一个缓冲区写Flash,效率高不少。
3.6 代码示例:数据包封装与解析
光说不练假把式。我写一个简单的C代码片段,展示如何封装一个数据包。
// 数据包结构体
typedef struct {
uint8_t header[2]; // 帧头 0xAA 0x55
uint8_t version; // 版本号
uint8_t pkt_type; // 包类型
uint16_t data_len; // 数据长度
uint16_t seq_num; // 包序号
uint8_t payload[1024]; // 有效载荷
uint16_t crc; // CRC16校验
} upgrade_packet_t;
// 封装数据包
uint16_t pack_upgrade_packet(upgrade_packet_t *pkt, uint8_t type,
uint16_t seq, uint8_t *data, uint16_t len) {
pkt->header[0] = 0xAA;
pkt->header[1] = 0x55;
pkt->version = 0x01;
pkt->pkt_type = type;
pkt->data_len = len;
pkt->seq_num = seq;
memcpy(pkt->payload, data, len);
// 计算CRC,从header开始到payload末尾
pkt->crc = crc16_ccitt((uint8_t*)pkt,
sizeof(pkt->header) + sizeof(pkt->version) +
sizeof(pkt->pkt_type) + sizeof(pkt->data_len) +
sizeof(pkt->seq_num) + len);
// 返回总包长度:头部8字节 + 数据长度 + CRC2字节
return 8 + len + 2;
}
解析端呢?逻辑反过来:先收够8个字节的头部,解析出data_len,再收data_len个字节的载荷,最后收2个字节的CRC。然后对整个包做CRC校验,一致就处理,不一致就丢掉。
经验之谈:解析时一定要做边界检查。我见过有人直接memcpy,结果data_len被篡改成65535,直接把MCU搞崩了。记住:永远不要信任收到的数据。
3.7 避坑指南
最后,我总结几个实战中容易踩的坑:
- 字节序问题:MCU有大小端之分。我建议统一用小端,因为ARM Cortex-M系列默认就是小端。如果你用大端MCU,记得做转换。
- 超时重传:接收端收到一个包后,要发ACK。发送端如果超过500ms没收到ACK,就重传。重传次数建议3次,超过就报错。
- 粘包处理:如果发送端连续发多个包,接收端可能一次收到多个包。这时候要循环解析,直到数据缓冲区被消费完。
嗯,协议设计这块就先讲这么多。说白了,就是定义好格式、做好校验、处理好分包。这三件事做好了,远程升级的通信基础就稳了。下一节咱们聊聊具体的升级流程和状态机设计,到时候你会看到这些协议字段是怎么在实际流程里跑起来的。