12. 事件驱动状态机:状态机基础、事件驱动状态转换、状态机实现模式

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

我刚开始做嵌入式那会儿,总觉得状态机是教科书里的概念,实际写代码用if-else堆就完事了。直到有一次做一个多按键的菜单系统,if-else嵌套了七八层,改一个按键逻辑要翻半天代码……嗯,从那以后,我老老实实把状态机捡起来了。

12.1 状态机基础:三个核心要素

一个状态机,不管多复杂,核心就三样东西:状态事件动作

  • 状态(State):系统当前所处的“模式”。比如一个按键,有“按下”、“释放”、“长按”三种状态。
  • 事件(Event):触发状态变化的东西。比如“定时器超时”、“引脚电平变化”、“消息到达”。
  • 动作(Action):状态转换时执行的操作。比如“点亮LED”、“发送数据”、“启动定时器”。

我个人习惯把状态机画成一张表,比看代码直观得多。下面这张图就是最经典的状态转换模型:

空闲状态 工作状态 事件: 启动指令 事件: 停止指令 / 超时 事件: 心跳

你看,状态机其实就是一个“有限状态 + 事件驱动”的循环。每个状态只关心它能处理的事件,其他事件要么忽略,要么报错。这种“有限”恰恰是它可靠的原因。

12.2 事件驱动状态转换:谁触发了谁?

事件驱动,说白了就是“有事才动,没事歇着”。这和轮询完全不同——轮询是“不管有事没事,我都去看看”。

我在项目中遇到过一个问题:一个传感器采集模块,用轮询方式每10ms检查一次数据是否就绪。结果CPU占用率居高不下,低优先级任务饿死了。后来改成事件驱动——数据就绪时触发一个中断,状态机收到事件才处理。CPU占用率直接降了60%。

事件驱动状态转换的典型流程:

  1. 事件产生:硬件中断、定时器超时、消息队列投递等。
  2. 事件分发:将事件投递到对应状态机的事件队列。
  3. 状态查询:状态机从当前状态的事件表中查找该事件对应的处理函数。
  4. 动作执行:执行处理函数,可能包含状态转换。
  5. 状态更新:如果动作中指定了新状态,则更新当前状态。

关键点:事件驱动状态机中,状态转换是原子操作。也就是说,一个事件处理过程中,不会插入另一个事件的处理。这避免了竞态条件。

12.3 状态机实现模式:三种主流写法

实际写代码时,状态机实现方式有好几种。我按自己的经验,从简单到复杂给你捋一遍。

12.3.1 switch-case 模式(最直观)

这是入门级写法,适合状态少于10个的简单场景。

typedef enum {
    STATE_IDLE,
    STATE_RUNNING,
    STATE_ERROR
} state_t;

state_t current_state = STATE_IDLE;

void state_machine_run(event_t event) {
    switch (current_state) {
        case STATE_IDLE:
            if (event == EVT_START) {
                motor_start();
                current_state = STATE_RUNNING;
            }
            break;
        case STATE_RUNNING:
            if (event == EVT_STOP) {
                motor_stop();
                current_state = STATE_IDLE;
            } else if (event == EVT_FAULT) {
                motor_emergency_stop();
                current_state = STATE_ERROR;
            }
            break;
        case STATE_ERROR:
            if (event == EVT_RESET) {
                motor_reset();
                current_state = STATE_IDLE;
            }
            break;
        default:
            break;
    }
}

这种写法好处是简单,一眼能看懂。坏处是——状态多了以后,一个switch-case能写几百行,维护起来想哭。我曾经维护过一个项目,一个switch-case函数有800多行,改一个状态要翻半天。

12.3.2 状态表模式(推荐)

把状态和事件的对应关系抽成一张表。这是我最常用的模式,可读性和可维护性都很好。

typedef struct {
    state_t  current_state;
    event_t  event;
    state_t  next_state;
    void     (*action)(void);
} state_transition_t;

// 状态转换表
static const state_transition_t trans_table[] = {
    {STATE_IDLE,    EVT_START,  STATE_RUNNING,  motor_start},
    {STATE_RUNNING, EVT_STOP,   STATE_IDLE,     motor_stop},
    {STATE_RUNNING, EVT_FAULT,  STATE_ERROR,    motor_emergency_stop},
    {STATE_ERROR,   EVT_RESET,  STATE_IDLE,     motor_reset},
    // 终止标记
    {STATE_MAX,     EVT_MAX,    STATE_MAX,      NULL}
};

