3. 状态机基础:有限状态机概念、状态/事件/转移三要素、状态图绘制方法

好,咱们今天聊聊状态机。说实话,我做了十几年嵌入式,最怕的就是那种「if-else 堆成山」的代码。你想想看,一个按键处理,按一下是启动,长按是设置,双击是切换模式……全写在中断里?迟早要出事的。

状态机,就是来解决这个问题的。它不是什么高深理论,说白了就是一种思考方式——把系统行为拆成「状态」和「状态之间的跳转」。我当年第一次用状态机重构一个红外遥控解码程序,代码量直接砍掉一半,bug 也少了很多。嗯,从那以后我就爱上了这种写法。

3.1 有限状态机到底是什么?

有限状态机,英文叫 Finite State Machine,简称 FSM。核心思想就一句话:系统在任何时刻,只能处于有限个状态中的一个。每个状态都定义了系统在该时刻的行为,而状态的切换由事件驱动。

举个例子,一个简单的台灯:

  • 状态:关、弱光、强光
  • 事件:按键按下
  • 转移:关→弱光→强光→关

你看,就这么简单。三个状态,一个事件,一个循环。这就是一个完整的有限状态机。

核心要点:有限状态机的「有限」二字很重要。状态数量必须是可枚举的、有限的。你不能说「状态是温度值」,因为温度是连续的,那叫无限状态,没法用 FSM 处理。

我在项目中遇到过一位同事,非要把 ADC 采样值直接当状态用,结果状态图画出来有 4096 个状态……我看了半天,跟他说:「兄弟,你这不叫状态机,这叫查表法。」后来我们改成用阈值划分成「低温、正常、高温」三个状态,代码瞬间清爽了。

3.2 三要素:状态、事件、转移

任何一个有限状态机,都离不开这三个东西。我习惯把它们叫做「状态机三剑客」。

3.2.1 状态(State)

状态是系统在某个时刻的「快照」。它决定了系统接下来能做什么、不能做什么。比如:

  • 空闲态:等待用户操作
  • 测量态:正在采集数据
  • 显示态:刷新屏幕
  • 错误态:传感器异常

我个人习惯给每个状态一个枚举值,清晰明了:

typedef enum {
    STATE_IDLE,
    STATE_MEASURING,
    STATE_DISPLAY,
    STATE_ERROR,
    STATE_COUNT   // 用于边界检查
} system_state_t;

注意最后那个 STATE_COUNT,这是我踩坑踩出来的习惯。有了它,你可以在代码里加断言,防止数组越界。我曾经在一个项目里忘了加这个,结果状态表越界访问,系统跑着跑着就飞了……查了两天才找到原因。

3.2.2 事件(Event)

事件是触发状态转移的「导火索」。它可以是外部输入(按键、串口数据)、内部定时(超时、计数到达)、或者系统条件变化(缓冲区满、传感器就绪)。

事件的定义也要枚举化:

typedef enum {
    EVENT_NONE,
    EVENT_BUTTON_PRESS,
    EVENT_TIMEOUT,
    EVENT_DATA_READY,
    EVENT_ERROR_OCCURRED
} event_t;

我的经验:事件要尽量「原子化」。一个事件只表达一个意思,不要搞复合事件。比如「按键按下并超时」这种,应该拆成两个独立事件,由状态机内部逻辑组合处理。否则你的状态图会乱成一锅粥。

3.2.3 转移(Transition)

转移就是「当前状态 + 事件 → 下一个状态」的映射关系。它通常包含三个信息:

  • 源状态(从哪里来)
  • 触发事件(为什么跳)
  • 目标状态(到哪里去)

有时候还会带上「动作(Action)」,也就是转移发生时执行的操作。比如退出状态时关灯、进入状态时初始化硬件。

我一般用结构体来定义转移表:

typedef struct {
    state_t     src_state;
    event_t     trigger_event;
    state_t     dst_state;
    void        (*action)(void);  // 转移时执行的动作
} transition_t;

