3. 状态机设计原理:有限状态机(FSM)概念、状态、事件、转移、动作、状态表与状态图

做嵌入式开发这些年,我越来越觉得状态机是MCU固件的灵魂。你想想看,一个按键消抖、一个通信协议解析、一个任务调度器——底层逻辑全是状态机。说白了,有限状态机(FSM)就是让程序知道“我现在在哪,该干什么,下一步去哪”。

3.1 什么是有限状态机?

有限状态机,英文叫Finite State Machine,简称FSM。它描述的是一个系统在有限个状态之间,根据事件驱动进行转移的行为模型。

我习惯用一个比喻来理解:状态机就像一台自动售货机。你投币(事件),它从“待机”状态(当前状态)变成“已投币”状态(下一状态),同时给你亮灯(动作)。状态是有限的,事件是明确的,转移是确定的——这就是FSM的核心。

FSM五大要素:

  • 状态(State):系统在某一时刻所处的稳定情形
  • 事件(Event):触发状态转移的外部或内部信号
  • 转移(Transition):从一个状态到另一个状态的路径
  • 动作(Action):进入状态或发生转移时执行的操作
  • 初始状态(Initial State):系统复位后进入的第一个状态

3.2 状态、事件、转移、动作——逐个拆解

3.2.1 状态(State)

状态是FSM最基本的单元。每个状态代表系统的一个稳定阶段。在MCU里,状态通常用一个枚举类型来表示。

typedef enum {
    STATE_IDLE,       // 空闲状态
    STATE_INIT,       // 初始化状态
    STATE_RUNNING,    // 运行状态
    STATE_SLEEP,      // 休眠状态
    STATE_ERROR       // 错误状态
} system_state_t;

我在项目中遇到过一个问题:有人把状态定义成连续的整数0、1、2、3,结果调试时根本看不出当前状态代表什么。后来我强制团队用枚举,每个状态都有名字,调试信息直接打印枚举名,问题定位快了三倍。

3.2.2 事件(Event)

事件是推动状态机运转的动力。它可以是外部中断、定时器超时、消息队列投递、或者某个条件成立。

事件通常也定义成枚举,方便switch-case处理。

typedef enum {
    EVT_NONE = 0,
    EVT_BUTTON_PRESS,     // 按键按下
    EVT_TIMEOUT,          // 超时事件
    EVT_DATA_READY,       // 数据就绪
    EVT_ERROR_OCCURRED,   // 错误发生
    EVT_RESET             // 复位事件
} event_t;

我的经验:事件定义要尽量原子化。一个事件只表达一个信号,不要把多个条件揉成一个事件。否则状态转移逻辑会变得混乱,调试时根本分不清是哪个条件触发的。

3.2.3 转移(Transition)

转移描述了系统如何从一个状态切换到另一个状态。一个完整的转移包含:当前状态、触发事件、转移条件(可选)、下一状态。

我习惯用结构体来描述转移表:

typedef struct {
    state_t      current_state;
    event_t      trigger_event;
    bool         (*guard_condition)(void);  // 守卫条件,可为NULL
    state_t      next_state;
    void         (*action)(void);           // 转移时执行的动作
} transition_t;

嗯,这里要注意:守卫条件(guard condition)是很多人忽略的。有时候同一个事件在不同条件下要转移到不同状态,没有守卫条件你就得写一堆if-else,状态机就退化成面条代码了。

3.2.4 动作(Action)

动作是状态机执行的具体操作。动作可以发生在三个时机:

  • 进入动作(Entry Action):进入某个状态时执行一次
  • 退出动作(Exit Action):离开某个状态时执行一次
  • 转移动作(Transition Action):转移过程中执行

我曾经在一个电机控制项目里,把PWM配置写在转移动作里,结果每次状态切换都重新配置PWM,导致电机抖动。后来改成进入动作,只在进入状态时配置一次,问题就解决了。动作的时机选择,真的很重要。

3.3 状态表与状态图

3.3.1 状态表(State Table)

状态表是FSM的“说明书”。它用表格的形式,清晰列出每个状态在每种事件下的转移目标和动作。

当前状态 事件 守卫条件 下一状态 动作
STATE_IDLE EVT_BUTTON_PRESS 无 STATE_INIT 启动初始化流程
STATE_INIT EVT_DATA_READY 校验通过 STATE_RUNNING 启动主循环
STATE_INIT EVT_ERROR_OCCURRED 无 STATE_ERROR 记录错误码
STATE_RUNNING EVT_TIMEOUT 无 STATE_SLEEP 保存上下文,进入低功耗
STATE_SLEEP EVT_BUTTON_PRESS 无 STATE_RUNNING 恢复上下文
任何状态 EVT_RESET 无 STATE_IDLE 全局复位

