4297 字
21 分钟
从一帧串口数据说起:状态机的几种写法

写过串口那篇之后, 有个东西一直没写: 帧是怎么”收完”的.

排错的时候看的是串口工具里的完整帧, 一屏一帧, 干净利落. 但程序看到的东西完全不是这样 —— cat /dev/ttyS1 | od -t x1 -v 打出来是一串没有边界的字节, 你甚至不知道它从哪个字节开始算一帧.

这篇文章从这件事讲起, 然后一路走到业务代码里的状态机: 为什么解析天然是个状态机, 以及五种写法各自适合什么场合.

1. 一次 read 拿不到一帧#

串口是字节流, 不是消息队列. 三个现实必须先接受:

一是半包: 一帧 32 字节, 这次 read 只回来 17 字节, 剩下 15 字节还在路上. 二是粘包: 上一帧的尾巴和下一帧的头挤在一次 read 里, 一次拿到 1.5 帧. 三是噪声: 上电、插拔、波特率不对的时候, 你会收到一堆根本不是帧的字节.

所以”解析”从来不是一次函数调用. 它是一台在一次字节流里慢慢推进的机器 —— 这就是状态机.

先把帧摆出来:

 EB 90  │  n  │  .. n 个字节 ..  │  ??  │  90 EB
 帧头      长度    载荷(要等)       校验   帧尾 

五段分别是帧头 EB 90、一个长度字节、n 字节载荷、校验、帧尾 90 EB. 只有载荷是”等出来的”——而”等”这件事, 就是状态机存在的全部理由.

2. 最老实的写法: 一个状态变量加 switch#

第一次写帧解析, 基本都是这样:

enum class St { Head0, Head1, Len, Payload, Crc0, Crc1, Tail0, Tail1 };

void on_byte(uint8_t b) {
    switch (st) {
    case St::Head0: if (b == 0xEB) st = St::Head1; break;
    case St::Head1: st = (b == 0x90) ? St::Len : St::Head0; break;
    case St::Len:   len = b; idx = 0; st = len ? St::Payload : St::Crc0; break;
    case St::Payload:
        buf[idx++] = b;
        if (idx == len) st = St::Crc0;
        break;
    // ... 校验、帧尾,任何一步不对就退回 Head0 并清计数
    }
}

它有几个别的方法比不了的优点:

状态就写在变量里, 打一行日志把 st 和已收字节数打出来, 现场问题能直接对上. 调试器里设个断点, 一眼就知道卡在哪一步. 新人接手五分钟能看懂.

坏处也很具体. 我踩的是这两条:

一是不对称. 上面每一步都有两个出口(收对了/收错了), 但 switch 让”收错”那一支散落在每个 case 里. 加一个”帧尾之后允许 10 ms 静默再收下一帧”, 得在四个地方各补一遍.

二是回退动作重复. 任何一步失败都要”丢缓冲、清计数、回 Head0”. 复制了七八遍之后, 某一次忘了清 idx, 于是下一次的载荷从上一帧的位置开始写 —— 表现是偶发校验失败, 查了两天才发现.

这就是为什么会有后面几种写法: 不是 switch 不好, 是状态一多, 重复就多.

3. 写法二: 把状态图抄成转移表#

既然用 switch 的痛点是重复, 那第一步就是把”重复”变成”数据”:

struct Trans { St cur; Ev ev; St next; void (*act)(Ctx&); };

static const Trans kTable[] = {
    { St::Head0,   Ev::Byte, St::Head1,   nullptr    },
    { St::Head1,   Ev::Byte, St::Len,     nullptr    },
    { St::Len,     Ev::Byte, St::Payload, on_len     },
    { St::Payload, Ev::Byte, St::Payload, on_payload },  // 收满由 guard 决定
    // ...
};

void dispatch(Ctx &c, Ev ev) {
    for (const auto &t : kTable)
        if (t.cur == c.st && t.ev == ev && guard(t, c)) {
            if (t.act) t.act(c);
            c.st = t.next;
            return;
        }
    on_invalid(c, ev);      // 没匹配上, 统一收口
}

