1. 状态机基础:有限状态机(FSM)的核心概念

各位做嵌入式的朋友,咱们今天聊聊状态机。说实话,我刚入行那会儿,觉得状态机就是个花架子——不就是 switch-case 嘛,谁不会写?直到我在一个串口协议解析项目里栽了大跟头,才明白这东西有多重要。

那次项目是这样的:一个工业传感器通过 UART 上报数据,协议很简单,帧头+长度+数据+校验。我一开始用标志位加 if-else 硬写,测试时单帧数据没问题,一上现场就乱套。为什么?因为通信是字节流,你不知道哪一帧会断在哪。后来老老实实上了状态机,问题迎刃而解。

1.1 什么是有限状态机?

有限状态机,说白了就是一个系统在任意时刻只能处于有限个状态中的一个。它根据输入事件,按照预定规则从一个状态转移到另一个状态,并在转移过程中执行动作。

你想想看,这跟嵌入式通信解析的场景天然吻合——你收到一个字节,系统处于某个解析阶段,根据这个字节决定下一步做什么。

FSM 四要素:

  • 状态(State):系统在某一时刻所处的稳定情形
  • 事件(Event):触发状态转移的外部或内部输入
  • 转移(Transition):从一个状态到另一个状态的路径
  • 动作(Action):转移过程中执行的操作

举个例子,一个最简单的 UART 帧解析状态机:

// 状态定义
typedef enum {
    ST_IDLE,      // 空闲,等待帧头
    ST_HEADER,    // 已收到帧头,等待长度
    ST_LENGTH,    // 已收到长度,等待数据
    ST_DATA,      // 接收数据中
    ST_CHECK,     // 等待校验字节
    ST_DONE       // 帧接收完成
} FrameState;

// 事件:收到一个字节
FrameState process_byte(FrameState cur_state, uint8_t byte) {
    switch (cur_state) {
        case ST_IDLE:
            if (byte == FRAME_HEADER) return ST_HEADER;
            return ST_IDLE;
        case ST_HEADER:
            // 保存长度,进入数据接收
            frame.len = byte;
            frame.pos = 0;
            return ST_DATA;
        case ST_DATA:
            frame.buf[frame.pos++] = byte;
            if (frame.pos >= frame.len) return ST_CHECK;
            return ST_DATA;
        case ST_CHECK:
            if (verify_crc(frame.buf, frame.len, byte))
                return ST_DONE;
            return ST_IDLE;  // 校验失败,重新开始
        case ST_DONE:
            // 处理完一帧,回到空闲
            return ST_IDLE;
        default:
            return ST_IDLE;
    }
}

1.2 为什么通信协议解析必须用状态机?

这个问题我问过不少新人。有人跟我说用标志位也能做,有人觉得状态机太麻烦。我的回答是:不是必须用,但不用你会后悔。

通信协议解析有几个天然痛点:

痛点 说明 状态机如何解决
字节流不完整 数据可能在任何位置被打断 状态保存了解析进度,断点续传
多帧交错 高优先级帧可能插入 状态机可嵌套或优先级切换
错误恢复 噪声导致数据异常 定义明确的错误转移路径
可读性差 if-else 嵌套深如地狱 状态表一目了然
调试困难 不知道当前解析到哪一步 打印当前状态即可定位

我记得有一次帮同事排查一个 CAN 协议解析的 bug。他的代码写了七八层 if 嵌套,里面还有 goto。我看了半小时没看明白。后来我让他改成状态机,不到半天就找到了问题——原来是在某个状态下漏处理了一个字节。

避坑指南:我曾经在一个项目中,状态机里忘了处理超时事件。结果设备在某个状态下卡死了,再也收不到后续数据。从那以后,我每个状态都会加一个超时转移,哪怕只是回到 IDLE 状态重新开始。

1.3 状态机的三种实现方式

我个人习惯把状态机实现分成三类,各有适用场景:

  1. switch-case 式:最直观,适合状态数少于 20 个的场景。代码可读性好,但状态多了会膨胀。
  2. 状态表式:用二维数组存储状态转移矩阵。适合状态多、转移规则固定的场景,比如 TCP 协议栈。
  3. 函数指针式:每个状态对应一个处理函数。灵活,适合复杂状态机,但调试时函数调用链不好追踪。

下面这张图展示了状态机在通信协议解析中的核心逻辑:

通信协议解析状态机核心流程 ST_IDLE ST_HEADER ST_LENGTH ST_DATA ST_CHECK ST_DONE ST_ERROR 收到帧头 保存长度 开始接收 数据收满 校验通过 处理完成,回到空闲 校验失败 错误恢复,回到空闲 每个状态都有超时保护

1.4 状态机在中断中的特殊考量

嵌入式通信协议解析,十有八九是在中断里做的。这里有个关键问题:中断上下文里能不能跑状态机?

我的答案是:能,但要小心。中断里跑状态机有几个铁律:

  • 不能阻塞:状态转移必须是 O(1) 的,不能有循环等待
  • 不能动态分配内存:中断里 malloc 是找死
  • 状态变量要 volatile:防止编译器优化掉中断和主循环之间的共享变量
  • 尽量用 switch-case:函数指针在中断里不好调试

我的习惯:中断里只做状态转移和字节缓存,不做协议解析。解析工作放到主循环或任务里做。这样既保证了中断的实时性,又避免了在中断里做复杂逻辑。

1.5 调试状态机的实用技巧

调试状态机,说白了就是搞清楚「当前在哪」和「为什么到这」。我分享几个实战技巧:

  1. 状态日志:每个状态转移都打印一条日志,格式固定为 [状态机名] 旧状态 -> 新状态 (事件)
  2. 状态计数器:统计每个状态被进入的次数,异常时一看就知道哪个状态被频繁触发
  3. 状态超时监控:每个状态设置最大停留时间,超时触发告警
  4. 状态机快照:在调试接口里暴露当前状态、上次事件、状态栈(如果有嵌套)

我曾经在一个项目中,设备偶尔会死机。查了三天没找到原因。后来加了状态日志,发现状态机在某个状态下收到了一个不该出现的字节,直接跑飞了。加了一个 default 分支处理非法事件,问题解决。

嗯,状态机的基础就聊到这。记住一句话:状态机不是银弹,但它是嵌入式通信协议解析最可靠的武器。后面的章节我们会深入中断管理、嵌套状态机、状态机调试工具链等实战内容。