实战:UART协议解析状态机设计

从字节流中解析AT指令,这件事看起来简单,做起来坑不少。我早年在一个物联网项目里,就吃过这个亏——串口数据一多,状态就乱了,解析出来的指令缺胳膊少腿。后来老老实实画状态机,才把问题按住。

今天咱们就手把手设计一个五态UART解析机:空闲态、接收态、转义态、校验态、完成态。你跟着走一遍,以后遇到类似协议,直接套这个框架。

1. 协议约定:我们解析什么?

先定规矩。假设我们的AT指令格式如下:

字段 长度 说明
帧头 1字节 0xAA
长度 1字节 有效载荷长度(不含头、长度、校验)
有效载荷 N字节 AT指令内容,如 "AT+CMGS"
校验 1字节 从帧头到有效载荷末尾的异或和
帧尾 1字节 0x55

注意,有效载荷里如果出现 0xAA 或 0x55,需要做字节转义。转义字符用 0x7D,后面跟原字节异或 0x20。这是HDLC的经典做法,我直接拿来用了。

2. 五态状态机设计

状态机一共五个状态,每个状态只做一件事。你想想看,这样调试的时候,出问题一眼就能定位到哪个状态卡住了。

核心原则:每个状态只处理一个字节,处理完立即决定下一个状态。绝不跨字节做复杂判断。

2.1 空闲态 (IDLE)

等待帧头 0xAA。收到 0xAA 就跳转到接收态,否则原地不动。这里有个细节:如果收到 0x7D(转义字符),要不要处理?我个人习惯是不处理——帧头不允许转义,否则协议就乱了。

2.2 接收态 (RECV)

这是核心状态。每收到一个字节,先判断是不是转义字符 0x7D:

  • 是 0x7D → 跳转到转义态,标记下一个字节需要反转
  • 是 0x55(帧尾)→ 跳转到校验态,准备做完整性检查
  • 其他字节 → 存入缓冲区,继续留在接收态

我在项目中遇到过一个问题:缓冲区满了怎么办?我的做法是设置最大长度,超限直接丢弃整帧,回到空闲态。宁可丢一帧,不能把内存搞崩。

2.3 转义态 (ESC)

这个状态只处理一个字节。把收到的字节与 0x20 异或,恢复原始值,存入缓冲区。然后跳回接收态。

避坑指南:我曾经忘记在转义态里重置超时计数器,导致转义后的字节被当成新帧头处理。记住:转义态只做异或和存值,不做任何其他判断。

2.4 校验态 (CHECK)

收到帧尾 0x55 后进入。计算从帧头到有效载荷末尾的异或和,与接收到的校验字节比对:

  • 校验通过 → 跳转到完成态,通知上层有完整指令
  • 校验失败 → 回到空闲态,丢弃整帧

这里要注意:校验字节本身不参与异或计算。我见过有人把校验字节也算进去,结果永远对不上。

2.5 完成态 (DONE)

这个状态其实是个瞬态。把解析好的指令交给上层回调函数,然后立即回到空闲态。不需要在这里做任何延时或等待。

3. 状态机流程图

下面这张图把五个状态和转移条件画清楚了。你调试的时候,把当前状态打印出来,对照这张图,问题一目了然。

UART AT指令解析状态机 空闲态 接收态 转义态 校验态 完成态 收到 0xAA 收到 0x7D 异或 0x20 后回接收态 收到 0x55 校验通过 回调后回空闲 校验失败 调试技巧: 每个状态入口打印 "STATE: IDLE/RECV/ESC/CHECK/DONE" 每个状态出口打印 "NEXT: 目标状态名"

4. 代码实现骨架

下面是一个精简的实现。我刻意去掉了平台相关的串口驱动,只保留状态机核心逻辑。你移植的时候,把 uart_get_byte() 换成你的接收函数就行。

// 状态枚举
typedef enum {
    STATE_IDLE,
    STATE_RECV,
    STATE_ESC,
    STATE_CHECK,
    STATE_DONE
} parser_state_t;

