3. 嵌入式状态机编程基础:C语言实现状态机的三种基本结构

大家好,我是你们的嵌入式系统讲师。今天我们来聊聊状态机的C语言实现。说实话,我见过太多新手一上来就写switch-case,然后代码膨胀到没法看。其实状态机的实现方式有好几种,每种都有它的脾气。

我个人习惯把状态机实现分成三类:switch-case法函数指针法查表法。这三种方法,说白了就是「从简单到灵活」的进化过程。你想想看,一个简单的按键消抖用switch-case就够了,但要是做个通信协议栈,不用查表法你会写到怀疑人生。

3.1 switch-case法:最直观的入门方式

这是大多数人接触状态机的第一种方式。结构清晰,逻辑直白。我在项目中遇到过不少同事,上来就用这个,结果状态一多,一个函数写了上千行。

typedef enum {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_PAUSE,
    STATE_STOP
} State_t;

State_t currentState = STATE_IDLE;

void stateMachine(void) {
    switch(currentState) {
        case STATE_IDLE:
            // 空闲状态处理
            if (startFlag) {
                currentState = STATE_RUNNING;
            }
            break;
            
        case STATE_RUNNING:
            // 运行状态处理
            if (pauseFlag) {
                currentState = STATE_PAUSE;
            } else if (stopFlag) {
                currentState = STATE_STOP;
            }
            break;
            
        case STATE_PAUSE:
            // 暂停状态处理
            if (resumeFlag) {
                currentState = STATE_RUNNING;
            } else if (stopFlag) {
                currentState = STATE_STOP;
            }
            break;
            
        case STATE_STOP:
            // 停止状态处理
            if (resetFlag) {
                currentState = STATE_IDLE;
            }
            break;
            
        default:
            // 错误状态处理
            currentState = STATE_IDLE;
            break;
    }
}

我的经验:switch-case适合状态数少于10个的场景。我曾经在一个温控器项目里用了这个,状态只有5个,代码可读性很好。但如果你有20个状态以上,嗯,还是看看后面两种方法吧。

注意:每个case后面一定要加break!我见过有人漏掉break,结果状态机跑飞了,排查了一整天。还有,default分支一定要写,哪怕你觉得永远不会执行到。

3.2 函数指针法:模块化的进阶之路

函数指针法,说白了就是把每个状态的处理逻辑封装成独立的函数。这样每个函数只关心自己的事,代码清爽多了。

为什么会这样?因为每个状态的处理函数都是独立的,你改一个状态不会影响其他状态。我在做物联网网关项目时,状态机有15个状态,用函数指针法后,每个状态函数平均只有30行代码。

typedef void (*StateHandler_t)(void);

typedef enum {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_PAUSE,
    STATE_STOP,
    STATE_MAX
} State_t;

// 状态处理函数声明
void idleHandler(void);
void runningHandler(void);
void pauseHandler(void);
void stopHandler(void);

// 状态表
StateHandler_t stateTable[STATE_MAX] = {
    idleHandler,
    runningHandler,
    pauseHandler,
    stopHandler
};

State_t currentState = STATE_IDLE;

void stateMachine(void) {
    if (currentState < STATE_MAX) {
        stateTable[currentState]();
    }
}

void idleHandler(void) {
    // 空闲状态处理逻辑
    if (startFlag) {
        currentState = STATE_RUNNING;
    }
}

void runningHandler(void) {
    // 运行状态处理逻辑
    if (pauseFlag) {
        currentState = STATE_PAUSE;
    } else if (stopFlag) {
        currentState = STATE_STOP;
    }
}

核心优势:每个状态处理函数可以单独测试。我习惯先写一个空函数,然后逐步填充逻辑。这样调试起来特别方便,哪个状态出问题,直接定位到对应的函数就行。

3.3 查表法:工业级状态机的标配

查表法,你想想看,就是把状态转移关系做成一张表。这张表里记录了:当前状态、触发事件、下一个状态、以及要执行的动作。这是我最推荐的方式,尤其是做复杂系统的时候。

我曾经在一个通信协议栈项目里,状态机有30多个状态,事件也有20多种。如果用switch-case,代码量会爆炸。但用查表法,整个状态机核心代码不到100行。

typedef enum {
    EVENT_NONE,
    EVENT_START,
    EVENT_PAUSE,
    EVENT_RESUME,
    EVENT_STOP,
    EVENT_RESET
} Event_t;

typedef struct {
    State_t currentState;
    Event_t event;
    State_t nextState;
    void (*action)(void);
} StateTransition_t;

// 状态转移表
const StateTransition_t transitionTable[] = {
    {STATE_IDLE,    EVENT_START,  STATE_RUNNING, actionStart},
    {STATE_RUNNING, EVENT_PAUSE,  STATE_PAUSE,   actionPause},
    {STATE_RUNNING, EVENT_STOP,   STATE_STOP,    actionStop},
    {STATE_PAUSE,   EVENT_RESUME, STATE_RUNNING, actionResume},
    {STATE_PAUSE,   EVENT_STOP,   STATE_STOP,    actionStop},
    {STATE_STOP,    EVENT_RESET,  STATE_IDLE,    actionReset},
    // 结束标记
    {STATE_MAX,     EVENT_NONE,   STATE_MAX,     NULL}
};

State_t currentState = STATE_IDLE;

void stateMachine(Event_t event) {
    const StateTransition_t *p = transitionTable;
    
    while (p->action != NULL) {
        if ((p->currentState == currentState) && (p->event == event)) {
            // 执行动作
            if (p->action) {
                p->action();
            }
            // 状态转移
            currentState = p->nextState;
            return;
        }
        p++;
    }
    
    // 未找到匹配的转移,可以记录错误
    errorHandler();
}

我的建议:查表法的表最好用const修饰,放在Flash里。这样既节省RAM,又不会意外修改。我在一个产品里就吃过这个亏,表被意外改了,状态机乱跳,排查了好久才发现是RAM被踩了。

3.4 三种方法的对比

特性 switch-case法 函数指针法 查表法
代码可读性 状态少时好,多时差 非常好
可维护性 差(状态耦合) 好(模块化) 极好(数据驱动)
RAM占用 中(函数指针数组) 低(表在Flash)
执行速度 快(间接调用) 中(查表开销)
适合场景 简单状态机(<10状态) 中等复杂度 复杂状态机(>20状态)

避坑指南:我曾经在一个项目里,用查表法但忘了加结束标记,结果查表越界,系统直接跑飞。所以一定要在表末尾加一个明确的结束标记,比如把state设为STATE_MAX,action设为NULL。

3.5 我的选择建议

说实话,没有银弹。我个人的经验是:

  • 原型验证阶段:用switch-case,快速出活
  • 产品开发阶段:用查表法,方便后期维护
  • 状态机复用场景:用函数指针法,方便扩展

你想想看,一个状态机写得好不好,不是看它能不能跑,而是看三个月后你还能不能看懂。我见过太多人一开始图省事用switch-case,结果状态一多,代码变成一坨屎山。嗯,这里要注意,选对方法比写对代码更重要。

最后说一句,不管用哪种方法,状态机的核心是状态和事件的清晰定义。先把状态图画好,再动手写代码,这才是正道。