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 状态转换表:把逻辑画成格子

状态转换表,说白了就是把“当前状态 + 事件 → 下一状态 + 动作”这个关系,整理成一张表格。它既是设计文档,也是代码的蓝图。

下面是一个简单的状态转换表示例(以自动售货机为例):

当前状态 事件 下一状态 动作
空闲 投币 已投币 显示余额
已投币 选择商品 出货中 扣款、出货
已投币 退币 空闲 退币、清零
出货中 出货完成 空闲
任何状态 故障 故障 报警、锁定

你想想看,有了这张表,写代码就是翻译工作。而且这张表还能用来做覆盖率分析——看看哪些状态-事件组合没测试到。

我的小技巧: 我习惯在状态转换表里加一列“错误处理”。比如某个状态收到了不该出现的事件,应该怎么处理?是忽略、记录日志、还是触发复位?提前想好,比出 bug 了再补要省心得多。

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 本章知识体系总览

下面这张图,把状态机编程的核心脉络串起来了。你可以把它当作一个快速索引:

状态机编程知识体系 状态机设计模式 • switch-case 模式:简单直观,适合状态少 • 查表法模式:数据驱动,易于扩展 • 分层状态机:状态嵌套,适合复杂系统 状态转换表 • 当前状态 + 事件 → 下一状态 + 动作 • 既是设计文档,也是代码蓝图 • 可用于覆盖率分析 状态机防御策略 • 状态变量保护:枚举、范围检查、冗余 • 默认分支与看门狗 • 事件队列与防重入 状态机测试 • 状态覆盖:每个状态至少进入一次 • 转换覆盖:每个事件-状态组合都测到 • 边界与异常测试 核心思想:让状态机可预测、可测试、可防御

状态机编程,说白了就是用结构对抗混乱。你想想看,一个系统里几十个状态、上百种事件,如果没有状态机这种结构化的方法,代码迟早变成一团乱麻。

我个人觉得,状态机是嵌入式工程师必须掌握的技能之一。它不复杂,但能帮你省下大量调试时间。下次写逻辑之前,先画张状态转换图,再动手写代码——你会发现,bug 少了一半。


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