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任务调度器的状态流转:
画图建议:我习惯先画状态图,再写代码。状态图能帮你发现逻辑漏洞。比如上图中,如果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固件。
我个人习惯是:先画状态图,再填状态表,最后写代码。图是思路,表是契约,代码是执行。顺序别搞反了,否则容易返工。
嗯,状态机设计就聊到这里。记住一句话:好的状态机,看表就知道系统怎么跑;烂的状态机,看代码也猜不透它在干嘛。