然后整个状态机就变成了一张表:

transition_t g_trans_table[] = {
    {STATE_IDLE,      EVENT_BUTTON_PRESS,   STATE_MEASURING,  start_measurement},
    {STATE_MEASURING, EVENT_DATA_READY,     STATE_DISPLAY,    update_display},
    {STATE_MEASURING, EVENT_TIMEOUT,        STATE_ERROR,      handle_timeout},
    {STATE_DISPLAY,   EVENT_BUTTON_PRESS,   STATE_IDLE,       clear_display},
    // ...
};

这种表驱动的方式,我用了快十年了。好处是:状态转移逻辑一目了然,新增状态或事件只需要加一行表项,不用改 if-else 结构。你想想看,如果哪天产品经理说「再加一个校准状态」,你只需要加两行表项,而不是去翻那个几百行的 switch-case。

3.3 状态图怎么画?

画状态图,我推荐用 UML 状态图的标准符号。别嫌麻烦,一张好的状态图,比一千行注释都有用。

3.3.1 基本符号

符号 含义 说明
圆角矩形 状态 内部写状态名称,如「空闲」
实心圆 初始状态 系统启动时所在的点
带边框的圆 终止状态 嵌入式里很少用,系统一般不终止
箭头 转移 上面标注触发事件,可加动作

3.3.2 画图步骤

我画状态图有个固定流程,分享给你:

  1. 列出所有状态——先不管转移,把系统可能处于的所有「稳定状态」写出来
  2. 确定初始状态——上电后系统进入哪个状态?画一个实心圆指向它
  3. 找出所有事件——哪些事情能改变系统行为?列出来
  4. 连接转移——对每个状态,问自己:在这个状态下,发生每个事件,系统应该去哪里?
  5. 检查完整性——有没有遗漏的事件?有没有状态永远跳不出去?

避坑指南:我曾经画过一个万用表的状态图,画到一半发现有个状态没有任何事件能跳出去——说白了就是死锁了。后来查代码,发现是某个转移条件写错了。所以画完图之后,一定要检查每个状态是否都有「出口」。没有出口的状态,就是一颗定时炸弹。

3.3.3 一个简单的万用表状态图示例

假设我们做一个简易万用表,功能只有:电压测量、电阻测量、关机。状态图大概长这样:

        [初始]
           |
           v
      +---------+
      |  待机   |<---------+
      +---------+          |
           |               |
      [旋钮切换]       [旋钮切换]
           |               |
           v               |
      +---------+     +---------+
      | 电压测量 |     | 电阻测量 |
      +---------+     +---------+
           |               |
      [超时30秒]      [超时30秒]
           |               |
           +-------+-------+
                   |
                   v
              +---------+
              |  休眠   |
              +---------+
                   |
              [按键唤醒]
                   |
                   v
                [待机]

你看,这个图里:

  • 4 个状态:待机、电压测量、电阻测量、休眠
  • 3 个事件:旋钮切换、超时、按键唤醒
  • 每个状态都有明确的出口,没有死循环

画完图之后,我一般会对着图写代码。先写状态枚举,再写事件枚举,然后照着箭头一条一条填转移表。这样写出来的代码,基本不用调试就能跑。真的,我试过很多次。

3.4 小结

有限状态机不是什么玄学,它就是一套「分而治之」的思考工具。把复杂的行为拆成有限的状态,把混乱的逻辑变成清晰的转移表。你想想看,一个万用表可能有几十种功能、上百种操作组合,但如果你用状态机来设计,核心代码可能只需要几百行。

下一节,我会带你手写一个完整的万用表状态机框架。到时候你会发现,状态机写起来比想象中简单得多——前提是你把状态图画清楚了。

课后小练习:试着画一下你家电视遥控器的状态图。就三个键:电源、音量+、音量-。看看你能画出几个状态?提示:别忘了「静音」这个隐藏状态。