4、串口通信协议设计:帧头、帧尾、数据长度、校验和(CRC/累加和)的设计思路

好,咱们接着聊。上一章我们把串口调通了,数据能收能发了。但问题来了——你发一串字节过去,接收端怎么知道这一包数据从哪开始、到哪结束?中间有没有被干扰?数据对不对?

这就引出了我们今天的主角:通信协议。说白了,就是给数据定个规矩,让收发双方能“对上话”。

4.1 为什么需要协议?

我刚开始做嵌入式那会儿,也犯过傻。直接把传感器数据往串口里扔,结果接收端那边显示出来的全是乱码。后来才明白——没有协议的串口,就像没有交通规则的路口,数据包撞车是迟早的事。

一个靠谱的协议,至少得解决三个问题:

  • 定界:哪几个字节是一包?
  • 完整性:这包数据有没有被截断或合并?
  • 正确性:传输过程中有没有发生比特翻转?

嗯,这三个问题,分别对应帧头帧尾、数据长度、校验和。咱们一个一个说。

4.2 帧头与帧尾:给数据包画个边界

帧头,就是一包数据的起始标志。我习惯用 0xAA0x55 这种有规律的字节。为什么?因为它们在示波器上看波形特别明显,调试时一眼就能认出来。

帧尾呢,就是结束标志。常见的有 0x0D 0x0A(回车换行),或者 0xCC 这种。

我的小经验:帧头和帧尾不要用相同的值。比如帧头用 0xAA,帧尾用 0x55。否则接收端容易误判边界。

举个例子,一个最简单的协议帧结构:

帧头(1字节) | 数据长度(1字节) | 数据(N字节) | 校验和(1字节) | 帧尾(1字节)
  0xAA      |    0x05        |  [5字节数据] |   0x??       |   0x55

接收端怎么工作?它不断读串口,一旦读到 0xAA,就认为新包开始了。然后读长度字段,知道后面要收多少字节。最后读到 0x55,一包结束。

注意:如果数据内容里恰好出现了 0xAA0x55,就会造成误判。这叫“帧头帧尾冲突”。解决办法是——用转义字符,或者干脆把帧头帧尾设成不可能出现在数据中的值。

4.3 数据长度:别多收也别少收

数据长度字段,告诉接收端“这包数据里有多少个有效字节”。

我个人习惯把长度字段放在帧头后面、数据前面。这样接收端一读到帧头,马上就知道要等多久才能收完。

长度字段本身占几个字节?看情况:

  • 如果一包数据不超过255字节,1字节就够了。
  • 如果数据量比较大(比如图像数据),建议用2字节,甚至4字节。

我曾经在一个项目里吃过亏——图像数据包长度超过255字节,但我只用了1字节存长度。结果数据一长,长度字段溢出,接收端直接崩溃。从那以后,我但凡涉及图像传输,长度字段至少用2字节。

4.4 校验和:数据到底对不对?

串口传输虽然稳定,但也不是100%可靠。电磁干扰、线缆接触不良,都可能导致某一位翻转。校验和就是用来检测这种错误的。

常用的校验方式有两种:累加和CRC

4.4.1 累加和:简单粗暴

累加和的做法很简单:把帧头之后、校验和之前的所有字节加起来,取低8位(或16位)作为校验值。

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;
}

接收端收到数据后,用同样的算法算一遍,跟收到的校验和对比。一致就认为数据正确。

优点:实现简单,计算快,适合资源受限的MCU。

缺点:检测能力有限。比如两个字节同时翻转,累加和可能不变,错误就漏掉了。

我的建议:如果传输的是普通传感器数据,累加和完全够用。但如果是图像数据、固件升级包这种“错一个比特就完蛋”的场景,请用CRC。

4.4.2 CRC:更可靠的校验

CRC(循环冗余校验)本质上是一个多项式除法。它比累加和复杂,但检测能力也强得多——能检测出几乎所有常见的错误模式。

常用的CRC有:

  • CRC-8:1字节校验,适合小数据包
  • CRC-16:2字节校验,工业上最常用
  • CRC-32:4字节校验,用于文件传输、网络协议

对于串口图像传输,我个人推荐 CRC-16。它占用的带宽不大,但检测能力已经非常强了。

下面是一个CRC-16的查表法实现,速度很快:

uint16_t crc16(uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}
避坑指南:我曾经在一个项目里直接用网上找的CRC代码,结果收发两端用的多项式不一样,怎么都对不上。后来我养成了一个习惯——CRC的初始值、多项式、输出异或值,必须在文档里写清楚。收发两端用同一套参数,才能对上。

4.5 完整协议帧设计示例

好了,我们把上面这些元素拼起来,看看一个完整的图像数据协议帧长什么样:

帧头(2字节) | 数据长度(2字节) | 包序号(1字节) | 图像数据(N字节) | CRC-16(2字节) | 帧尾(2字节)
  0xAA 0x55 |    0x00 0x64    |    0x01       |   [100字节]     |  0x?? 0x??   | 0x0D 0x0A

解释一下:

  • 帧头0xAA 0x55,两个字节,降低误判概率
  • 数据长度:2字节,大端模式,这里表示100字节
  • 包序号:1字节,用于检测丢包或乱序
  • 图像数据:实际要传输的像素数据
  • CRC-16:对帧头之后、CRC之前的所有字节计算
  • 帧尾0x0D 0x0A,回车换行,方便用串口助手查看

4.6 协议设计的一些小建议

最后,分享几个我在实际项目中总结出来的经验:

  1. 协议版本号:在帧头后面加一个版本字段。万一以后协议升级,接收端能自动识别。
  2. 超时处理:接收端如果收到帧头后,在规定时间内没收到帧尾,就丢弃这包数据。防止死等。
  3. 调试友好:帧尾用 0x0D 0x0A,这样你用串口助手看原始数据时,每包数据自动换行,一目了然。
  4. 不要过度设计:如果只是传几个温度值,用累加和就够了。别一上来就上CRC-32,浪费CPU和带宽。

嗯,协议设计这块,说白了就是“够用就好”。你想想看,一个简单的传感器节点,你给它配个CRC-32,MCU算半天,电池也耗得快,何必呢?

下一章,我们聊聊怎么用Python写个上位机,把串口收到的图像数据解析出来,显示在屏幕上。到时候你就知道,今天设计的这个协议到底好不好用了。