嵌入式事件驱动状态机与实时任务调度 - 第2节

状态机基础理论:有限状态机(FSM)的四要素

各位同学,咱们今天聊聊状态机。说实话,我刚入行那会儿,觉得状态机就是个花架子——不就是 switch-case 吗?后来在一个多传感器融合的项目里,被各种乱七八糟的状态组合折磨得死去活来,才老老实实回去啃状态机理论。

有限状态机,英文叫 Finite State Machine,简称 FSM。说白了,就是描述一个系统在任意时刻只能处于有限个状态中的一个,然后根据外部事件在这些状态之间跳来跳去。你想想看,电梯、自动售货机、通信协议栈,甚至你手机里的触摸屏手势识别,底层全是这玩意儿。

状态(State)——系统在干什么

状态是 FSM 最核心的概念。它表示系统在某个时刻的“定格画面”。我习惯把状态理解成:系统正在等待某个特定事件发生的时刻。

  • 确定性:同一时刻只能处于一个状态。不能又开门又关门。
  • 有限性:状态总数是固定的,不能无限多。
  • 可记忆性:状态本身隐含了历史信息——系统怎么走到这里的。
我的经验:定义状态时,别贪多。我见过有人把“等待按键按下”和“等待按键释放”拆成两个状态,其实用一个“等待按键”状态加一个内部标志位就够了。状态多了,转移逻辑就爆炸。

事件(Event)——发生了什么

事件是触发状态转移的“扳机”。没有事件,状态机就是死的。事件可以是外部输入(按键按下、数据包到达),也可以是内部信号(定时器超时、计算完成)。

这里有个坑,我踩过好几次:事件不能带“状态信息”。比如你定义了一个事件叫“温度过高”,这其实隐含了“当前温度 > 阈值”的判断。正确的做法是事件只叫“温度采样完成”,把判断逻辑放在转移条件里。否则你的事件会膨胀到无法维护。

转移(Transition)——怎么变

转移定义了系统从一个状态到另一个状态的路径。一个完整的转移包含三要素:

  • 源状态:从哪出发
  • 触发事件:什么条件下动
  • 目标状态:到哪去

有时候还会带上守卫条件(Guard)——事件发生了,但还得满足某个条件才真的转移。比如“按键按下”事件,只有在“非锁定状态”下才触发转移。

动作(Action)——变的时候干什么

动作是转移发生时执行的代码。很多人把动作和状态搞混。记住一句话:状态是“等”,动作是“做”。

动作可以发生在三个时机:

  1. 进入动作(Entry Action):进入某个状态时执行一次
  2. 退出动作(Exit Action):离开某个状态时执行一次
  3. 转移动作(Transition Action):转移过程中执行

关键原则:动作不能阻塞状态机。我在一个电机控制项目里,把延时函数写进了进入动作,结果整个状态机卡死了。动作应该是轻量的、快速的。需要耗时操作?扔给任务调度器去处理。

状态转移图——一图胜千言

状态转移图是 FSM 最直观的表达方式。圆圈表示状态,箭头表示转移,箭头上的标签写“事件/动作”。

下面这张图展示了一个简单的按键消抖状态机。我当年做嵌入式键盘驱动时,就是靠这个结构搞定的。

按键消抖有限状态机 IDLE 等待按键 DEBOUNCE 消抖计时中 PRESSED 按键已确认 按键按下 / 启动定时器 定时器超时 / 上报按键 按键释放 / 取消定时器 按键释放 / 触发单击事件 说明: IDLE 等待按键按下,进入 DEBOUNCE 开始 20ms 消抖计时。 若计时期间按键释放,回到 IDLE;若计时完成,进入 PRESSED 确认按键。 PRESSED 等待释放,释放后触发单击事件并回到 IDLE。

状态转移表——工程师的“查字典”

图虽然直观,但代码实现时,我更喜欢用状态转移表。它把每个状态和每个事件交叉起来,一目了然。查表比追着箭头看快多了。

下面是对应上面按键消抖状态机的转移表:

当前状态 事件 守卫条件 动作 下一状态
IDLE 按键按下 无 启动消抖定时器 DEBOUNCE
DEBOUNCE 定时器超时 无 上报按键事件 PRESSED
DEBOUNCE 按键释放 无 取消定时器 IDLE
PRESSED 按键释放 无 触发单击回调 IDLE
任意 系统复位 无 初始化硬件 IDLE

注意:转移表里一定要包含“未定义转移”的处理。我曾经在一个通信协议栈里漏掉了“连接断开”事件在“数据发送中”状态的处理,结果系统直接挂死。建议在代码里为每个状态加一个默认的“事件未处理”分支,至少打印个日志。

FSM 的两种实现风格

代码实现上,我见过两种主流风格,各有千秋:

1. 嵌套 switch-case

最直接的方式。外层 switch 状态,内层 switch 事件。适合状态少于 10 个的简单场景。

void fsm_run(fsm_t *fsm, event_t evt) {
    switch (fsm->state) {
        case IDLE:
            switch (evt) {
                case BUTTON_PRESS:
                    start_timer(20);
                    fsm->state = DEBOUNCE;
                    break;
                default:
                    break;
            }
            break;
        case DEBOUNCE:
            switch (evt) {
                case TIMEOUT:
                    report_key_press();
                    fsm->state = PRESSED;
                    break;
                case BUTTON_RELEASE:
                    cancel_timer();
                    fsm->state = IDLE;
                    break;
                default:
                    break;
            }
            break;
        // ... 其他状态
    }
}

2. 查表法

把转移表定义成数据结构,运行时查表执行。可维护性更好,适合状态多的场景。

typedef struct {
    state_t  src_state;
    event_t  trigger;
    state_t  dst_state;
    void     (*action)(void *param);
} transition_t;

transition_t g_trans_table[] = {
    {IDLE,      BUTTON_PRESS,   DEBOUNCE,  action_start_timer},
    {DEBOUNCE,  TIMEOUT,        PRESSED,   action_report_key},
    {DEBOUNCE,  BUTTON_RELEASE, IDLE,      action_cancel_timer},
    {PRESSED,   BUTTON_RELEASE, IDLE,      action_trigger_click},
    // 最后加一个哨兵
    {STATE_MAX, EVENT_MAX,      STATE_MAX, NULL},
};

void fsm_run(fsm_t *fsm, event_t evt) {
    for (int i = 0; g_trans_table[i].action != NULL; i++) {
        if (g_trans_table[i].src_state == fsm->state &&
            g_trans_table[i].trigger == evt) {
            g_trans_table[i].action(fsm->param);
            fsm->state = g_trans_table[i].dst_state;
            return;
        }
    }
    // 未找到转移,记录错误
    log_error("unhandled event %d in state %d", evt, fsm->state);
}

我个人的建议:产品代码里优先用查表法。为什么?因为转移表可以放在常量区(ROM),不占 RAM。而且加一个新状态或新事件,只需要改表,不用动逻辑代码。我在一个智能家居网关项目里,状态机有 20 多个状态,查表法让代码量减少了 60%。

小结

有限状态机没那么玄乎。记住四要素:状态、事件、转移、动作。画图用状态转移图,写代码用状态转移表。把这两个工具用好,大部分嵌入式状态逻辑都能搞定。