Firmware//15 分钟

层次状态机 HSM:状态爆炸、LCA 切换与 C 语言查表引擎

用一棵父指针树替掉 N² 条转移线:HSM 靠行为继承、LCA 局部切换与事件冒泡收敛复杂度。附 Entry/Exit 归属判定、查表引擎源码和三个必踩的坑。

HSM状态机嵌入式架构ANSI CLCA

先看结论

平铺 FSM 的复杂度长在转移连线上,HSM 的复杂度长在状态表里。前者随状态数平方膨胀,后者是线性的、可枚举的、能放进 Flash 的。

维度经典平铺 FSM层次状态机 HSM
拓扑模型单层平面网父指针构成的倒挂树
状态爆炸差,全局行为要在每个状态各配一遍优,父状态统一收敛
代码复用差,靠复制粘贴优,子状态继承父状态行为
事件分发单点命中,未配置即丢弃自底向上冒泡,逐级兜底
切换开销每次都是完整退旧进新同父子状态切换不触碰父状态
适用规模状态少于 5 个的简单控制电源模式、多级 GUI、协议栈

一句话:HSM 是状态机领域的面向对象。父状态是基类,子状态是派生类,状态迁移是严格遵循最小公共祖先(LCA)的析构与构造序列。

平铺 FSM 的三个死结

  1. 状态爆炸。 N 个互斥状态再叠加全局行为(急停、低电关机、通信断开),每个状态都要配一条指向目标的转移,连线量按 O(N²) 增长。
  2. 重复代码。 大量子状态共享同一段上下文处理逻辑,平铺结构只能机械复制,违反 DRY。
  3. 没有层级抽象。 无法表达"状态中的状态",比如"运行中"包含"空闲 / 加速 / 匀速",复合业务无处安放。

HSM 的四个关键特征

  • 树状层次。 状态不是孤立节点,而是通过 Parent_Name 串成一棵树。外层叫父状态(super-state),内层叫子状态(sub-state)。
  • 行为继承。 子状态自动获得父状态的事件响应能力,只需实现自己的特异性行为;未处理的事件沿父链自动冒泡。
  • LCA 局部切换。 同一父状态下的子状态互切时,父状态维系的资源与上下文保持常驻,既不退出也不重复初始化。
  • ROM / RAM 分离。 状态表与事件映射表全部 const 放 Flash,运行时只维护一个表示当前叶子的 RAM 变量。

结构体里的函数到底属于谁

最容易搞混的一点:Entry_function、Exit_function、Ev_func_Table 到底属于当前状态,还是属于它的父状态?

typedef struct t_HSM_STATE {
    HSM_NAME_TYPE       Node_Name;       /* 本状态名,全表唯一 */
    HSM_NAME_TYPE       Parent_Name;     /* 父状态名;顶层填 ROOT */
    HsmNormalFuntion    Entry_function;  /* 进入本状态时执行,可为 NULL */
    HsmNormalFuntion    Exit_function;   /* 退出本状态时执行,可为 NULL */
    HSM_EV_FUNC_TYPE   *Ev_func_Table;   /* 本状态的事件表首地址,可为 NULL */
    HSM_SIZE_TYPE       EvTableSize;     /* 本状态事件表项数 */
} HSM_STATE_TYPE;

结论只有三条:

  • 父状态和子状态是状态表数组里两条完全独立的记录,各持一个结构体实例。
  • 三个函数字段 100% 属于 Node_Name,绝不属于 Parent_Name。
  • Parent_Name 只是一个向上链接的 ID,是构建树形拓扑的唯一线索,不承载任何行为。

假设有 STATE_WORKING 父状态和两个子状态,只读表长这样:

配置行Node_NameParent_NameEntry / Exit / 事件表
Row 0STATE_WORKINGROOT全部属于 WORKING 自己
Row 1STATE_NORMALSTATE_WORKING全部属于 NORMAL 自己
Row 2STATE_BOOSTSTATE_WORKING全部属于 BOOST 自己