// 解析器上下文
typedef struct {
    parser_state_t state;
    uint8_t buf[256];
    uint16_t len;
    uint8_t checksum;
    uint8_t expected_checksum;
} at_parser_t;

void at_parser_init(at_parser_t *p) {
    p->state = STATE_IDLE;
    p->len = 0;
    p->checksum = 0;
}

void at_parser_feed(at_parser_t *p, uint8_t byte) {
    switch (p->state) {
        case STATE_IDLE:
            if (byte == 0xAA) {
                p->state = STATE_RECV;
                p->len = 0;
                p->checksum = 0;
                // 帧头参与校验
                p->checksum ^= byte;
            }
            break;

        case STATE_RECV:
            if (byte == 0x7D) {
                p->state = STATE_ESC;
            } else if (byte == 0x55) {
                p->state = STATE_CHECK;
            } else {
                if (p->len < sizeof(p->buf)) {
                    p->buf[p->len++] = byte;
                    p->checksum ^= byte;
                } else {
                    // 缓冲区满,丢弃
                    p->state = STATE_IDLE;
                }
            }
            break;

        case STATE_ESC:
            // 转义:异或 0x20 恢复原始值
            byte ^= 0x20;
            if (p->len < sizeof(p->buf)) {
                p->buf[p->len++] = byte;
                p->checksum ^= byte;
            }
            p->state = STATE_RECV;
            break;

        case STATE_CHECK:
            p->expected_checksum = byte;
            if (p->checksum == p->expected_checksum) {
                p->state = STATE_DONE;
            } else {
                p->state = STATE_IDLE;
            }
            break;

        case STATE_DONE:
            // 回调上层处理
            at_command_callback(p->buf, p->len);
            p->state = STATE_IDLE;
            break;
    }
}

5. 中断上下文中的注意事项

这个状态机通常跑在UART接收中断里。中断里最怕两件事:耗时过长和重入。

警告:不要在中断里直接调用上层回调函数。正确做法是设置一个标志位,在主循环里处理。否则回调里如果做了耗时操作,会导致中断延迟,丢字节是小事,系统崩溃就麻烦了。

我个人习惯的做法是:中断里只做状态转移和数据存储,完成后置一个 frame_ready 标志。主循环检测到这个标志后,再调用回调。这样中断的占用时间控制在微秒级。

6. 调试手段:状态机日志

调试状态机,最有效的办法就是打日志。我一般会在每个状态入口和出口各打一条:

// 伪代码:调试日志宏
#define PARSER_LOG(state, next) \
    printf("[PARSER] STATE=%s -> NEXT=%s\r\n", \
           state_names[state], state_names[next])

// 在状态转移处调用
PARSER_LOG(p->state, STATE_RECV);

你想想看,如果收到一帧数据后,状态卡在 RECV 不动了,说明没收到帧尾。如果频繁进入 ESC 态,说明有效载荷里有很多 0xAA 或 0x55。这些信息对定位问题非常有帮助。

避坑指南:我曾经在调试时把日志打印放在中断里,结果打印太慢,导致后续字节丢失。后来改成环形缓冲区+主循环打印,问题解决。记住:中断里只存日志,不输出日志。

7. 状态机健壮性检查清单

最后,给你一份自检清单。每次写完状态机,逐条核对:

检查项 说明
每个状态都有超时保护 防止硬件异常导致状态卡死
缓冲区溢出处理 超限时丢弃整帧,回到空闲态
转义态不处理其他逻辑 只做异或和存值
校验态不修改缓冲区 只做比对,不写数据
完成态是瞬态 回调后立即回到空闲
中断里不调用回调 用标志位延迟到主循环处理

嗯,这套五态机我用了好几年,从8位MCU到ARM Cortex-M,基本没出过问题。你把它吃透了,以后解析Modbus、CANOpen、甚至自定义协议,都能快速上手。