1. 状态机基础概念

各位同学好,我是老周。做嵌入式这行十几年了,今天咱们聊聊状态机。说实话,状态机这东西,你天天都在用,只是可能没意识到。

什么叫有限状态机?说白了,就是一个系统在任意时刻只能处于有限个状态中的一个。它根据输入事件,从一个状态切换到另一个状态。就这么简单。

我记得刚入行那会儿,带我的师傅让我写一个按键消抖程序。我上来就写 if-else 嵌套,写了七八层,自己都看晕了。师傅看了一眼说:“小子,画个状态图再写。”从那以后,我养成了一个习惯——先画状态图,再写代码。

1.1 状态机的三要素

状态机有三个核心要素,缺一不可。我习惯用一个公式来记:

FSM = 状态 + 事件 + 转移

咱们一个一个说。

状态(State)

状态就是系统在某个时刻的稳定情形。比如一个LED灯,要么亮、要么灭。这就是两个状态。注意,状态是互斥且完备的——系统在任何时刻只能处于一个状态,而且所有可能的状态必须被覆盖到。

事件(Event)

事件是触发状态变化的外部输入或内部条件。比如按键按下、定时器超时、数据帧到达。事件是状态机的“燃料”,没有事件,状态机就停在那不动。

转移(Transition)

转移定义了在什么事件下,从哪个状态到哪个状态。转移可以附带动作,比如进入新状态时点亮LED、退出旧状态时关闭定时器。

我画了个图,把这三要素的关系展示出来:

有限状态机(FSM)三要素关系图 状态 A (空闲/等待) 状态 B (忙碌/处理) 事件 X (按键按下) 事件 Y (定时器超时) 核心要点: 1. 系统在任意时刻只能处于一个状态(互斥性) 2. 所有可能的状态必须被定义(完备性) 3. 转移由事件触发,可附带动作

1.2 状态机的应用场景

状态机不是理论玩具,它在实际工程中无处不在。我挑三个最常见的场景说说。

通信协议

这是状态机用得最狠的地方。想想看,一个UART接收数据,要经历空闲 → 检测起始位 → 接收数据位 → 校验位 → 停止位。每一步都是一个状态,每个位到达都是一个事件。

我曾经调试过一个I²C从机设备,总线老是卡死。后来把状态图一画,发现少了一个“总线错误恢复”状态。加上之后,问题就解决了。嗯,状态图就是通信协议的骨架。

协议层 典型状态 典型事件
UART 接收 空闲、起始位、数据位、校验位、停止位 RX 引脚电平跳变、位时钟
I²C 主控 空闲、起始、发送地址、ACK、发送数据、停止 SCL 时钟、SDA 数据、ACK/NACK
TCP 连接 CLOSED、SYN_SENT、ESTABLISHED、FIN_WAIT SYN 到达、ACK 到达、FIN 到达

UI 控制

你手机上的每个界面切换,背后都是一个状态机。比如一个设置菜单:

  • 主菜单 → 按“设置”键 → 设置菜单
  • 设置菜单 → 按“返回”键 → 主菜单
  • 设置菜单 → 选择“亮度” → 亮度调节

我做过一个嵌入式设备的UI,一开始用全局变量记录当前界面,结果代码乱成一锅粥。后来改成状态机实现,每个界面一个状态,按键事件驱动转移。代码量少了三分之一,bug也少了很多。

我的经验:UI状态机里,“返回”事件一定要单独处理。很多新手把返回当成普通按键,结果状态转移乱套。我习惯在状态机里维护一个“历史栈”,返回就是弹出栈顶。

游戏逻辑

游戏角色有待机、行走、奔跑、攻击、受伤、死亡等状态。每个状态能做什么、不能做什么,都由状态机控制。

举个例子,角色在“攻击”状态下,不能直接切换到“行走”——你得等攻击动画播完。这就是状态机的转移约束。

注意:游戏状态机最容易踩的坑是状态穿透——一个事件导致连续多次状态转移,中间状态被跳过。我曾经在做一个格斗游戏时,角色被击中后应该进入“硬直”状态,结果因为事件处理顺序不对,直接跳到了“倒地”状态。画面看起来就是角色还没挨打就躺下了。

1.3 状态机的两种实现方式

实际编码中,状态机主要有两种写法。我两种都用过,各有优劣。

实现方式 核心思路 优点 缺点
switch-case 式 用 switch 分支处理每个状态 简单直观,适合状态少的情况 状态多了代码膨胀,难以维护
状态表式 用二维数组存储状态转移表 结构清晰,易于扩展和调试 需要额外设计表结构

我个人习惯:状态少于5个用switch-case,超过5个用状态表。下面给个简单的switch-case示例:

// 按键消抖状态机 - switch-case 实现
typedef enum {
    STATE_IDLE,
    STATE_DEBOUNCE,
    STATE_PRESSED
} button_state_t;

button_state_t state = STATE_IDLE;

void button_fsm(button_event_t event) {
    switch (state) {
        case STATE_IDLE:
            if (event == BUTTON_DOWN) {
                state = STATE_DEBOUNCE;
                start_timer(10);  // 启动10ms定时器
            }
            break;

        case STATE_DEBOUNCE:
            if (event == TIMEOUT) {
                if (read_button() == PRESSED) {
                    state = STATE_PRESSED;
                    notify_app(BUTTON_PRESSED);
                } else {
                    state = STATE_IDLE;  // 抖动,回到空闲
                }
            }
            break;

        case STATE_PRESSED:
            if (event == BUTTON_UP) {
                state = STATE_IDLE;
                notify_app(BUTTON_RELEASED);
            }
            break;
    }
}

你看,每个状态只处理自己关心的事件。不关心的事件直接忽略——这就是状态机的优雅之处。

1.4 避坑指南

最后,分享几个我踩过的坑:

  • 遗漏状态:画状态图时,一定要问自己“所有可能的情况都覆盖了吗?”比如通信异常、超时、复位。
  • 状态爆炸:状态太多时,考虑用嵌套状态机或层次状态机。把大状态拆成子状态机。
  • 事件丢失:事件处理要原子化。我曾经在中断里修改状态机状态,主循环里也在读,结果状态跳飞了。后来加了互斥锁才解决。

一句话总结:状态机不是银弹,但它能让你的逻辑可预测、可调试、可维护。画好图、定好状态、管好事件,剩下的就是水到渠成。