7. 状态机嵌套:层次状态机概念、父状态与子状态、状态继承与复用
好,咱们今天聊一个稍微进阶一点的话题——状态机嵌套。说白了,就是状态里面还能再套状态。你可能会问:“有必要搞这么复杂吗?”嗯,我刚开始接触这个概念时也是这个反应。直到有一次我在做一个通信协议栈的项目,状态多到我自己都数不清,画出来的状态图跟蜘蛛网似的……那时候我才意识到,扁平状态机在某些场景下,真的不够用。
7.1 为什么需要层次状态机?
先说说扁平状态机的痛点。假设你正在设计一个智能家居系统,设备有“待机”、“运行”、“告警”三个大状态。每个大状态下,又有若干子状态。比如“运行”状态下,可能还分“加热”、“制冷”、“送风”等子状态。
如果用扁平状态机,你会写出类似这样的状态列表:
// 扁平状态机——状态爆炸
typedef enum {
STATE_IDLE,
STATE_HEATING,
STATE_COOLING,
STATE_FAN_ONLY,
STATE_ALARM_HIGH_TEMP,
STATE_ALARM_LOW_TEMP,
STATE_ALARM_SENSOR_FAIL,
// ... 还有更多
} State_t;
你看,光是告警状态就有好几种。而且每个告警子状态里,可能都要处理“退出告警”这个动作。如果每个状态都单独写一遍退出逻辑,代码冗余不说,还容易漏掉。
层次状态机(Hierarchical State Machine, HSM) 就是为了解决这个问题而生的。它允许一个状态包含多个子状态,子状态可以继承父状态的行为。说白了,就是让状态机有了“继承”的能力。
核心思想: 父状态定义通用行为,子状态定义特殊行为。子状态处理不了的事件,交给父状态处理。
7.2 父状态与子状态:谁管谁?
咱们用个具体的例子来说明。假设你正在写一个嵌入式设备的电源管理模块。设备有“正常模式”和“省电模式”两个大状态。每个大状态下,CPU 频率和屏幕亮度都不一样。
先画个图,看看层次关系:
在这个结构里,“正常模式”是父状态,“全速运行”和“待机”是它的子状态。父状态可以定义一些通用规则,比如“进入正常模式时,打开散热风扇”。子状态只需要关注自己的特殊行为,比如“全速运行”时 CPU 跑满,“待机”时降频。
我的经验: 父状态最适合放“进入/退出”时的公共操作。比如进入“正常模式”时打开风扇,退出时关闭风扇。子状态只管自己特有的逻辑。这样改一个地方,所有子状态都受益。
7.3 状态继承:子状态“继承”父状态的行为
继承这个词,搞过面向对象编程的人应该不陌生。在层次状态机里,子状态会继承父状态的事件处理逻辑。什么意思呢?
举个例子。假设父状态“正常模式”定义了一个事件处理:
- 收到
EVENT_BATTERY_LOW→ 切换到“省电模式”
那么它的子状态“全速运行”和“待机”都会自动继承这个处理逻辑。也就是说,不管当前在哪个子状态,只要电池电量低,都会切换到省电模式。你不需要在每个子状态里重复写这个逻辑。
但子状态也可以“重写”父状态的行为。比如“待机”子状态可能觉得:电池电量低时,我先保存一下数据再切换。那它就可以自己处理 EVENT_BATTERY_LOW,父状态的处理就被覆盖了。
// 层次状态机的事件处理示例
void NormalMode_HandleEvent(Event_t event) {
switch (event) {
case EVENT_BATTERY_LOW:
// 父状态的默认处理:直接切换
TransitionToState(&PowerSaveMode);
break;
case EVENT_TEMP_HIGH:
// 父状态处理温度过高
StartFan();
break;
default:
// 未处理的事件,可以忽略或上报
break;
}
}
void Standby_HandleEvent(Event_t event) {
switch (event) {
case EVENT_BATTERY_LOW:
// 子状态重写:先保存数据
SaveUserData();
// 再调用父状态的处理
NormalMode_HandleEvent(event);
break;
default:
// 子状态不处理的事件,交给父状态
NormalMode_HandleEvent(event);
break;
}
}
你看,子状态 Standby 处理 EVENT_BATTERY_LOW 时,先做了自己的事(保存数据),然后又调用了父状态的处理。这就是典型的“继承+扩展”。
注意: 子状态重写父状态事件时,一定要想清楚:是“完全替换”还是“扩展”。如果是扩展,记得在最后调用父状态的处理。我曾经因为忘了调用父状态处理,导致一个设备在低电量时没有及时切换模式,差点把电池搞废了。
7.4 状态复用:让代码少写一半
复用是层次状态机的另一个大招。你想想看,如果多个父状态都有相似的子状态结构,是不是可以复用?
比如,不管是“正常模式”还是“省电模式”,可能都有一个“用户交互”子状态。这个子状态负责处理按键、触摸等输入。那我们可以把这个子状态定义成独立的模块,然后在两个父状态里都引用它。
// 可复用的“用户交互”子状态
State_t UserInteractionState = {
.entry = UserInteraction_Entry,
.exit = UserInteraction_Exit,
.handleEvent = UserInteraction_HandleEvent
};
// 正常模式下引用
State_t NormalMode = {
.subStates = {&FullSpeedState, &StandbyState, &UserInteractionState},
.entry = NormalMode_Entry,
.exit = NormalMode_Exit,
.handleEvent = NormalMode_HandleEvent
};
// 省电模式下也引用同一个子状态
State_t PowerSaveMode = {
.subStates = {&DeepSleepState, &LightWorkState, &UserInteractionState},
.entry = PowerSaveMode_Entry,
.exit = PowerSaveMode_Exit,
.handleEvent = PowerSaveMode_HandleEvent
};
这样做的好处很明显:
- 代码量减少:同样的逻辑不用写两遍
- 维护方便:改一个地方,所有引用它的父状态都生效
- 一致性:用户交互的行为在任何模式下都一样,用户体验更好
避坑指南: 复用子状态时,要注意子状态内部不能依赖父状态的特定上下文。比如,不要在子状态里直接访问父状态的私有变量。我见过有人把父状态的指针硬编码在子状态里,结果复用时出了大问题。正确的做法是通过事件或回调来传递上下文。
7.5 实现层次状态机的几种方式
说了这么多理论,咱们看看实际怎么实现。我常用的有三种方式:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 函数指针 + 状态栈 | 实现简单,内存可控 | 嵌套层次深时管理复杂 | 资源受限的嵌入式系统 |
| 状态表 + 父状态指针 | 结构清晰,易于扩展 | 需要额外的表结构 | 中等复杂度的项目 |
| 状态机框架(如 QP/C) | 功能强大,支持多层嵌套 | 学习成本高,代码量大 | 大型复杂系统 |
我个人比较推荐第二种——状态表 + 父状态指针。它既不会太复杂,又能很好地支持层次结构。下面是一个简单的实现示例:
// 状态表结构
typedef struct State {
void (*entry)(void); // 进入动作
void (*exit)(void); // 退出动作
void (*handleEvent)(Event_t event); // 事件处理
const struct State *parent; // 父状态指针
} State_t;
// 事件分发函数
void DispatchEvent(Event_t event) {
const State_t *current = currentState;
// 从当前状态开始,逐层向上查找能处理该事件的状态
while (current != NULL) {
if (current->handleEvent != NULL) {
current->handleEvent(event);
// 如果事件被处理了,就停止向上传递
// 这里假设 handleEvent 返回 true 表示已处理
// 实际项目中可以用返回值或全局标志
}
current = current->parent;
}
}
// 状态切换函数
void TransitionToState(const State_t *target) {
// 退出当前状态及其父状态
const State_t *p = currentState;
while (p != NULL) {
if (p->exit) p->exit();
p = p->parent;
}
// 进入目标状态及其父状态
// 注意:需要从顶层父状态开始进入
// 这里简化处理,实际需要记录路径
currentState = target;
p = target;
while (p != NULL) {
if (p->entry) p->entry();
p = p->parent;
}
}
这个实现里,每个状态都有一个 parent 指针指向它的父状态。事件分发时,如果当前状态处理不了,就自动向上找父状态处理。这就是层次状态机的核心机制。
小技巧: 状态切换时,退出和进入的顺序很重要。退出时从当前状态开始,逐层向上退出;进入时从顶层父状态开始,逐层向下进入。这样才能保证状态机的行为符合预期。
7.6 实际项目中的经验总结
最后,分享几个我在项目中积累的经验:
- 不要过度嵌套:层次超过3层,代码就很难维护了。我一般控制在2层以内。
- 父状态不要做太多事:父状态只放公共逻辑,特殊逻辑交给子状态。否则父状态会变得臃肿。
- 小心“状态风暴”:子状态切换时,父状态可能也会跟着切换。要确保切换逻辑是确定的,不会出现死循环。
- 善用“历史状态”:有些层次状态机支持“历史状态”,即退出父状态后,再次进入时恢复到之前的子状态。这个功能很实用,但实现起来要小心。
嗯,关于层次状态机,今天就聊这么多。说白了,它就是让状态机有了“继承”和“复用”的能力,让代码更简洁、更易维护。下次你遇到状态多到头疼的项目,不妨试试这个思路。
一句话总结: 父状态管通用,子状态管特殊。事件从下往上找,谁有能力谁处理。
公众号:蓝海资料掘金营,微信deep3321