13、状态机编程:状态机设计模式、状态转换表、状态机防御策略、状态机测试
状态机这东西,说白了就是让代码“长记性”。
我刚开始做嵌入式那会儿,写逻辑全靠一堆 if-else 堆砌。程序跑起来像醉汉走路,动不动就跑到不该去的地方。后来被一个 bug 折磨了三天——某个标志位在中断里被改了,主循环没反应过来,系统直接死锁。从那以后,我彻底服了状态机。
今天咱们就聊聊状态机的几种玩法,以及怎么让它变得皮实耐用。
13.1 状态机设计模式:三种主流风格
我个人习惯把状态机分成三类。不是教科书上的分类,是我在实际项目中摸爬滚打总结出来的。
13.1.1 简单状态机:switch-case 大法
这是最直观的写法。适合状态少、逻辑简单的场景。比如一个按键消抖处理。
typedef enum {
KEY_IDLE,
KEY_DEBOUNCE,
KEY_PRESSED,
KEY_RELEASE
} KeyState_t;
KeyState_t currentState = KEY_IDLE;
void KeyFSM_Process(uint8_t keyInput) {
switch (currentState) {
case KEY_IDLE:
if (keyInput == 0) { // 按键按下
currentState = KEY_DEBOUNCE;
debounceTimer = 0;
}
break;
case KEY_DEBOUNCE:
debounceTimer++;
if (debounceTimer >= 5) {
if (keyInput == 0) {
currentState = KEY_PRESSED;
// 触发按键按下事件
} else {
currentState = KEY_IDLE; // 抖动,回到空闲
}
}
break;
case KEY_PRESSED:
if (keyInput == 1) { // 按键释放
currentState = KEY_RELEASE;
}
break;
case KEY_RELEASE:
// 处理释放后的逻辑
currentState = KEY_IDLE;
break;
default:
// 防御:未知状态,复位
currentState = KEY_IDLE;
break;
}
}
这种写法有个好处:一眼就能看明白。但状态一多,switch-case 会变得又臭又长。我曾经维护过一个 30 多个状态的状态机,光 case 就写了 200 行,改起来心惊胆战。
13.1.2 查表法状态机:用数据驱动逻辑
状态多了怎么办?用表。把状态转换关系抽出来,变成一张表格。代码就变成了查表执行。
typedef struct {
State_t currentState;
Event_t event;
State_t nextState;
Action_t action; // 转换时执行的动作
} StateTransition_t;
// 状态转换表
const StateTransition_t stateTable[] = {
{STATE_IDLE, EVENT_START, STATE_RUNNING, ActionStart},
{STATE_RUNNING, EVENT_PAUSE, STATE_PAUSED, ActionPause},
{STATE_RUNNING, EVENT_STOP, STATE_IDLE, ActionStop},
{STATE_PAUSED, EVENT_RESUME, STATE_RUNNING, ActionResume},
{STATE_PAUSED, EVENT_STOP, STATE_IDLE, ActionStop},
// ... 更多条目
};
State_t currentState = STATE_IDLE;
void FSM_ProcessEvent(Event_t event) {
for (int i = 0; i < sizeof(stateTable)/sizeof(stateTable[0]); i++) {
if (stateTable[i].currentState == currentState &&
stateTable[i].event == event) {
// 执行动作
if (stateTable[i].action) {
stateTable[i].action();
}
// 状态迁移
currentState = stateTable[i].nextState;
return;
}
}
// 没找到匹配项:事件在当前状态下被忽略或视为错误
// 这里可以记录日志或触发错误处理
}
查表法的好处是:逻辑和数据分离。想加一个新状态或新事件?往表里加一行就行,不用改核心代码。我在做通信协议栈时用过这种模式,状态转换表有 40 多条,但核心引擎只有 50 行代码,维护起来特别清爽。
13.1.3 分层状态机:状态里套状态
有些系统状态太复杂,比如一个无人机飞控:有“飞行中”这个大状态,里面又包含“悬停”、“前进”、“降落”等子状态。这时候分层状态机就派上用场了。
分层状态机的核心思想是:子状态继承父状态的行为。父状态处理通用事件,子状态处理特殊事件。我在项目中实现过一个简化版:
typedef struct {
State_t superState; // 父状态
State_t subState; // 当前子状态
void (*onEntry)(void);
void (*onExit)(void);
State_t (*process)(Event_t event);
} HierarchicalState_t;
嗯,这里要注意:分层状态机虽然强大,但调试起来也最头疼。我建议除非真的需要,否则别轻易上分层。简单够用就好。
13.2 状态转换表:把逻辑画成格子
状态转换表,说白了就是把“当前状态 + 事件 → 下一状态 + 动作”这个关系,整理成一张表格。它既是设计文档,也是代码的蓝图。
下面是一个简单的状态转换表示例(以自动售货机为例):
| 当前状态 | 事件 | 下一状态 | 动作 |
|---|---|---|---|
| 空闲 | 投币 | 已投币 | 显示余额 |
| 已投币 | 选择商品 | 出货中 | 扣款、出货 |
| 已投币 | 退币 | 空闲 | 退币、清零 |
| 出货中 | 出货完成 | 空闲 | 无 |
| 任何状态 | 故障 | 故障 | 报警、锁定 |
你想想看,有了这张表,写代码就是翻译工作。而且这张表还能用来做覆盖率分析——看看哪些状态-事件组合没测试到。
13.3 状态机防御策略:别让状态跑飞了
状态机最怕什么?跑到一个未定义的状态。一旦状态变量被意外修改(比如内存越界、中断干扰),整个系统就乱套了。
我分享几个实战中验证过的防御策略:
13.3.1 状态变量保护
- 使用枚举类型:别用 int 或 char 表示状态,用 enum。编译器能帮你做类型检查。
- 状态范围检查:每次状态转换后,检查新状态是否在合法范围内。
- 关键状态加冗余:对于安全关键系统,可以用两个变量存储状态,比较一致才执行。
// 状态范围检查宏
#define IS_VALID_STATE(s) ((s) >= STATE_FIRST && (s) <= STATE_LAST)
void FSM_Transition(State_t newState) {
if (!IS_VALID_STATE(newState)) {
// 非法状态!触发错误处理
ErrorHandler(ERROR_INVALID_STATE);
return;
}
currentState = newState;
}
13.3.2 默认状态与看门狗
在 switch-case 里,永远要加一个 default 分支。这不是摆设,是救命稻草。我曾经遇到过一个 case,某个状态因为 RAM 被意外改写,变成了一个不存在的值。幸好 default 分支把它拉回了空闲状态,系统才没死锁。
另外,状态机超时检测也很有用。比如某个状态应该在 100ms 内离开,如果超时了,说明出问题了。这时候可以强制复位状态机。
13.3.3 事件队列与防重入
如果状态机在中断和主循环中都被调用,一定要加事件队列。中断只负责往队列里放事件,主循环负责处理。别在中断里直接调用状态机处理函数,否则重入问题会让你欲哭无泪。
13.4 状态机测试:别让 bug 溜过去
状态机测试,核心就两件事:状态覆盖和转换覆盖。
13.4.1 状态覆盖测试
确保每个状态都至少进入过一次。这听起来简单,但有些状态(比如“故障恢复中”)可能只在特定条件下触发,容易被忽略。
13.4.2 转换覆盖测试
确保每个状态-事件组合都被测试到。对于查表法状态机,可以写一个自动化脚本,遍历状态转换表,生成测试用例。
// 测试框架示例:遍历所有状态-事件组合
void FSM_TestAllTransitions(void) {
for (State_t s = STATE_FIRST; s <= STATE_LAST; s++) {
for (Event_t e = EVENT_FIRST; e <= EVENT_LAST; e++) {
// 设置初始状态
currentState = s;
// 发送事件
FSM_ProcessEvent(e);
// 检查结果状态是否合法
assert(IS_VALID_STATE(currentState));
// 记录测试结果
printf("State %d, Event %d -> State %d\n", s, e, currentState);
}
}
}
13.4.3 边界与异常测试
- 非法事件:发送一个不存在的 event ID,看状态机是否崩溃。
- 连续事件:快速连续发送多个事件,看状态机是否处理得过来。
- 空事件:发送 NULL 事件或空指针,看会不会段错误。
13.5 本章知识体系总览
下面这张图,把状态机编程的核心脉络串起来了。你可以把它当作一个快速索引:
状态机编程,说白了就是用结构对抗混乱。你想想看,一个系统里几十个状态、上百种事件,如果没有状态机这种结构化的方法,代码迟早变成一团乱麻。
我个人觉得,状态机是嵌入式工程师必须掌握的技能之一。它不复杂,但能帮你省下大量调试时间。下次写逻辑之前,先画张状态转换图,再动手写代码——你会发现,bug 少了一半。