状态机编程:有限状态机的C语言实现,事件驱动与状态转移表

状态机这东西,说白了就是让程序“记住自己到了哪一步”。

我刚开始做嵌入式那会儿,写控制逻辑全靠if-else堆。一个按键处理函数能写两百行,后来自己都看不懂了。直到有一次做通信协议解析,状态一多,bug满天飞。嗯,从那以后,我彻底转向了状态机。

什么是有限状态机?

有限状态机(FSM)是一种数学模型。它包含:

  • 状态:系统当前所处的模式
  • 事件:触发状态变化的条件
  • 动作:进入或离开状态时执行的操作
  • 转移:从一个状态到另一个状态的路径

你想想看,一个LED灯,无非就是“亮”和“灭”两种状态。按一下按键,状态翻转。这就是最简单的状态机。

核心思想:程序的行为由当前状态决定,而不是由代码的执行顺序决定。

状态机的三种实现方式

我在项目中用过三种方式,各有优劣。我一个个说。

1. switch-case 实现

最直观的方式。适合状态少、逻辑简单的场景。

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

State_t currentState = STATE_IDLE;

void stateMachine(Event_t event) {
    switch (currentState) {
        case STATE_IDLE:
            if (event == EVT_START) {
                currentState = STATE_RUNNING;
                motorStart();
            }
            break;
        case STATE_RUNNING:
            if (event == EVT_PAUSE) {
                currentState = STATE_PAUSE;
                motorPause();
            } else if (event == EVT_STOP) {
                currentState = STATE_STOP;
                motorStop();
            }
            break;
        // 其他状态...
    }
}

这种写法,我早期项目里用得最多。简单直接,但状态一多,case块就膨胀得厉害。我曾经维护过一个项目,一个switch-case函数写了800行,改一个状态要翻半天。

我的习惯:状态少于5个,用switch-case。超过5个,赶紧换方案。

2. 函数指针表实现

每个状态对应一个处理函数。用函数指针数组来管理。

typedef void (*StateHandler_t)(Event_t);

StateHandler_t stateTable[] = {
    [STATE_IDLE]    = idleHandler,
    [STATE_RUNNING] = runningHandler,
    [STATE_PAUSE]   = pauseHandler,
    [STATE_STOP]    = stopHandler
};

void stateMachine(Event_t event) {
    stateTable[currentState](event);
}

你看,主循环就一行代码。每个状态的处理逻辑被隔离到独立的函数里。改一个状态,不会影响其他状态。

我记得有一次做智能家居网关,有12个状态。用函数指针表,代码清晰得像教科书。后来同事接手,说这是他见过最好维护的代码。

3. 状态转移表实现

这是我最推荐的方式,尤其是状态多、事件复杂的时候。

状态转移表是一个二维数组。行是当前状态,列是事件。单元格里存的是下一个状态和要执行的动作。

typedef struct {
    State_t  nextState;
    Action_t action;
} Transition_t;

// 状态转移表
Transition_t stateTable[STATE_MAX][EVT_MAX] = {
    // STATE_IDLE
    [STATE_IDLE] = {
        [EVT_START]  = {STATE_RUNNING, ACTION_START_MOTOR},
        [EVT_STOP]   = {STATE_IDLE,    ACTION_NONE},
        [EVT_ERROR]  = {STATE_ERROR,   ACTION_LOG_ERROR}
    },
    // STATE_RUNNING
    [STATE_RUNNING] = {
        [EVT_PAUSE]  = {STATE_PAUSE,   ACTION_PAUSE_MOTOR},
        [EVT_STOP]   = {STATE_STOP,    ACTION_STOP_MOTOR},
        [EVT_ERROR]  = {STATE_ERROR,   ACTION_EMERGENCY_STOP}
    },
    // 其他状态...
};

void stateMachine(Event_t event) {
    Transition_t *t = &stateTable[currentState][event];
    if (t->action != ACTION_NONE) {
        executeAction(t->action);
    }
    currentState = t->nextState;
}

