16. 状态机接口:分层状态机设计,状态转换表与事件处理接口的分离
状态机这东西,做嵌入式的基本都绕不开。我早年刚入行时,写状态机就是一堆 switch-case 堆到底,代码长得像裹脚布。后来项目越来越复杂,一个状态机里嵌套了十几个状态,每次加需求都心惊胆战。说白了,那种写法耦合太紧,改一处可能崩一片。
今天聊的分层状态机设计,核心思路就一句话:把“怎么跳转”和“跳转后干什么”分开。你想想看,状态转换表管的是“从哪到哪”,事件处理接口管的是“到了之后做什么”。这两件事混在一起,代码就成了一锅粥。
16.1 为什么需要分层?
我见过不少同事,一个状态机函数写了上千行。里面既有状态跳转逻辑,又有业务处理代码,还夹杂着硬件操作。这种代码,别说维护了,读一遍都费劲。
分层的好处很明显:
- 转换逻辑集中管理:所有状态跳转规则放在一张表里,一目了然
- 业务逻辑独立:每个状态的处理函数只管自己的事,不关心怎么进来的
- 可测试性提升:可以单独测试转换表,也可以单独测试状态处理函数
- 扩展方便:加一个新状态,只需要在表里加一行,再写一个处理函数
核心思想:状态转换表是“路由”,事件处理接口是“业务”。路由和业务解耦,才是分层状态机的精髓。
16.2 状态转换表的设计
状态转换表,说白了就是一个二维数组。行是当前状态,列是触发事件,单元格里存的是下一个状态。我习惯用结构体来定义:
/* 状态转换表条目 */
typedef struct {
uint8_t current_state;
uint8_t event;
uint8_t next_state;
void (*action)(void *param); /* 转换时执行的动作 */
} state_transition_t;
为什么不用二维数组?因为实际项目中,很多状态-事件组合是无效的。用结构体数组,只填有效条目,省内存也清晰。
举个例子,一个简单的门禁系统:
/* 状态定义 */
#define STATE_IDLE 0
#define STATE_CARD_WAIT 1
#define STATE_OPENING 2
#define STATE_OPENED 3
/* 事件定义 */
#define EVT_CARD_INSERT 0
#define EVT_CARD_VALID 1
#define EVT_CARD_INVALID 2
#define EVT_DOOR_CLOSED 3
/* 转换表 */
state_transition_t g_trans_table[] = {
{STATE_IDLE, EVT_CARD_INSERT, STATE_CARD_WAIT, NULL},
{STATE_CARD_WAIT, EVT_CARD_VALID, STATE_OPENING, on_door_open},
{STATE_CARD_WAIT, EVT_CARD_INVALID, STATE_IDLE, on_access_denied},
{STATE_OPENING, EVT_DOOR_CLOSED, STATE_OPENED, NULL},
{STATE_OPENED, EVT_DOOR_CLOSED, STATE_IDLE, on_door_close},
/* 终止标记 */
{0xFF, 0xFF, 0xFF, NULL}
};
我的习惯:在表末尾加一个终止标记,这样遍历时不用额外传长度参数。代码更简洁,也方便动态扩展。
16.3 事件处理接口的分离
状态转换表只管“跳到哪里”,那“到了之后干什么”呢?这就交给事件处理接口。我通常这样设计:
/* 状态处理函数指针类型 */
typedef void (*state_handler_t)(void *param);
/* 状态处理表 */
typedef struct {
uint8_t state_id;
state_handler_t on_entry; /* 进入状态时调用 */
state_handler_t on_exit; /* 离开状态时调用 */
state_handler_t on_event; /* 状态内处理事件 */
} state_handler_table_t;
你看,每个状态有三个钩子:进入时、离开时、处理事件时。这样就把“状态的行为”和“状态的转换”彻底分开了。
我曾经在一个项目中,把状态处理函数写成了独立的 .c 文件。每个状态一个文件,编译时按需加载。那感觉,清爽得很。
16.4 状态机引擎的实现
有了表和接口,引擎就简单了。核心逻辑就是查表+回调:
void state_machine_run(fsm_t *fsm, uint8_t event, void *param) {
state_transition_t *trans = g_trans_table;
uint8_t found = 0;
/* 查转换表 */
while (trans->current_state != 0xFF) {
if (trans->current_state == fsm->current_state &&
trans->event == event) {
found = 1;
break;
}
trans++;
}
if (!found) {
/* 无效转换,可以记录日志或触发错误处理 */
return;
}
/* 离开当前状态 */
if (fsm->handlers[fsm->current_state].on_exit) {
fsm->handlers[fsm->current_state].on_exit(param);
}
/* 执行转换动作 */
if (trans->action) {
trans->action(param);
}
/* 更新状态 */
fsm->current_state = trans->next_state;
/* 进入新状态 */
if (fsm->handlers[fsm->current_state].on_entry) {
fsm->handlers[fsm->current_state].on_entry(param);
}
}
注意:on_exit 和 on_entry 的顺序不能搞反。先退出旧状态,再进入新状态。我曾经在调试一个通信协议栈时,因为顺序写反了,导致状态机在边界条件下死锁。查了整整两天。
16.5 分层状态机的优势
用这种结构,你会发现几个明显的好处:
- 代码量减少:状态转换逻辑集中在一张表里,不用到处写 if-else
- 可读性提升:新同事接手,看表就能知道整个状态机的全貌
- 调试方便:可以在查表处加断点,看当前状态和事件,一目了然
- 复用性强:同样的状态处理函数,可以用于不同的转换表
我记得有一次,产品经理临时要加一个新功能,需要在某个状态下增加一个超时处理。我只需要在转换表里加一行,再写一个超时处理函数。前后不到半小时。要是以前那种 switch-case 写法,估计得改半天,还得担心改出 bug。
16.6 核心知识体系
下面这张图,把分层状态机的核心逻辑串起来了。你看一眼就能明白:
16.7 避坑指南
最后分享几个我踩过的坑:
- 转换表不要太大:如果状态超过 30 个,建议拆成子状态机。否则查表效率会下降,而且表本身也难维护。
- 事件编号要统一:我见过一个项目,不同模块用不同的事件编号,结果对接时乱成一团。建议在全局头文件里统一定义。
- 注意空指针:状态处理函数可能为 NULL,调用前一定要判空。别问我怎么知道的。
- 状态机初始化:上电后一定要有一个明确初始状态,并且调用一次 on_entry。否则状态机可能处于“未初始化”的尴尬境地。
我的小技巧:在调试阶段,可以在状态机引擎里加一个日志输出,每次状态转换都打印出来。这样出了问题,看日志就能定位。等产品稳定了,再把这个日志关掉。
嗯,分层状态机就聊这么多。说白了,就是把复杂问题拆成简单模块,每个模块各司其职。你下次写状态机的时候,不妨试试这种思路。代码会清爽很多,维护起来也省心。
公众号:蓝海资料掘金营,微信deep3321