状态表的好处是直观。你一眼就能看出有没有遗漏的转移。我每次写完状态机,第一件事就是对着状态表检查:每个状态对每个事件都有处理吗?没处理的要么是非法组合,要么是遗漏了。

3.3.2 状态图(State Diagram)

状态图是状态表的图形化表达。它用圆圈表示状态,箭头表示转移,箭头上的标签表示事件/动作。

下面是我用SVG画的一个典型状态图,展示了一个MCU任务调度器的状态流转:

MCU任务调度器状态图 STATE_IDLE STATE_INIT STATE_RUNNING STATE_SLEEP STATE_ERROR EVT_BUTTON_PRESS EVT_DATA_READY EVT_ERROR_OCCURRED EVT_TIMEOUT EVT_BUTTON_PRESS EVT_RESET EVT_NONE 图例说明 空闲状态 初始化状态 运行状态 错误状态

画图建议:我习惯先画状态图,再写代码。状态图能帮你发现逻辑漏洞。比如上图中,如果STATE_ERROR没有指向STATE_IDLE的转移,系统就会卡死在错误状态——这种bug在代码里很难看出来,但在图上一目了然。

3.4 FSM的两种实现方式

3.4.1 嵌套switch-case方式

这是最直接的方式,适合状态少于10个的简单场景。

void fsm_run(system_state_t *current_state, event_t event) {
    switch (*current_state) {
        case STATE_IDLE:
            if (event == EVT_BUTTON_PRESS) {
                *current_state = STATE_INIT;
                action_start_init();
            }
            break;

        case STATE_INIT:
            if (event == EVT_DATA_READY) {
                *current_state = STATE_RUNNING;
                action_start_running();
            } else if (event == EVT_ERROR_OCCURRED) {
                *current_state = STATE_ERROR;
                action_log_error();
            }
            break;

        // ... 其他状态处理
    }
}

注意:嵌套switch-case虽然简单,但状态一多代码就会膨胀。我曾经维护过一个项目,状态机有30多个状态,switch-case写了800多行,每次加状态都心惊胆战。后来我全部重构为查表法。

3.4.2 查表法(Table-Driven)

查表法把状态转移逻辑放在一个常量表里,运行时通过查表决定转移。代码量少,可维护性强。

// 状态转移表
const transition_t state_table[] = {
    {STATE_IDLE,    EVT_BUTTON_PRESS,    NULL,                STATE_INIT,    action_start_init},
    {STATE_INIT,    EVT_DATA_READY,      guard_check_pass,    STATE_RUNNING, action_start_running},
    {STATE_INIT,    EVT_ERROR_OCCURRED,  NULL,                STATE_ERROR,   action_log_error},
    {STATE_RUNNING, EVT_TIMEOUT,         NULL,                STATE_SLEEP,   action_enter_sleep},
    {STATE_SLEEP,   EVT_BUTTON_PRESS,    NULL,                STATE_RUNNING, action_wakeup},
    {ANY_STATE,     EVT_RESET,           NULL,                STATE_IDLE,    action_reset},
};

void fsm_run(system_state_t *current, event_t event) {
    for (int i = 0; i < TABLE_SIZE; i++) {
        const transition_t *t = &state_table[i];
        if ((t->current_state == *current || t->current_state == ANY_STATE) &&
            t->trigger_event == event) {
            // 检查守卫条件
            if (t->guard_condition == NULL || t->guard_condition()) {
                if (t->action) t->action();
                *current = t->next_state;
                return;
            }
        }
    }
    // 未找到匹配转移——非法事件
    action_illegal_event(*current, event);
}

查表法的好处是:加一个新状态或新事件,只需要在表里加一行,不用动核心逻辑。我现在的项目基本都用查表法,代码评审时大家看表就能理解整个状态机。

3.5 状态机设计避坑指南

我曾经踩过的坑:

  • 遗漏状态:只考虑了正常流程,忘了错误处理和复位。结果系统异常时直接跑飞。
  • 事件风暴:短时间内连续触发多个事件,状态机来不及处理。后来我加了事件队列,一个一个消化。
  • 状态爆炸:把参数组合也当成状态。比如“等待超时且计数大于5”应该用变量+守卫条件,而不是单独的状态。
  • 动作副作用:在动作里修改了状态变量,导致转移逻辑出错。动作只做输出,不碰状态变量。

3.6 小结

有限状态机不是什么高深理论,它就是一套让程序“有规矩”的方法。状态、事件、转移、动作——这四个概念搞清楚了,你就能写出清晰、可靠、易维护的MCU固件。

我个人习惯是:先画状态图,再填状态表,最后写代码。图是思路,表是契约,代码是执行。顺序别搞反了,否则容易返工。

嗯,状态机设计就聊到这里。记住一句话:好的状态机,看表就知道系统怎么跑;烂的状态机,看代码也猜不透它在干嘛。