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字节。所以必须把大文件切成小块,一块一块发。

分包逻辑其实不复杂,核心就三点:

  1. 固定包大小:除了最后一个包,其他包大小固定。比如每个包1024字节载荷。
  2. 包序号递增:从0开始,每发一个包序号+1。接收端靠序号来排序。
  3. 结束标记:最后一个包的类型字段设为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次,超过就报错。
  • 粘包处理:如果发送端连续发多个包,接收端可能一次收到多个包。这时候要循环解析,直到数据缓冲区被消费完。

嗯,协议设计这块就先讲这么多。说白了,就是定义好格式、做好校验、处理好分包。这三件事做好了,远程升级的通信基础就稳了。下一节咱们聊聊具体的升级流程和状态机设计,到时候你会看到这些协议字段是怎么在实际流程里跑起来的。