3、状态机基础:有限状态机(FSM)概念、状态、事件、转移

说实话,很多嵌入式工程师写了好几年代码,天天跟状态机打交道,却从来没认真想过它到底是什么。我见过太多人把状态机当成一个「高大上」的概念,其实说白了,它就是一套规则——告诉系统在什么情况下该做什么事。

你想想看,一个按键按下,LED 该亮还是该灭?一个串口收到数据,系统是进入接收模式还是解析模式?这些决策背后,都是状态机在起作用。今天我们就把它彻底讲透。

3.1 什么是有限状态机?

有限状态机,英文叫 Finite State Machine,简称 FSM。核心就三个词:有限、状态、机。

  • 有限:状态的数量是确定的,不会无限多。
  • 状态:系统在某个时刻所处的特定情形。
  • 机:一套自动运转的机制。

我习惯用一个比喻来解释:状态机就像一部电梯。电梯只有几种状态:静止、上行、下行、开门、关门。它不可能同时处于「上行」和「开门」——这就是状态互斥。你按了 5 楼,电梯从 1 楼开始上行,到了 5 楼停下来开门,然后关门等待下一个指令。整个过程就是状态在事件驱动下不断转移。

核心要点:FSM 在任何时刻只能处于一个状态。这是它的根本约束,也是它简单可靠的原因。

3.2 状态机的四大要素

一个完整的 FSM,由四个东西组成。我当年带团队时,要求每个新人都必须能脱口而出这四点:

要素 说明 电梯例子
状态(State) 系统在某一时刻的稳定情形 静止、上行、下行、开门、关门
事件(Event) 触发状态转移的外部或内部信号 按楼层按钮、到达目标层、超时
转移(Transition) 从一个状态到另一个状态的路径 静止→上行(按了 5 楼)
动作(Action) 转移发生时执行的操作 启动电机、打开门、开始计时

这里有个容易混淆的点:动作到底算不算要素?有些教材把它归在转移里,我个人的习惯是单独列出来。为什么?因为在实际编码中,动作往往是 bug 最多的地方。我曾经在一个项目中,状态转移写对了,但动作里忘了关中断,结果系统跑飞了整整两天才找到原因。

3.3 状态与事件:谁驱动谁?

很多人会问:状态和事件,到底谁先谁后?

答案是:事件驱动状态转移,状态决定事件响应。

举个例子。同样是「收到数据」这个事件:

  • 如果当前状态是空闲,系统会进入接收状态,开始缓存数据。
  • 如果当前状态是正在处理,系统可能会丢弃数据,或者把它放入队列。

看到了吗?同一个事件,在不同状态下行为完全不同。这就是状态机的精髓——把复杂的行为逻辑拆解成「状态 × 事件」的二维表格。

我的经验:设计状态机时,先列状态,再列事件,最后填转移。顺序别搞反。先想「系统有哪些稳定状态」,比先想「有哪些事件」要容易得多。

3.4 状态转移图:一图胜千言

写代码之前,一定要画状态转移图。我见过太多人直接上手写 switch-case,写到一半发现状态漏了,或者转移条件冲突了。图一画,所有问题都暴露了。

下面是一个典型的按键消抖 FSM 示意图。它有三个状态:等待按下、抖动中、确认按下。事件是按键电平变化和定时器超时。

按键消抖有限状态机 等待按下 抖动中 确认按下 按键按下 定时器超时 电平抖动(复位定时器) 按键释放 说明:按键按下后进入抖动期,定时器超时且电平稳定才确认按下; 抖动期内任何电平变化都复位定时器,继续等待稳定。

这张图里,三个状态、三个事件、四条转移路径,一目了然。你照着这个写代码,基本不会出错。

3.5 状态机的两种实现方式

实际编码中,FSM 的实现方式主要有两种。我两种都用过,各有优劣。

3.5.1 switch-case 方式

这是最直观的方式。适合状态少、逻辑简单的场景。

typedef enum {
    STATE_IDLE,
    STATE_DEBOUNCE,
    STATE_PRESSED
} state_t;

