实战: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. 状态机流程图
下面这张图把五个状态和转移条件画清楚了。你调试的时候,把当前状态打印出来,对照这张图,问题一目了然。
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、甚至自定义协议,都能快速上手。