好处是把状态图直接抄成了数组: 协议文档里画一张图, 代码里就是一张表, 一眼能核对有没有漏. 而且能穷举测试 —— 状态 × 事件的所有组合, 写个循环全跑一遍, 比手工点界面靠谱得多.

代价有两个. 一是动作只能是”签名统一的函数”, 稍微带点参数就要往 Ctx 里塞, 塞着塞着 Ctx 变成上帝对象. 二是调试栈里只剩 dispatch, 看不到业务语义 —— 崩了之后栈是这样的, 没人知道它当时在等校验还是等帧尾.

所以我给它划的适用范围是: 状态少于十来个、事件种类单一、需要和文档逐条对得上的地方. 比如一个协议里带三种帧型, 每种走不同的收帧流程.

4. 写法三: 一个状态一个类#

状态变多之后, 最自然的想法是”每个状态管好自己的事”:

struct State {
    virtual ~State() = default;
    virtual void enter(Ctx&) {}
    virtual State* on(Ctx&, Ev) = 0;     // 返回下一个状态
    virtual void exit(Ctx&) {}
};

struct WaitHead : State {
    State* on(Ctx &c, Ev ev) override;
};

它解决的是 switch 解决不了的那件事: 一个状态的入场动作、出场动作、超时策略、日志措辞, 都放在一起. 帧尾状态退出时要把缓冲游标归零, 那就写在 exit 里, 只写一份.

代价也很真实:

类会爆炸. 八个状态八个类, 文件从 80 行变成 400 行, 而每个类的逻辑只有三五行. 状态之间共享的数据必须显式归位到 Ctx, 归不好就出现”两个状态各自改了同一个字段”. 还有一点不太好意思说但是真的: 现场排查时你只有一次机会, 面对一堆 WaitCrc::on 的调用栈, 有时候不如一句 printf("state=%d") 实在.

这套写法适合状态有独立生命周期的场合 —— 每个状态进来要建连接、退出要拆连接、各自持有缓冲区. 纯字节解析其实用不上这么重.

5. 写法四: C++17 的 variant + visit#

如果编译器够新, 我最近更愿意用这个: 状态就是类型, 数据跟着状态走.

struct Head0 {}; struct Head1 {}; struct WaitLen {};
struct InPayload { std::vector<uint8_t> buf; size_t need; };

using St = std::variant<Head0, Head1, WaitLen, InPayload>;
St st = Head0{};

// overloaded 就是那个把多个 lambda 合成一个 visitor 的小工具
st = std::visit(overloaded{
    [&](Head0&,   uint8_t b) -> St { return b == 0xEB ? St{Head1{}} : St{Head0{}}; },
    [&](Head1&,   uint8_t b) -> St { return b == 0x90 ? St{WaitLen{}} : St{Head0{}}; },
    [&](WaitLen&, uint8_t b) -> St { return InPayload{{}, b}; },
    [&](InPayload &s, uint8_t b) -> St {
        s.buf.push_back(b);
        return s.buf.size() == s.need ? St{Head0{}} : st;   // 收满就回开头
    },
}, st, byte);

三个好处: 状态携带的数据不用外置(长度、缓冲直接挂在状态上, 不存在”这个字段属于谁”的问题); 漏写一个状态分支编译不过(visit 要穷举); 栈里名字虽然长得像小说, 但至少能看出是哪个状态.

代价是: 所有状态类型得放在一起定义, 加一个状态整块重编; 而且它只解决”状态 + 数据”, 不解决”状态多到需要分文件”.

6. 写法五: 用协程把流程写直#

帧解析最别扭的地方是: 语义上是顺序的(等帧头 → 等长度 → 等 N 字节 → 等校验), 代码却被写成散点的.

C++20 的协程能把这层别扭去掉:

task<Frame> recv_frame() {
    co_await expect(0xEB);            // 不是就丢掉继续等
    co_await expect(0x90);
    auto len = co_await read_u8();
    auto body = co_await read_n(len);  // 这里等着, 不阻塞线程
    auto crc = co_await read_u16();
    co_await expect_tail();
    co_return Frame{body, crc};
}

这东西读起来就是协议本身的顺序, 拿去给同事做 code review, 不用解释”我们在第几个状态”.

