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 核心知识体系

下面这张图,把分层状态机的核心逻辑串起来了。你看一眼就能明白:

分层状态机核心架构 事件输入 状态转换表 当前状态 + 事件 → 下一状态 状态机引擎 事件处理接口 on_entry / on_exit / on_event 业务逻辑处理 无效事件反馈 转换表:路由 处理接口:业务 两者解耦

16.7 避坑指南

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

  • 转换表不要太大:如果状态超过 30 个,建议拆成子状态机。否则查表效率会下降,而且表本身也难维护。
  • 事件编号要统一:我见过一个项目,不同模块用不同的事件编号,结果对接时乱成一团。建议在全局头文件里统一定义。
  • 注意空指针:状态处理函数可能为 NULL,调用前一定要判空。别问我怎么知道的。
  • 状态机初始化:上电后一定要有一个明确初始状态,并且调用一次 on_entry。否则状态机可能处于“未初始化”的尴尬境地。

我的小技巧:在调试阶段,可以在状态机引擎里加一个日志输出,每次状态转换都打印出来。这样出了问题,看日志就能定位。等产品稳定了,再把这个日志关掉。

嗯,分层状态机就聊这么多。说白了,就是把复杂问题拆成简单模块,每个模块各司其职。你下次写状态机的时候,不妨试试这种思路。代码会清爽很多,维护起来也省心。


公众号:蓝海资料掘金营,微信deep3321