4、数据帧结构设计:帧头、帧尾、长度域、数据域、校验域的设计原则

做串口烧录这么多年,我踩过最大的坑,就是帧结构设计不合理。你想想看,一个字节对不上,整包数据就废了。更头疼的是,你根本不知道是硬件干扰丢包了,还是协议本身就有漏洞。

所以这一章,咱们把帧结构拆开揉碎了讲。说白了,帧就是数据在串口线上跑的「集装箱」。集装箱设计得好,货物(数据)才能安全到达。

核心观点:一个健壮的帧结构,是串口通信的基石。它决定了你能不能在嘈杂的工业现场,准确地把数据从设备里「捞」出来。

4.1 帧结构总览:一帧数据长什么样?

我习惯把一帧数据分成五个部分:帧头、长度域、数据域、校验域、帧尾。它们各司其职,缺一不可。

数据帧结构示意图 帧头 0xAA 0x55 长度域 1~2 字节 数据域 N 字节(有效载荷) 校验域 CRC/累加和 帧尾 0x0D 0x0A 💡 说明: 帧头和帧尾用于「定界」,长度域用于「拆包」,校验域用于「验伤」。四者配合,才能保证数据完整到达。

4.2 帧头(Frame Header)—— 数据包的「身份证」

帧头的作用就一个:告诉接收端「注意,数据要来了」。我见过有人用单字节 0xAA 做帧头,结果在强干扰环境下频繁误触发。后来我改成了双字节固定模式,比如 0xAA 0x55,误判率直接降了一个数量级。

设计原则:

  • 唯一性:帧头模式在数据域中出现的概率要极低。如果数据里也频繁出现 0xAA 0x55,那就需要做字节填充(Byte Stuffing)。
  • 长度:建议 2~4 字节。太短容易误判,太长浪费带宽。
  • 固定值:不要用可变帧头,那会让接收逻辑变得极其复杂。

我的经验:在 STM32 的 Bootloader 项目中,我用了 0xAA 0x55 0x01 三字节帧头。前两字节做同步,第三字节做协议版本号。这样后期升级协议时,接收端可以自动识别版本。

4.3 长度域(Length Field)—— 告诉你要收多少

长度域记录的是数据域的长度。注意,是数据域的长度,不是整帧的长度。这个细节很多人搞混。

设计原则:

  • 定长还是变长?如果数据域长度固定,可以省略长度域。但串口烧录场景下,数据长度经常变化,所以我建议保留。
  • 字节数:1 字节最大 255,2 字节最大 65535。对于烧录场景,2 字节比较稳妥(一包数据通常 256~1024 字节)。
  • 小端还是大端?我统一用小端(Little-Endian),因为 Cortex-M 系列 MCU 原生就是小端,省去转换开销。
// 长度域解析示例(C语言)
uint16_t data_len = (uint16_t)(rx_buffer[2]) | ((uint16_t)(rx_buffer[3]) << 8);
// 假设帧头占2字节,长度域从第3字节开始,占2字节,小端模式

4.4 数据域(Data Field)—— 真正的「货物」

数据域就是你要传输的有效数据。在烧录场景下,它可能是固件的一小块、一条 AT 指令、或者一组配置参数。

设计原则:

  • 最大长度:受接收缓冲区大小限制。我一般设 1024 字节,超过就分包。
  • 对齐:如果数据域包含结构体,建议 4 字节对齐,否则某些 MCU 会触发 HardFault。
  • 避免敏感值:数据域里尽量不要出现帧头模式。如果无法避免,用字节填充(0x7D 转义)或改用更复杂的帧头。

⚠️ 我曾经踩过的坑:有一次数据域里连续出现了 0xAA 0x55,接收端直接把它当帧头处理了,导致后面所有数据都错位。从那以后,我强制在数据域里做字节填充——遇到 0xAA 就发 0x7D 0x02,遇到 0x7D 就发 0x7D 0x01。

4.5 校验域(Checksum Field)—— 数据的「体检报告」

串口通信受干扰是常态。校验域就是用来检查数据在传输过程中有没有「受伤」的。

常见校验方式对比:

校验方式 字节数 检错能力 计算开销 适用场景
累加和(Sum) 1~2 低(无法检测字节顺序错误) 极低 简单控制指令
异或校验(XOR) 1 中 低 小数据包
CRC-8 1 较高 中 中等可靠性要求
CRC-16 2 高(可检测所有单/双比特错误) 中 烧录数据、文件传输
CRC-32 4 极高 较高 固件完整性校验

我的建议:烧录场景下,至少用 CRC-16。我习惯用 CRC-16-IBM 多项式(0x8005),查表法实现,速度很快。

// CRC-16 查表法实现片段
uint16_t crc16_update(uint16_t crc, uint8_t data) {
    static const uint16_t crc16_table[256] = {
        0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 省略完整表 ... */
    };
    return (crc >> 8) ^ crc16_table[(crc ^ data) & 0xFF];
}

// 计算整包校验
uint16_t calc_frame_crc(uint8_t *buf, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc = crc16_update(crc, buf[i]);
    }
    return crc;
}

4.6 帧尾(Frame Tail)—— 收工的信号

帧尾的作用是告诉接收端「这包数据结束了」。它和帧头配合,完成帧同步。

设计原则:

  • 固定模式:常用 0x0D 0x0A(回车换行)或 0x7E。
  • 不要与帧头相同:否则接收端无法区分一包的开始和结束。
  • 可选性:如果长度域足够可靠,帧尾可以省略。但我个人习惯保留,多一层保险。

避坑指南:我曾经在一个项目中,帧尾用了 0x00。结果数据域里大量 0x00 导致接收端提前判定帧结束。后来我把帧尾改成了 0x0D 0x0A,问题解决。记住,帧尾的值在数据域中出现的概率必须极低。

4.7 完整帧结构示例

下面是我在某个量产项目中实际使用的帧结构,你可以直接参考:

偏移 大小(字节) 字段 值/说明
0 2 帧头 0xAA 0x55
2 2 长度域 数据域长度(小端)
4 1 命令字 0x01=烧录, 0x02=读取, 0x03=擦除
5 N 数据域 有效载荷(最大 1019 字节)
5+N 2 校验域 CRC-16(从帧头到数据域结束)
7+N 2 帧尾 0x0D 0x0A

你看,整个帧结构清晰明了。接收端只需要三步:找帧头 → 读长度 → 校验。逻辑简单,不容易出 bug。

4.8 总结一下

帧结构设计没有银弹,但有原则可循。帧头要唯一,长度域要准确,数据域要防冲突,校验域要够强,帧尾要可靠。把这五点做好,你的串口通信就成功了一半。

嗯,这一章就到这里。记住,好的帧结构是设计出来的,不是调试出来的。前期多花点心思,后期少掉很多头发。