4、数据帧结构设计:帧头、帧尾、长度域、数据域、校验域的设计原则
做串口烧录这么多年,我踩过最大的坑,就是帧结构设计不合理。你想想看,一个字节对不上,整包数据就废了。更头疼的是,你根本不知道是硬件干扰丢包了,还是协议本身就有漏洞。
所以这一章,咱们把帧结构拆开揉碎了讲。说白了,帧就是数据在串口线上跑的「集装箱」。集装箱设计得好,货物(数据)才能安全到达。
核心观点:一个健壮的帧结构,是串口通信的基石。它决定了你能不能在嘈杂的工业现场,准确地把数据从设备里「捞」出来。
4.1 帧结构总览:一帧数据长什么样?
我习惯把一帧数据分成五个部分:帧头、长度域、数据域、校验域、帧尾。它们各司其职,缺一不可。
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 总结一下
帧结构设计没有银弹,但有原则可循。帧头要唯一,长度域要准确,数据域要防冲突,校验域要够强,帧尾要可靠。把这五点做好,你的串口通信就成功了一半。
嗯,这一章就到这里。记住,好的帧结构是设计出来的,不是调试出来的。前期多花点心思,后期少掉很多头发。