为什么我偏爱转移表?

  • 状态和事件的关系一目了然
  • 新增状态或事件,只需要扩展表格
  • 逻辑集中,不容易遗漏转移路径
  • 可以用工具自动生成表格代码

事件驱动机制

状态机不能自己跑,得有东西推它一把。这个“推手”就是事件。

事件从哪里来?

  • 硬件中断(按键按下、定时器超时)
  • 消息队列(其他任务发来的指令)
  • 内部定时器(超时事件)
  • 传感器数据变化

我一般会做一个事件队列。外部中断或任务把事件塞进队列,主循环从队列里取事件,喂给状态机。

typedef struct {
    Event_t events[QUEUE_SIZE];
    int head;
    int tail;
} EventQueue_t;

void postEvent(Event_t evt) {
    // 把事件放入队列
    queue.events[queue.tail] = evt;
    queue.tail = (queue.tail + 1) % QUEUE_SIZE;
}

void mainLoop(void) {
    while (1) {
        if (!isQueueEmpty()) {
            Event_t evt = getEvent();
            stateMachine(evt);
        }
        // 其他任务...
    }
}

我曾经踩过的坑:事件队列溢出。有一次做工业控制器,中断频率太高,事件生产速度大于消费速度,队列满了,事件丢失。后来我加了队列满时的处理策略——要么丢弃旧事件,要么阻塞生产者,要么报警。具体选哪种,看业务容忍度。

状态机设计要点

做了这么多年,我总结了几条铁律:

  1. 状态要正交:两个状态不能同时成立。如果出现“既在运行又在暂停”,说明状态划分有问题。
  2. 事件要完整:每个状态对每个事件都要有处理。哪怕只是忽略,也要显式写出来。空转移也是一种设计。
  3. 动作要原子:一个动作只做一件事。不要在动作里嵌套状态转移。
  4. 状态图先画:写代码之前,先在纸上画状态转移图。画清楚了,代码就是翻译工作。

我的小技巧:用枚举定义状态和事件时,把“无效”或“空”值放在0位置。这样初始化时默认就是无效状态,调试时容易发现问题。

状态转移图

下面这张图,展示了一个典型的状态机结构。我建议你把它打印出来贴在工位上。

有限状态机结构图 硬件中断 消息队列 定时器超时 传感器数据 事件队列 FIFO 生产者-消费者 状态机核心 状态A 状态B 状态C 状态D 状态转移表驱动 动作 执行 回调 反馈:动作执行结果可能产生新事件

实际项目中的状态机

我参与过一个智能门锁项目。状态包括:待机、验证中、已开锁、报警、低电量。事件有:按键输入、指纹匹配、超时、电量检测。

用状态转移表实现后,整个逻辑只有不到100行。测试覆盖率轻松做到100%。后来客户要加一个“防撬报警”状态,我花了10分钟改表,5分钟测试,搞定。

状态机的价值:它把混乱的逻辑变成了清晰的表格。你不再需要关心“现在该执行哪段代码”,只需要关心“当前状态 + 来了什么事件 = 下一步做什么”。

避坑指南

最后,分享几个我踩过的坑:

  • 状态爆炸:状态太多时,考虑用嵌套状态机或层次状态机。不要把100个状态平铺在一张表里。
  • 事件丢失:中断里直接调用状态机?别这么做。中断应该只负责投递事件,状态机在主循环里跑。
  • 状态不一致:多任务环境下,状态机要加锁。我见过两个任务同时修改状态,结果状态变成了一个非法值。
  • 死循环:状态A收到事件X转到状态B,状态B收到事件Y又转回状态A。如果事件X和Y交替触发,系统就卡死了。设计时要注意避免这种循环。

我曾经犯过的错:在一个通信协议栈里,状态机没有处理“非法事件”。结果收到一个损坏的数据包,状态机跑到了一个未定义的状态,整个协议栈崩溃。从那以后,我每个状态都加了一个default分支,专门处理意外事件。

状态机编程,说白了就是“以不变应万变”。你把状态和转移规则定好了,剩下的就是事件驱动。代码稳定、可读、好维护。嗯,这大概就是嵌入式工程师追求的“优雅”吧。


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