2. STM32状态机实现:三种主流方案

状态机的实现方式,说白了就是怎么把「当前状态 + 事件 → 下一状态 + 动作」这个逻辑落地到代码里。我在实际项目中见过不下十种写法,但真正经得起工程考验的,其实就三种:switch-case、函数指针表、状态表驱动。

今天咱们就把这三种方案掰开揉碎,看看它们各自适合什么场景,坑在哪,怎么选。

2.1 switch-case 实现:最直观,但别滥用

这是大多数人入门状态机的写法。一个 switch 套一个 case,每个 case 里处理当前状态下的所有事件。简单粗暴,一眼就能看懂。

// 状态枚举
typedef enum {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_PAUSED,
    STATE_ERROR
} State_t;

// 事件枚举
typedef enum {
    EVT_START,
    EVT_STOP,
    EVT_PAUSE,
    EVT_RESUME,
    EVT_ERROR
} Event_t;

// 状态机处理函数
State_t stateMachine(State_t currentState, Event_t event) {
    State_t nextState = currentState;

    switch (currentState) {
        case STATE_IDLE:
            if (event == EVT_START) {
                nextState = STATE_RUNNING;
                // 执行进入动作:启动定时器、点亮指示灯
            }
            break;

        case STATE_RUNNING:
            switch (event) {
                case EVT_STOP:
                    nextState = STATE_IDLE;
                    break;
                case EVT_PAUSE:
                    nextState = STATE_PAUSED;
                    break;
                case EVT_ERROR:
                    nextState = STATE_ERROR;
                    break;
                default:
                    break;
            }
            break;

        case STATE_PAUSED:
            if (event == EVT_RESUME) {
                nextState = STATE_RUNNING;
            } else if (event == EVT_STOP) {
                nextState = STATE_IDLE;
            }
            break;

        case STATE_ERROR:
            if (event == EVT_STOP) {
                nextState = STATE_IDLE;
            }
            break;

        default:
            break;
    }

    return nextState;
}
我的习惯:状态少于 5 个、事件少于 8 个时,我首选 switch-case。代码可读性最好,调试时单步跟踪也最方便。

但这里有个坑——状态多了以后,代码会迅速膨胀。我曾经接手过一个项目,状态机有 20 多个状态,每个 case 里嵌套着 switch,整个函数写了 800 多行。改一个状态要翻半天,加一个新事件得把所有 case 过一遍。嗯,那感觉,谁改谁知道。

避坑指南:我曾经在一个温控项目中用 switch-case 写了 15 个状态,结果产品迭代到第三版时,状态数翻了一倍。代码已经没法维护了,最后不得不重构。所以我的建议是:状态数超过 10 个,就别用 switch-case 了。

2.2 函数指针表实现:灵活,但别玩脱了

函数指针表的核心思想是:把每个状态的处理逻辑封装成一个独立的函数,然后用一个二维函数指针数组来管理状态转移。

// 状态处理函数类型
typedef State_t (*StateHandler_t)(Event_t);

// 状态处理函数声明
State_t idleHandler(Event_t event);
State_t runningHandler(Event_t event);
State_t pausedHandler(Event_t event);
State_t errorHandler(Event_t event);

// 状态处理函数表
StateHandler_t stateHandlers[] = {
    idleHandler,      // STATE_IDLE
    runningHandler,   // STATE_RUNNING
    pausedHandler,    // STATE_PAUSED
    errorHandler      // STATE_ERROR
};

// 状态机主循环
State_t currentState = STATE_IDLE;

while (1) {
    Event_t event = getEvent();  // 获取事件
    currentState = stateHandlers[currentState](event);
}

每个状态处理函数内部可以写自己的逻辑,互不干扰:

State_t idleHandler(Event_t event) {
    switch (event) {
        case EVT_START:
            initMotor();  // 初始化电机
            return STATE_RUNNING;
        default:
            return STATE_IDLE;
    }
}

State_t runningHandler(Event_t event) {
    switch (event) {
        case EVT_STOP:
            stopMotor();
            return STATE_IDLE;
        case EVT_PAUSE:
            pauseMotor();
            return STATE_PAUSED;
        case EVT_ERROR:
            emergencyStop();
            return STATE_ERROR;
        default:
            return STATE_RUNNING;
    }
}
优点很明显:每个状态的处理逻辑被隔离到独立函数中,新增状态只需要加一个函数、在表里注册一下就行。代码结构清晰,适合团队协作开发。

但函数指针表也有它的短板。你想想看,如果状态和事件都很多,这个二维表会变得非常大。比如 20 个状态 × 15 个事件 = 300 个函数指针。而且,函数指针的调用开销比直接 switch-case 要大,在 STM32F103 这种主频不高的芯片上,高频调用的场景下会有性能问题。

我的经验:在 STM32F4 以上的芯片上,函数指针表的性能损耗基本可以忽略。但在 F0、F1 系列上,如果状态机每秒被调用超过 1000 次,我建议还是用 switch-case 或者状态表驱动。

