1. 状态机基础概念
各位同学好,我是老周。做嵌入式这行十几年了,今天咱们聊聊状态机。说实话,状态机这东西,你天天都在用,只是可能没意识到。
什么叫有限状态机?说白了,就是一个系统在任意时刻只能处于有限个状态中的一个。它根据输入事件,从一个状态切换到另一个状态。就这么简单。
我记得刚入行那会儿,带我的师傅让我写一个按键消抖程序。我上来就写 if-else 嵌套,写了七八层,自己都看晕了。师傅看了一眼说:“小子,画个状态图再写。”从那以后,我养成了一个习惯——先画状态图,再写代码。
1.1 状态机的三要素
状态机有三个核心要素,缺一不可。我习惯用一个公式来记:
FSM = 状态 + 事件 + 转移
咱们一个一个说。
状态(State)
状态就是系统在某个时刻的稳定情形。比如一个LED灯,要么亮、要么灭。这就是两个状态。注意,状态是互斥且完备的——系统在任何时刻只能处于一个状态,而且所有可能的状态必须被覆盖到。
事件(Event)
事件是触发状态变化的外部输入或内部条件。比如按键按下、定时器超时、数据帧到达。事件是状态机的“燃料”,没有事件,状态机就停在那不动。
转移(Transition)
转移定义了在什么事件下,从哪个状态到哪个状态。转移可以附带动作,比如进入新状态时点亮LED、退出旧状态时关闭定时器。
我画了个图,把这三要素的关系展示出来:
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 避坑指南
最后,分享几个我踩过的坑:
- 遗漏状态:画状态图时,一定要问自己“所有可能的情况都覆盖了吗?”比如通信异常、超时、复位。
- 状态爆炸:状态太多时,考虑用嵌套状态机或层次状态机。把大状态拆成子状态机。
- 事件丢失:事件处理要原子化。我曾经在中断里修改状态机状态,主循环里也在读,结果状态跳飞了。后来加了互斥锁才解决。
一句话总结:状态机不是银弹,但它能让你的逻辑可预测、可调试、可维护。画好图、定好状态、管好事件,剩下的就是水到渠成。