活跃状态是一条路径,不是一个点

运行时 pRAMData->CurHsmStateName 只存叶子节点,但逻辑上整条祖先链同时处于活跃态。当前叶子是 STATE_NORMAL 时,STATE_WORKING 也是活跃的。

这正是资源分层的依据:父状态的 Entry 打开公共硬件(总线供电、主定时器),子状态只管自己那份私有配置(某个外设的时钟分频)。

Entry / Exit 的四个触发时机

场景触发入口执行时序父状态是否参与
系统冷启动Hsm_Init自顶向下逐级 Entry 到默认叶子是,祖先链全部执行
同级兄弟切换Hsm_Handle_Event仅 OldChild->Exit() 然后 NewChild->Entry()否,完全无感知
跨父分支切换Hsm_Handle_Event自底向上 Exit 到 LCA,再自顶向下 Entry是,退出旧父、进入新父
内部事件响应Hsm_Handle_Event只跑事件表里的回调,不迁移否,无任何 Entry/Exit

冷启动:自顶向下

Hsm_Init 先校验状态表(非空、节点名不重复),再从默认状态沿 Parent_Name 回溯到 ROOT,然后自顶向下逐级调用 Entry。顶层父状态最先初始化,叶子最后初始化——保证底层执行时上层环境已经就绪。

同级切换:只动两个子状态

同级切换黄金法则:只执行旧子状态的 Exit、新子状态的 Entry。父状态的 Exit / Entry 一次都不许触发。

源码层面有三处截断保证这件事:

  • Hsm_Check_NewState_Is_OtherBranch 检测到两个状态的 Parent_Name 相同,把最高退出截断点设为当前子状态、最高进入截断点设为目标子状态。
  • Hsm_State_ExitFunc 的循环终止条件是 EndState == pTempState->Node_Name,执行完子状态 Exit 后立刻命中并返回,不会继续回溯。
  • Hsm_State_EntryFunc 的起点直接是目标子状态,执行完 Entry 即到达终点,父状态不会被重跑。

跨分支切换:LCA 法则

从 ParentA -> ChildA 切到 ParentB -> ChildB,引擎执行经典的最小公共祖先算法:

  1. 退出起点:ChildA->Exit_function()。
  2. 逐级回退:沿父链向上执行 ParentA->Exit_function()。
  3. 祖先分界:到达 LCA 即停止,LCA 自身不触发 Exit。
  4. 跨入新支:从目标分支顶层开始,ParentB->Entry_function()。
  5. 终点就绪:下钻执行 ChildB->Entry_function(),把 CurHsmStateName 改为 ChildB。

事件冒泡:从叶子往上找处理者

Hsm_Handle_Event 的检索闭环可以压缩成下面这段逻辑:

FindStateName = this->pRAMData->CurHsmStateName;   /* 从当前叶子开始 */
do {
    LoopFlag = false;
    pTempState = Hsm_Find_CurState(this, FindStateName);

    /* 1. 在本状态的 Ev_func_Table 中线性检索 */
    for (Index = 0; Index < pTempState->EvTableSize; Index++) {
        if (Event == pCurEv->Event) {
            pTempEvFunc = pCurEv;
            break;
        }
        pCurEv++;
    }

    /* 2. 命中:执行动作,返回目标迁移状态 */
    if (命中) {
        if (pTempEvFunc->EvFunction != NULL) pTempEvFunc->EvFunction();
        return pTempEvFunc->TransNewStateName;
    }

    /* 3. 未命中:沿父链向上冒泡 */
    if (pTempState->Parent_Name != HSM_STATE_NAME_ROOT) {
        FindStateName = pTempState->Parent_Name;
        LoopFlag = true;
    }
} while (LoopFlag);

return HSM_STATE_TRANS_NONE;   /* 冒到根仍未命中,静默丢弃 */

