第27问:协议解析实战——串口通信协议,帧头帧尾校验,状态机解析

串口通信,搞嵌入式的没人能绕开它。

但说实话,很多人写了几年串口收发,遇到粘包、丢帧、校验失败,还是一脸懵。我当年刚入行时也踩过这个坑——明明数据发出来了,解析出来全是乱码。后来才明白,问题不在硬件,在协议解析的设计上。

今天我们就来聊聊,怎么用状态机把串口协议解析做得干净、可靠。

一、协议帧结构:先定规矩

没有规矩,不成方圆。串口通信也一样。你要解析数据,首先得知道数据长什么样。

我个人习惯用这种帧结构:

字段 长度(字节) 说明
帧头 2 固定值 0xAA 0x55
长度 1 数据域长度(不含帧头帧尾)
命令字 1 0x01 读,0x02 写
数据域 N 实际载荷
校验 1 累加和校验
帧尾 1 固定值 0x0D

你想想看,为什么要有帧头和帧尾?说白了,就是为了让接收方能找到数据的“边界”。

帧头告诉你“数据要开始了”,帧尾告诉你“数据结束了”。中间再加个校验,确保数据没被干扰。

核心原则:帧头帧尾用固定值,校验用简单算法(累加和或CRC),长度字段让解析器知道要收多少字节。

二、状态机解析:别再用中断里做复杂逻辑

很多新手喜欢在串口中断里直接解析数据。我劝你别这么干。

中断里应该只做一件事:把收到的字节扔进缓冲区。解析逻辑放到主循环或任务里做。

那解析怎么做?状态机是最优雅的方案。

来看一个典型的状态机设计:

typedef enum {
    STATE_IDLE,       // 空闲,等待帧头
    STATE_HEADER1,    // 收到第一个帧头 0xAA
    STATE_HEADER2,    // 收到第二个帧头 0x55
    STATE_LENGTH,     // 接收长度字段
    STATE_CMD,        // 接收命令字
    STATE_DATA,       // 接收数据域
    STATE_CHECKSUM,   // 接收校验
    STATE_FOOTER      // 接收帧尾
} ParserState_t;

typedef struct {
    ParserState_t state;
    uint8_t buffer[256];
    uint16_t index;
    uint8_t length;
    uint8_t checksum;
} Parser_t;

void Parser_Feed(Parser_t *p, uint8_t byte) {
    switch (p->state) {
        case STATE_IDLE:
            if (byte == 0xAA) {
                p->state = STATE_HEADER1;
                p->index = 0;
                p->checksum = 0;
            }
            break;

        case STATE_HEADER1:
            if (byte == 0x55) {
                p->state = STATE_HEADER2;
                p->buffer[p->index++] = 0xAA;
                p->buffer[p->index++] = 0x55;
            } else {
                p->state = STATE_IDLE;  // 帧头不对,重新等
            }
            break;

        case STATE_HEADER2:
            p->length = byte;
            p->buffer[p->index++] = byte;
            p->checksum += byte;
            p->state = STATE_LENGTH;
            break;

        case STATE_LENGTH:
            p->buffer[p->index++] = byte;
            p->checksum += byte;
            p->state = STATE_CMD;
            break;

        case STATE_CMD:
            p->buffer[p->index++] = byte;
            p->checksum += byte;
            p->state = STATE_DATA;
            break;

        case STATE_DATA:
            p->buffer[p->index++] = byte;
            p->checksum += byte;
            if (p->index - 4 >= p->length) { // 4 = 帧头2 + 长度1 + 命令1
                p->state = STATE_CHECKSUM;
            }
            break;

        case STATE_CHECKSUM:
            if (byte == p->checksum) {
                p->state = STATE_FOOTER;
            } else {
                p->state = STATE_IDLE;  // 校验失败,丢弃
            }
            break;

        case STATE_FOOTER:
            if (byte == 0x0D) {
                // 帧尾正确,一帧数据解析完成
                ProcessFrame(p->buffer, p->index);
            }
            p->state = STATE_IDLE;  // 无论对错,回到空闲
            break;

        default:
            p->state = STATE_IDLE;
            break;
    }
}

这段代码看起来长,但逻辑很清晰。每个状态只做一件事:要么等特定字节,要么收数据。

我的经验:状态机解析最大的好处是“无阻塞”。你不需要等一帧收完再处理,来一个字节处理一个字节。这在多任务系统中特别有用。

三、状态机流程图:一目了然

光看代码可能不够直观。我画了一张状态转移图,帮你理清思路:

STATE_IDLE STATE_HEADER1 STATE_HEADER2 STATE_LENGTH STATE_CMD STATE_DATA STATE_CHECKSUM STATE_FOOTER 0xAA 0x55 数据收完 校验通过 0x0D 或超时 不是0x55 校验失败

看到没?状态机就像一个流水线。每个字节进来,走一个状态,要么前进,要么退回IDLE重新等。

四、避坑指南:我踩过的那些坑

协议解析看着简单,实际项目中坑不少。我分享几个亲身经历:

  • 帧头重复问题:我曾经遇到数据域里恰好有0xAA 0x55的情况。解析器误以为这是新帧头,结果数据全乱了。后来我加了“帧头超时”机制——如果收到帧头后超过一定时间没收到完整帧,就强制复位状态机。
  • 校验算法选择:累加和虽然简单,但抗干扰能力一般。我在工业项目里改用CRC8,效果好了很多。不过CRC计算稍慢,你要权衡一下。
  • 缓冲区溢出:如果对方发了超长帧,你的缓冲区可能被撑爆。我习惯在状态机里加一个最大长度检查,超过256字节直接丢弃。

特别注意:不要在中断里直接调用状态机解析函数!中断应该只做“收字节+放缓冲区”。解析放到主循环或任务里做。否则,一旦解析逻辑复杂,中断响应时间会变长,系统就卡了。

五、实战建议:从小做起

如果你刚开始做协议解析,我建议你从最简单的开始:

  1. 先定一个固定长度的帧(比如10字节),帧头帧尾固定,不做校验。
  2. 跑通了,再加长度字段,变成变长帧。
  3. 最后加校验,确保数据可靠性。

一步步来,别想一口吃成胖子。我在项目里见过太多人一上来就搞复杂协议,结果调试了两个月还没跑通。

嗯,协议解析其实没那么玄乎。状态机就是一张“地图”,告诉你每个字节该往哪走。只要地图画对了,代码自然就写对了。

总结一句话:帧头帧尾定边界,状态机做解析,校验保可靠。这三样齐了,串口通信基本稳了。

公众号:蓝海资料掘金营,微信deep3321