3. UART协议帧结构设计:从裸数据到可靠报文
做嵌入式通信这么多年,我最大的体会就是:裸数据不可靠。你想想看,UART 只是把字节一个个发出去,接收方怎么知道这一包数据从哪开始、到哪结束?中间要是丢了一个字节,整条数据流就全乱了。所以我们需要一个东西——协议帧。
说白了,协议帧就是给原始数据套上一层“信封”。这个信封告诉接收方:
“嘿,我要开始发数据了(帧头),这是我要发的数据有多长(长度字段),这是真正的数据(负载),最后这是校验码,你算算对不对(校验和/CRC)。”
我个人习惯把协议帧设计分成四个核心要素:帧头/帧尾、数据长度字段、校验和、CRC16。今天我们就一个一个拆开讲。
3.1 帧头与帧尾:通信的“起跑线”和“终点线”
帧头的作用很简单——告诉接收方“注意,我要开始发一帧数据了”。帧尾则是“这帧数据结束了”。
我在项目中遇到过一个问题:如果帧头和帧尾选得太简单,比如用 0xAA 做帧头,结果数据里也出现了 0xAA,接收方就会误判。所以帧头一般选两个或以上字节,比如 0xAA 0x55,或者 0x7E 0x7E,降低误判概率。
| 要素 | 推荐值 | 说明 |
|---|---|---|
| 帧头 | 0xAA 0x55 | 两个字节,误判概率低 |
| 帧尾 | 0x0D 0x0A | CR+LF,兼容串口终端习惯 |
| 可选帧尾 | 0x7E | HDLC 风格,单字节但需转义 |
3.2 数据长度字段:让接收方知道“要读多少”
帧头之后,紧跟着就是数据长度字段。这个字段告诉接收方:接下来我要发 N 个字节的数据,你数着点。
长度字段通常用 1 个字节(最大 255 字节)或 2 个字节(最大 65535 字节)。我个人习惯用 1 个字节,因为 UART 一帧数据一般不会超过 256 字节,太长反而容易出错。
嗯,这里要注意:长度字段本身也要参与校验。我曾经见过一个项目,长度字段被干扰后变成了 0xFF,接收方傻傻地等了 255 个字节,结果超时了。所以长度字段一定要放在校验范围内。
3.3 校验和(Checksum):最简单的错误检测
校验和是最基础的错误检测方法。做法很简单:把帧头之后、校验和之前的所有字节加起来,取低 8 位或低 16 位作为校验值。
举个例子:
// 计算 8 位校验和
uint8_t calc_checksum(uint8_t *data, uint16_t len) {
uint8_t sum = 0;
for (uint16_t i = 0; i < len; i++) {
sum += data[i];
}
return sum;
}
// 发送方计算并附加
uint8_t frame[] = {0xAA, 0x55, 0x03, 0x01, 0x02, 0x03, 0x00};
// 帧头: 0xAA 0x55
// 长度: 0x03
// 数据: 0x01 0x02 0x03
// 校验和: 0x03 + 0x01 + 0x02 + 0x03 = 0x09
frame[6] = calc_checksum(&frame[2], 4); // 从长度字段开始算
3.4 CRC16:工业级的错误检测
CRC(循环冗余校验)比校验和强太多了。它用多项式除法来生成校验码,能检测出所有单比特错误、双比特错误、奇数个错误,以及大部分突发错误。
常用的 CRC16 有多项式:
| CRC 类型 | 多项式 | 初始值 | 应用场景 |
|---|---|---|---|
| CRC-16/MODBUS | 0x8005 | 0xFFFF | 工业总线、RS485 |
| CRC-16/CCITT | 0x1021 | 0x0000 | 通信协议、XMODEM |
| CRC-16/USB | 0x8005 | 0x0000 | USB 协议 |
我个人最常用的是 CRC-16/MODBUS,因为 RS485 组网中 Modbus 协议就是用它。下面是一个查表法的实现,速度比直接计算快很多:
// CRC-16/MODBUS 查表法
static const uint16_t crc16_table[256] = {
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
// ... 省略中间 248 个值,实际代码中需要完整表
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
};
uint16_t calc_crc16(uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF; // MODBUS 初始值
for (uint16_t i = 0; i < len; i++) {
crc = (crc >> 8) ^ crc16_table[(crc ^ data[i]) & 0xFF];
}
return crc;
}
3.5 完整协议帧结构
把上面四个要素组合起来,一个完整的协议帧长这样:
| 帧头(2B) | 长度(1B) | 数据(NB) | 校验和(1B) | CRC16(2B) | 帧尾(2B) |
| 0xAA 0x55 | 0x03 | 0x01 0x02 0x03 | 0x09 | 0xXX 0xXX | 0x0D 0x0A |
你可能会问:“校验和和 CRC16 都要吗?”
我的建议是:二选一。对可靠性要求一般的场景,用校验和就够了;工业控制、数据采集这种场景,直接用 CRC16。两个都加反而浪费带宽。
3.6 协议帧结构总览图
下面这张图把整个协议帧的组成和校验范围画清楚了:
3.7 接收端解析流程
接收端怎么解析这个帧?我一般用状态机来处理,简单又可靠:
- 空闲状态:等待帧头 0xAA
- 收到 0xAA:进入“等待第二个帧头”状态
- 收到 0x55:进入“接收长度”状态
- 收到长度值:进入“接收数据”状态,按长度值收 N 个字节
- 数据收完:进入“接收校验”状态,收校验和或 CRC
- 校验通过:进入“接收帧尾”状态,收 0x0D 0x0A
- 帧尾正确:一帧完整接收,回调处理函数
任何一步出错,立即回到空闲状态。这样即使线路干扰严重,也不会卡死。
3.8 总结
协议帧设计是 UART 通信的基石。帧头帧尾解决“数据从哪开始到哪结束”,长度字段解决“要读多少字节”,校验和/CRC 解决“数据有没有出错”。
我个人建议:初学者先从校验和开始,代码简单,调试方便。等项目跑通了,再升级到 CRC16,提高可靠性。一口吃不成胖子,通信协议也是一步步迭代出来的。