但它不是免费的: 编译器/标准库支持要确认(工业现场的交叉工具链经常落后); 协程的栈在调试器里不直观; 谁负责取消、超时怎么打断、co_await 期间对象被销毁怎么保证安全, 这些都得自己想清楚 —— 库通常只给你语法, 不给你语义.

我用它的场合很窄: 一次性握手、需要顺序等待的设备初始化、脚本式的多步操作. 字节级的高频解析我仍然用前几种, 因为那里每一纳秒都要算, 而协程的调度开销是真实存在的.

7. 回到业务: 状态机真正的麻烦在”急停”#

上面讲的都是解析层. 到了业务层, 事情换个形状:

  Idle     --使能-->  Ready
  Ready    --启动-->  Running
  Running  --暂停-->  Paused
  Paused   --继续-->  Running
  任一状态  --故障-->  Fault
  Fault    --复位-->  Ready

画出来很干净, 直到有人问你一句: 急停按下来, 从哪个状态走?

诚实的答案是”任何状态”. 于是朴素的 switch 写法里, 每个 case 都要加一句”如果急停就……”, 而这一句要保证: 停运动、断使能、清队列、记日志、置故障码、拒绝后续启动命令. 六个动作乘以状态数, 就是典型的”改一处漏一处”.

我的做法是把它挡在状态机外面: 急停不是业务事件, 是中断式的前置判断 —— 在 on_event 入口先看一句”急停是否有效”, 有效就走统一的停机流程, 根本不进状态分派. 六个动作只写一份, 唯一入口, 好测.

另外两条路也有人走: 层次状态机(父状态统一处理断使能, 子状态只管差异)是正经解法, 代价是要维护层次结构, 团队得有人看得懂状态图; 事件总线加订阅听着现代, 但谁先收到、动作有没有顺序、某模块漏订阅, 都是线上才暴露的问题.

我选前置判断, 理由很朴素: 急停要求”立刻、无条件、可预期”, 而状态机天生是”按当前状态决定行为”—— 用后者实现前者, 等于在最好的位置引入最坏的耦合.

8. 当安全条件互相咬合#

上面那个例子还是简单版. 真实设备上的条件会互相咬.

机械手在 A 模式下, 人站在里面也无所谓, 光栅不起作用; 但只要它一动, 光栅就必须立刻生效, 触发就抱闸加下电. 光栅和 1 号安全门的极性还是反的, 一个给 1 触发、一个给 0 触发. 故障之后要么按复位按钮, 要么切走模式再切回来. 启动还得按住按钮五秒才允许动作.

每一个条件单拿出来都必要, 合在一起就没人说得清: 这一刻到底允许不允许动.

问题不在条件多, 在于它们不是一类东西. 我后来一律拆成三层:

类别是什么例子违反后怎么办
不变式任何状态都必须成立的物理前提抱闸与使能一致、安全回路闭环立即停机, 不容商量
守卫只影响某一次跳转进 Running 要”无人在内或 A 模式”且光栅无遮挡拒绝这次跳转
模式改变”哪些前提适用”的维度自动 / A 模式 / 手动不跳状态, 换一张规则表

拆开之后, 状态 × 模式 就是一张表, 每格写清哪种前提有效、违反后做什么:

状态 · 模式自动A 模式(允许进入)手动
Idle / Ready光栅 + 安全门光栅无效, 安全门有效光栅无效, 只允许点动
Running任一触发即停机光栅只在运动时生效不允许进入
Paused / Fault全部有效全部有效全部有效

“光栅在 A 模式下、机器人静止时不起作用”在这张表里只是一格的内容, 不需要散在五个 if 里. 这就是前面那句: 模式是维度, 不是状态. 一旦把模式写成状态, 组合就变成乘积, 而没人能把乘积穷举完.

三件配套的事, 少一件都不行.

一是极性在采集层归一化. 安全门给 1 触发、光栅给 0 触发, 这种差异只能出现在 IO 采集那一层, 上层读到的都是统一的”安全事件已触发”. 业务代码里只要出现一次 if (!io), 半年后就没人记得为什么取反. 归一化本身也要能验: 上电自检验一遍每条安全输入的已知状态, 配错极性当场上报.