2.3 状态表驱动实现:最工程化,但门槛最高

状态表驱动,说白了就是把状态转移规则做成一张表。这张表里每一行就是一个「当前状态 + 事件 → 下一状态 + 动作」的映射。状态机引擎只需要查表执行就行了。

// 状态表条目结构
typedef struct {
    State_t  currentState;
    Event_t  event;
    State_t  nextState;
    void     (*action)(void);  // 转移时执行的动作
} StateTableEntry_t;

// 动作函数声明
void actionStartMotor(void);
void actionStopMotor(void);
void actionPauseMotor(void);
void actionResumeMotor(void);
void actionErrorHandler(void);

// 状态转移表
const StateTableEntry_t stateTable[] = {
    {STATE_IDLE,    EVT_START,  STATE_RUNNING,  actionStartMotor},
    {STATE_RUNNING, EVT_STOP,   STATE_IDLE,     actionStopMotor},
    {STATE_RUNNING, EVT_PAUSE,  STATE_PAUSED,   actionPauseMotor},
    {STATE_PAUSED,  EVT_RESUME, STATE_RUNNING,  actionResumeMotor},
    {STATE_PAUSED,  EVT_STOP,   STATE_IDLE,     actionStopMotor},
    {STATE_RUNNING, EVT_ERROR,  STATE_ERROR,    actionErrorHandler},
    {STATE_IDLE,    EVT_ERROR,  STATE_ERROR,    actionErrorHandler},
    // 表结束标记
    {STATE_MAX,     EVT_MAX,    STATE_MAX,      NULL}
};

// 状态机引擎
State_t stateMachineEngine(State_t currentState, Event_t event) {
    const StateTableEntry_t *entry = stateTable;

    while (entry->action != NULL) {
        if (entry->currentState == currentState && entry->event == event) {
            if (entry->action != NULL) {
                entry->action();  // 执行动作
            }
            return entry->nextState;
        }
        entry++;
    }

    // 未找到匹配项,保持当前状态
    return currentState;
}
状态表驱动的核心优势:状态转移规则和数据分离。你想改一个转移逻辑,不需要动代码逻辑,只需要改表里的数据。这在产品迭代频繁的项目里简直是救命稻草。

我在一个工业控制项目中用过这种方案。那个项目有 30 多个状态、20 多种事件,状态转移规则有 100 多条。如果用 switch-case,代码得写到天荒地老。用状态表驱动,整个状态机引擎只有 30 行代码,剩下的全是数据表。测试人员可以直接对着表写测试用例,开发效率翻了一倍。

注意:状态表驱动虽然好,但查表是线性扫描的。如果表很大(比如几百条),每次状态转移都要遍历整个表,性能会下降。我的做法是:按当前状态分组排序,查表时先定位到当前状态对应的起始位置,这样能把 O(n) 降到 O(m),m 是当前状态对应的事件数。

2.4 三种方案对比

对比维度 switch-case 函数指针表 状态表驱动
代码可读性 状态少时很好 良好 需要理解表结构
可维护性 状态多时差 良好 优秀
执行效率 最高 中等 中等(线性查表)
代码量 少 中等 引擎少,数据多
适合状态数 ≤ 10 ≤ 20 ≥ 10
调试难度 低 中等 中等
典型场景 简单按键处理 中等复杂协议 工业控制、通信协议

2.5 三种方案的选择策略

说了这么多,到底怎么选?我个人的经验是:

  • 原型验证阶段:用 switch-case,快。等需求稳定了再重构。
  • 状态数 5-10 个,且不会大幅增长:switch-case 够用,别折腾。
  • 状态数 10-20 个,需要团队协作:函数指针表,每个状态一个文件,分工明确。
  • 状态数超过 20 个,或者需求频繁变更:状态表驱动,改表不改代码。
  • 对性能要求极高(比如中断里调用):switch-case 或者优化后的状态表(用二分查找)。
一个小技巧:我习惯在项目初期先用 switch-case 快速跑通功能,然后在代码里预留好状态表和函数指针表的接口。等需求明确了,再花半天时间把 switch-case 重构为状态表驱动。这样既保证了开发速度,又不会把代码写死。
状态机实现方案选择流程图 开始选择 状态数 ≤ 10? 是 switch-case 否 状态数 ≤ 20? 是 函数指针表 否 状态表驱动 高频调用(中断内)? 是 优化查表/switch-case 否 状态表驱动 提示:原型阶段先用 switch-case 快速验证,稳定后再根据状态数重构为更合适的方案

嗯,三种方案都讲完了。你可能会问:「那我到底该学哪一种?」我的回答是:全都要学。因为在实际项目中,你很可能在一个系统里同时用到它们——比如主状态机用状态表驱动,子状态机用函数指针表,某个简单的按键处理用 switch-case。工具没有好坏,只有合不合适。