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 小结
有限状态机不是什么玄学。它就是一套「当前状态 + 输入事件 → 下一状态 + 执行动作」的规则。你只要记住:
- 状态是有限的、互斥的
- 事件是驱动源
- 转移是路径
- 动作是结果
画好图,选对实现方式,注意边界情况。状态机就能成为你手里最趁手的工具。