二是动作在前, 状态在后. 光栅触发后的抱闸下电由安全链直接做, 不能等状态机算完 —— 等它算完就已经晚了. 状态机事后只做一件事: 记一笔”安全停机已发生 / 时间戳 / 触发源”, 然后进 Fault. 顺序反过来的写法(先置状态, 等别的模块发现该停机)在现场就是几百毫秒的空窗.

三是恢复要重新确认, 不是恢复记忆. 这条我含糊过一阵: 切模式回来, 之前的抱闸信号要不要恢复? 我的答案是分两种用途:

  • 用于恢复执行: 一概不恢复. 停机等于放弃执行上下文, 恢复必须重走启动序列. 因为停机这段时间世界变了 —— 人可能还在里面, 工件可能被取走, 位置可能被推过.
  • 用于追溯: 全部保留. 停在哪个状态、谁触发的、当时各路信号什么值, 一条不少.

所以”切回模式就接着跑”我明确禁止; 要动, 就从头按一次启动.

最后那个”按住五秒”也不该是布尔条件, 它是个过渡态:

  Ready --按住启动--> PendingStart --满 5 s 且无安全事件--> Running
                        │
                        └── 松手 / 任何安全事件 --> Ready

没有这个中间态, 五秒钟就会被写成”记下按下时刻, 下个周期算差值”, 然后忘掉松手要重置 —— 见过松一下再按就秒过的实现, 就是这样来的.

还有一条最省事也最救命: 未定义即禁止. 状态乘模式那么多组合, 总会有没写进表里的格子. 那些格子一律按”禁止运动、回安全态”处理, 而不是”没命中就沿用上一次的允许值”. if 堆最危险的地方就在这: 它把未定义悄悄当成了放行.

顺一句责任边界: 上面讲的都是业务侧怎么理解、怎么响应这些信号. 光栅、安全门、抱闸这类停机的最终兜底应该在硬件安全回路里, 软件这层能承诺的只是”绝不误放行”.

9. 状态别和设备走散#

Ready → Running 这一跳, 在你这边是”改了个枚举”, 在设备那边是”伺服真的使能、抱闸真的松开”, 中间隔着几毫秒到几百毫秒.

发完指令立刻改状态, 就会出现: 界面显示”运行中”, 报警面板同时报”伺服未使能”. 更糟的是操作员在这个窗口里点了暂停 —— 命令到了设备, 设备说”我还没启动呢”.

处理方式和上面那个 PendingStart 一样: 加过渡态, 只用反馈推进.

  Ready --发启动--> Starting --反馈到位--> Running
                       └──超时/错误--> Fault

代价是状态数变多, 好处是状态机的状态从此等于设备的真实状态, 而不是”我以为的状态”. 这一点在自动化设备上比代码好看重要得多.

顺带一句: 超时定时器千万别挂在状态机外面. 我吃过一次 —— 上一个状态留下的超时回调, 在新状态里把数据清掉了, 现象是”偶发丢帧”. 正确做法是每次 enter 重置、exit 取消, 让定时器跟着状态活.

10. 选型#

一张表, 按我自己的使用习惯:

场合我的选择
状态 ≤ 5, 单人维护, 要现场排查switch
要和协议文档逐条对, 需要穷举测试转移表
每个状态有独立的资源和生命周期状态对象
状态带数据, 编译器支持 C++17variant + visit
顺序握手、多步初始化协程
有层次或多设备并行层次状态机, 或每设备一个实例 + 事件分发

补一句不一定中听的: 大多数”我们该用状态机框架了”的场景, 最后写的都是 switch, 而且够用. 真正需要换写法的时候, 你会先感到痛 —— 痛在”加一个状态要改五处”, 而不是痛在”代码不够优雅”.

最后#

状态机不是一个”高级写法”, 它就是把随时间变化的东西显式写出来. 判据只有一个: 你能不能把那张图画出来. 画得出来, 用哪种实现都能对; 画不出来, 用什么框架都会长成一团.

所以顺序永远是: 先画图, 再选写法. 反过来做的, 最后都在维护一个没人敢动的枚举.

从一帧串口数据说起:状态机的几种写法
https://alongnice.github.io/posts/serial-frame-to-fsm/
作者
Along Nice
发布于
2026-10-28
许可协议
CC BY-NC-SA 4.0