先看结论
平铺 FSM 的复杂度长在转移连线上,HSM 的复杂度长在状态表里。前者随状态数平方膨胀,后者是线性的、可枚举的、能放进 Flash 的。
| 维度 | 经典平铺 FSM | 层次状态机 HSM |
|---|---|---|
| 拓扑模型 | 单层平面网 | 父指针构成的倒挂树 |
| 状态爆炸 | 差,全局行为要在每个状态各配一遍 | 优,父状态统一收敛 |
| 代码复用 | 差,靠复制粘贴 | 优,子状态继承父状态行为 |
| 事件分发 | 单点命中,未配置即丢弃 | 自底向上冒泡,逐级兜底 |
| 切换开销 | 每次都是完整退旧进新 | 同父子状态切换不触碰父状态 |
| 适用规模 | 状态少于 5 个的简单控制 | 电源模式、多级 GUI、协议栈 |
一句话:HSM 是状态机领域的面向对象。父状态是基类,子状态是派生类,状态迁移是严格遵循最小公共祖先(LCA)的析构与构造序列。
平铺 FSM 的三个死结
- 状态爆炸。 N 个互斥状态再叠加全局行为(急停、低电关机、通信断开),每个状态都要配一条指向目标的转移,连线量按 O(N²) 增长。
- 重复代码。 大量子状态共享同一段上下文处理逻辑,平铺结构只能机械复制,违反 DRY。
- 没有层级抽象。 无法表达"状态中的状态",比如"运行中"包含"空闲 / 加速 / 匀速",复合业务无处安放。
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_Name | Parent_Name | Entry / Exit / 事件表 |
|---|---|---|---|
| Row 0 | STATE_WORKING | ROOT | 全部属于 WORKING 自己 |
| Row 1 | STATE_NORMAL | STATE_WORKING | 全部属于 NORMAL 自己 |
| Row 2 | STATE_BOOST | STATE_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,引擎执行经典的最小公共祖先算法:
- 退出起点:
ChildA->Exit_function()。 - 逐级回退:沿父链向上执行
ParentA->Exit_function()。 - 祖先分界:到达 LCA 即停止,LCA 自身不触发 Exit。
- 跨入新支:从目标分支顶层开始,
ParentB->Entry_function()。 - 终点就绪:下钻执行
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 结构体,剩下的都能靠宏写清楚。