3. UART协议帧结构设计:从裸数据到可靠报文

做嵌入式通信这么多年,我最大的体会就是:裸数据不可靠。你想想看,UART 只是把字节一个个发出去,接收方怎么知道这一包数据从哪开始、到哪结束?中间要是丢了一个字节,整条数据流就全乱了。所以我们需要一个东西——协议帧。

说白了,协议帧就是给原始数据套上一层“信封”。这个信封告诉接收方:
“嘿,我要开始发数据了(帧头),这是我要发的数据有多长(长度字段),这是真正的数据(负载),最后这是校验码,你算算对不对(校验和/CRC)。”

我个人习惯把协议帧设计分成四个核心要素:帧头/帧尾、数据长度字段、校验和、CRC16。今天我们就一个一个拆开讲。

3.1 帧头与帧尾:通信的“起跑线”和“终点线”

帧头的作用很简单——告诉接收方“注意,我要开始发一帧数据了”。帧尾则是“这帧数据结束了”。

我在项目中遇到过一个问题:如果帧头和帧尾选得太简单,比如用 0xAA 做帧头,结果数据里也出现了 0xAA,接收方就会误判。所以帧头一般选两个或以上字节,比如 0xAA 0x55,或者 0x7E 0x7E,降低误判概率。

要素 推荐值 说明
帧头 0xAA 0x55 两个字节,误判概率低
帧尾 0x0D 0x0A CR+LF,兼容串口终端习惯
可选帧尾 0x7E HDLC 风格,单字节但需转义
小技巧:如果你用 0xAA 0x55 做帧头,数据里出现相同组合的概率大约是 1/65536。对于大多数工业场景,这个误判率完全可以接受。

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);  // 从长度字段开始算
避坑指南:校验和算法虽然简单,但有一个致命弱点——如果两个字节同时出错且和不变,校验和检测不出来。比如 0x01 + 0x02 = 0x03,如果变成 0x02 + 0x01,和还是 0x03。所以对可靠性要求高的场景,我建议用 CRC。

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;
}
关键点: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 协议帧结构总览图

下面这张图把整个协议帧的组成和校验范围画清楚了:

UART 自定义协议帧结构 帧头 0xAA 0x55 长度 1 字节 数据负载 N 字节 校验和 1 字节 CRC16 2 字节 帧尾 0x0D 0x0A 校验范围(长度 + 数据 + 校验和/CRC) 帧定位范围(帧头 + 帧尾) 建议:校验和与 CRC16 二选一,不要同时使用

3.7 接收端解析流程

接收端怎么解析这个帧?我一般用状态机来处理,简单又可靠:

  1. 空闲状态:等待帧头 0xAA
  2. 收到 0xAA:进入“等待第二个帧头”状态
  3. 收到 0x55:进入“接收长度”状态
  4. 收到长度值:进入“接收数据”状态,按长度值收 N 个字节
  5. 数据收完:进入“接收校验”状态,收校验和或 CRC
  6. 校验通过:进入“接收帧尾”状态,收 0x0D 0x0A
  7. 帧尾正确:一帧完整接收,回调处理函数

任何一步出错,立即回到空闲状态。这样即使线路干扰严重,也不会卡死。

经验之谈:我曾经在一个项目中,接收状态机没有加超时处理。结果有一次数据线松了,状态机卡在“接收数据”状态,后面所有数据都解析不了。后来我加了一个 100ms 的超时定时器,任何状态超过 100ms 没收到预期字节,自动复位到空闲状态。从此再没出过问题。

3.8 总结

协议帧设计是 UART 通信的基石。帧头帧尾解决“数据从哪开始到哪结束”,长度字段解决“要读多少字节”,校验和/CRC 解决“数据有没有出错”。

我个人建议:初学者先从校验和开始,代码简单,调试方便。等项目跑通了,再升级到 CRC16,提高可靠性。一口吃不成胖子,通信协议也是一步步迭代出来的。