void state_machine_run(event_t event) {
    const state_transition_t *p = trans_table;
    while (p->action != NULL) {
        if (p->current_state == current_state && p->event == event) {
            p->action();          // 执行动作
            current_state = p->next_state;  // 状态转换
            return;
        }
        p++;
    }
    // 未找到匹配项,忽略事件或报错
}

这种模式的好处:

  • 状态转换逻辑集中在一张表里,改一个状态只需改表的一行。
  • 新增状态或事件,不会影响已有逻辑。
  • 甚至可以做成配置化,从外部文件加载。

我的习惯:状态转换表用const修饰,放在Flash里。嵌入式MCU的RAM宝贵,能省则省。另外,表末尾加一个终止标记,防止越界。

12.3.3 函数指针模式(最灵活)

每个状态对应一个处理函数,状态转换就是切换函数指针。这种模式适合状态行为差异很大的场景。

typedef void (*state_handler_t)(event_t event);

void state_idle_handler(event_t event);
void state_running_handler(event_t event);
void state_error_handler(event_t event);

static state_handler_t current_handler = state_idle_handler;

void state_machine_run(event_t event) {
    current_handler(event);
}

void state_idle_handler(event_t event) {
    switch (event) {
        case EVT_START:
            motor_start();
            current_handler = state_running_handler;
            break;
        default:
            break;
    }
}

void state_running_handler(event_t event) {
    switch (event) {
        case EVT_STOP:
            motor_stop();
            current_handler = state_idle_handler;
            break;
        case EVT_FAULT:
            motor_emergency_stop();
            current_handler = state_error_handler;
            break;
        default:
            break;
    }
}

这种模式的好处是每个状态的处理逻辑完全独立,可以单独测试。坏处是状态转换分散在各个函数里,全局视图不如状态表清晰。

注意:函数指针模式中,一定要小心野指针。我曾经在项目中遇到一个bug,状态处理函数指针被意外覆盖,结果程序跑飞了。排查了两天才找到原因。从那以后,我每次切换函数指针前都会加一个断言检查。

12.4 状态机设计中的常见陷阱

做状态机这么多年,踩过的坑不少。挑几个典型的说说:

  • 状态爆炸:状态太多,导致转换表庞大。解决办法是引入层次状态机(HSM),把公共行为提取到父状态。
  • 事件丢失:事件处理过程中,新事件到达被覆盖。解决办法是用队列缓存事件,保证每个事件都被处理。
  • 状态转换死循环:两个状态互相触发转换,CPU卡死。解决办法是加一个转换计数器,超过阈值就报错。
  • 动作执行时间过长:在状态机里做延时操作,阻塞了事件处理。解决办法是把耗时操作放到任务里异步执行。

嗯,说到这个,我想起一个真实案例。有个同事做智能家居网关,状态机里直接调用了WiFi连接函数,结果连接超时阻塞了5秒,期间所有按键事件全部丢失。用户按了开关,灯要等5秒才反应……后来改成异步连接,状态机只负责发起连接和接收连接结果事件,问题就解决了。

12.5 状态机与事件队列的配合

事件驱动状态机,光有状态机还不够,还得有个事件队列。事件队列负责“缓冲”事件,状态机负责“消费”事件。两者配合,才能构建一个健壮的系统。

典型的配合模式:

// 事件队列
event_t event_queue[QUEUE_SIZE];
int head = 0, tail = 0;

// 投递事件(通常由中断或任务调用)
void post_event(event_t event) {
    int next = (tail + 1) % QUEUE_SIZE;
    if (next != head) {
        event_queue[tail] = event;
        tail = next;
    }
    // 队列满时,可丢弃或覆盖旧事件
}

// 主循环或任务中处理事件
void event_loop(void) {
    while (1) {
        if (head != tail) {
            event_t event = event_queue[head];
            head = (head + 1) % QUEUE_SIZE;
            state_machine_run(event);
        }
        // 无事件时,可进入低功耗模式
    }
}

这种设计的好处是:中断或任务只管投递事件,不用关心状态机当前在干什么。状态机只管处理事件,不用关心事件从哪里来。解耦得干干净净。

总结一下:事件驱动状态机 = 事件队列(缓冲) + 状态表(逻辑) + 动作函数(执行)。三个部分各司其职,组合起来就是一个可靠、可维护的嵌入式系统核心。

好了,状态机的基础和实现模式就聊到这儿。这东西看着简单,但用好了,能让你的代码结构清晰一个数量级。下次写复杂逻辑时,不妨先画个状态图,再动手写代码——你会发现,思路顺了,bug也少了。