三条原则对应三种语义:

  • 子状态优先拦截(重写)。 事件最先投递给叶子;子状态表里配了就直接消费,父状态的同名规则被覆盖,等价于子类重写虚方法。
  • 未命中逐级上溯(继承)。 子状态没配就换父节点继续查,任意一级祖先配了就在那一级处理。
  • 根节点兜底丢弃(安全边界)。 冒到 ROOT 仍无匹配,视为无响应,静默丢弃,状态保持不变。

三个真实场景

  • 全局急停。 平铺版有 10 个工作模式就要配 10 遍;HSM 只在父状态 WORKING 挂一条迁移,10 个子状态无缝继承。
  • 统一按键响应。 车载 UI 无论下钻到第几级,Home / Back 都挂在父页面,子页面零代码。
  • 链路超时重连。 握手、发数据、收确认三个子状态不必各写超时逻辑,父通信状态直接截获并统一进入复位重连。

内存模型:只读表 + 4 字节 RAM

  • 零动态分配。 状态表 t_HSM_STATE 与事件表 t_HSM_EV_FUNC 全部 const,编译进 Flash,运行期只读,野指针改不动。
  • 极小 RAM。 可变状态收敛在 t_HSM_RAM 里,只有一个 CurHsmStateName(通常 32 位)。几十个状态的系统,运行期 RAM 消耗几乎为零。

构造宏:把拓扑写成声明

/* 1. 事件表:{事件, 回调, 目标状态} */
HSM_EV_TABLE_DEF Normal_EvTable[] = {
    { EV_ACCELERATE, On_SpeedUp_Action, STATE_BOOST },          /* 动作 + 迁移 */
    { EV_KEEP_ALIVE, On_Heartbeat_Tick, HSM_STATE_TRANS_NONE }, /* 只动作,不迁移 */
};

/* 2. 状态表:状态自己、父状态、Entry、Exit、事件表、表长 */
HSM_STATE_TABLE_DEF SystemStateTable[] = {
    HSM_STATE_CONSTRUCT(STATE_ROOT_WORK, HSM_STATE_NAME_ROOT, Root_Entry, Root_Exit, Root_EvTable, 2),
    HSM_STATE_CONSTRUCT(STATE_NORMAL,    STATE_ROOT_WORK,     Norm_Entry, Norm_Exit, Normal_EvTable, 2),
    HSM_STATE_CONSTRUCT(STATE_BOOST,     STATE_ROOT_WORK,     Bost_Entry, Bost_Exit, Boost_EvTable, 1),
};

/* 3. 实例化:自动分配私有 RAM 并绑定状态表 */
HSM_OBJ_CONSTRUCT(MyPowerHsm, STATE_NORMAL, SystemStateTable, 3);

三个会咬人的坑

表长与数组长度脱钩。 StateTableSize 和 EvTableSize 都是手填标量,增删表项忘了同步就会越界读或漏查。用 sizeof(table) / sizeof(table[0]) 自动推导,别手写数字。

在回调里重入事件接口。 Hsm_Handle_Event 非可重入,严禁在 Entry_function、Exit_function、EvFunction 内直接同步调用它。需要派发新事件就压进事件环形队列,由主循环轮询分派。

多实例共享全局变量。 Hsm_CauseSwitchPage_Event 记录最近一次切换事件,多个 HSM 实例会互相覆盖。要独立跟踪就把它搬进 HSM_RAM_TYPE。

结论

  • 收敛点不同。 平铺 FSM 把复杂度摊在转移连线上,HSM 把复杂度收进状态表的父子拓扑里。
  • 两个机制撑起复用。 行为继承解决"同样的逻辑写 N 遍",LCA 解决"切换时把不该动的环境也重建了一遍"。
  • 落地靠三条纪律。 表长自动推导、事件接口不重入、全局状态实例化到 RAM 结构体,剩下的都能靠宏写清楚。