state_t current_state = STATE_IDLE;

void fsm_run(event_t event) {
    switch (current_state) {
        case STATE_IDLE:
            if (event == EVENT_KEY_DOWN) {
                start_timer(10);  // 启动 10ms 定时器
                current_state = STATE_DEBOUNCE;
            }
            break;
        case STATE_DEBOUNCE:
            if (event == EVENT_TIMEOUT) {
                if (key_stable()) {
                    current_state = STATE_PRESSED;
                    notify_key_press();
                }
            } else if (event == EVENT_KEY_UP) {
                reset_timer();
                current_state = STATE_IDLE;
            }
            break;
        case STATE_PRESSED:
            if (event == EVENT_KEY_UP) {
                current_state = STATE_IDLE;
            }
            break;
        default:
            break;
    }
}

注意:switch-case 方式最怕状态多了以后代码膨胀。我曾经维护过一个项目,状态有 30 多个,一个 switch 函数写了 800 行。后来我果断重构了。

3.5.2 状态表方式

状态多了怎么办?用表驱动。把状态转移逻辑放在一个二维表格里,代码变成查表执行。

typedef struct {
    state_t  next_state;
    void     (*action)(void);
} transition_t;

// 状态表:[当前状态][事件] = { 下一状态, 动作函数指针 }
transition_t state_table[STATE_MAX][EVENT_MAX] = {
    // STATE_IDLE
    { { STATE_DEBOUNCE, start_timer_10ms },  // EVENT_KEY_DOWN
      { STATE_IDLE,     NULL } },            // EVENT_KEY_UP
    // STATE_DEBOUNCE
    { { STATE_PRESSED,  notify_key_press },  // EVENT_TIMEOUT
      { STATE_IDLE,     reset_timer } },     // EVENT_KEY_UP
    // STATE_PRESSED
    { { STATE_PRESSED,  NULL },              // EVENT_TIMEOUT
      { STATE_IDLE,     NULL } },            // EVENT_KEY_UP
};

void fsm_run(event_t event) {
    transition_t *t = &state_table[current_state][event];
    if (t->action) {
        t->action();
    }
    current_state = t->next_state;
}

这种方式的好处是:新增状态或事件时,不用改逻辑代码,只改表格。我后来做通信协议栈,状态有 40 多个,事件有 20 多种,全靠状态表撑着,代码量反而比 switch-case 少了 60%。

3.6 避坑指南:我踩过的三个坑

讲完理论,说点实在的。这些年我用状态机踩过不少坑,挑三个最典型的分享给你。

坑一:状态遗漏

我曾经设计一个电源管理模块,画了状态图,写了代码,测试也过了。结果量产之后,客户反馈偶尔会死机。查了三天,发现少了一个状态——「电池电量极低」时的临界状态。系统在低电量时既不是正常模式,也不是关机模式,而是处于一个「半死不活」的中间态,代码里没处理,直接跑飞了。

教训:画状态图时,把边界情况都列出来。电量 0%、缓冲区满、超时未响应……这些极端情况往往藏着坑。

坑二:事件冲突

有一次做多按键扫描,两个按键同时按下,触发了两个事件。我的状态机一次只处理一个事件,结果另一个事件被丢了,系统卡在中间状态出不来。

解决办法:引入事件队列。所有事件先入队,状态机逐个处理。这样就不会丢事件了。

坑三:动作执行时机

动作到底是在进入状态时执行,还是在转移过程中执行?这个问题不搞清楚,很容易出 bug。我个人的习惯是:动作绑定在转移上,而不是状态上。也就是说,每次转移发生时执行一次动作,进入新状态后不再重复执行。这样逻辑更清晰,也更容易调试。

3.7 小结

有限状态机不是什么玄学。它就是一套「当前状态 + 输入事件 → 下一状态 + 执行动作」的规则。你只要记住:

  • 状态是有限的、互斥的
  • 事件是驱动源
  • 转移是路径
  • 动作是结果

画好图,选对实现方式,注意边界情况。状态机就能成为你手里最趁手的工具。