4、状态机基础理论:什么是状态机?

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

我刚开始做嵌入式那会儿,写按键逻辑就是一堆 if-else 嵌套。按下、抬起、抖动、长按……代码越写越乱,最后自己都看不懂。后来才明白——状态机就是用来治这个病的。

4.1 状态机的本质

状态机(State Machine)是一种数学模型。它描述了一个对象在不同“状态”之间如何切换。你想想看,现实世界里到处都是状态机:

  • 电梯:停在1楼、正在上行、停在2楼、正在下行……
  • 红绿灯:红灯→绿灯→黄灯→红灯……
  • 按键:未按下、抖动中、已按下、抬起中……

在单片机里,状态机帮我们把“当前在做什么”和“下一步该做什么”拆开。代码结构清晰了,bug 自然就少了。

核心观点:状态机不是算法,是一种组织代码的思维方式。它把“条件判断”变成了“状态转移表”。

4.2 四要素:状态、事件、转移、动作

一个完整的状态机,离不开这四个东西。我习惯把它们叫做“状态机的四根柱子”:

要素 英文 一句话解释 按键消抖例子
状态 State 系统当前所处的稳定阶段 空闲态、消抖态、确认态
事件 Event 触发状态转移的外部或内部信号 检测到电平变化、定时器超时
转移 Transition 从一个状态到另一个状态的路径 空闲态→消抖态(当检测到下降沿)
动作 Action 进入/离开状态时执行的操作 启动定时器、更新按键标志位

嗯,这里要注意:动作不一定只在转移时发生。有些动作是“进入状态时执行一次”,有些是“在状态内持续执行”。这个区别后面写代码时会遇到。

4.3 状态机的两种常见模型

实际项目中,我们主要用两种:

  • Moore 型:输出只取决于当前状态。比如按键状态机里,“按键按下”这个输出只在“确认态”才为真。
  • Mealy 型:输出取决于当前状态 + 输入事件。比如串口接收时,收到特定字节才触发某个动作。

我个人习惯:嵌入式裸机里优先用 Moore 型。为什么?因为输出稳定,不容易出现毛刺。我在一个电机控制项目里吃过 Mealy 型的亏——事件和输出耦合太紧,调试时根本分不清是状态问题还是事件问题。

我的建议:新手先从 Moore 型入手。等把状态机玩熟了,再考虑 Mealy 型。别一上来就搞混合,容易翻车。

4.4 用流程图描述按键消抖状态机

光讲理论太干。咱们拿一个最经典的应用——按键消抖——来画张图。

按键按下时,金属触点会弹跳 5~20ms。如果不做消抖,一次按下可能被识别成好几次。状态机怎么处理?看下面这张 SVG 流程图:

按键消抖状态机流程图 按键消抖状态机流程图 S1: 空闲态 等待按键按下 S2: 消抖态 启动定时器,等待10ms S3: 确认态 按键已稳定按下 检测到下降沿 定时器超时,电平仍为低 定时器超时,电平已变高(抖动) 检测到上升沿(按键释放) 动作说明 进入消抖态 → 启动定时器 | 进入确认态 → 设置按键按下标志 | 回到空闲态 → 清除所有标志

这张图里,三个状态、三个事件、四条转移路径、三个动作,清清楚楚。你想想看,如果用 if-else 写这个逻辑,得嵌套多少层?

4.5 状态机在裸机中的实现思路

在 STM32 裸机里,状态机通常用 switch-case 或 函数指针数组 来实现。我个人更推荐 switch-case 起步,因为可读性好,调试时一眼就能看到所有状态。

一个典型的状态机骨架长这样:

typedef enum {
    STATE_IDLE,
    STATE_DEBOUNCE,
    STATE_CONFIRMED
} KeyState_t;

KeyState_t currentState = STATE_IDLE;
uint8_t keyPressed = 0;

void KeyFSM_Run(void) {
    uint8_t input = ReadKeyPin();

    switch (currentState) {
        case STATE_IDLE:
            if (input == 0) {          // 检测到下降沿
                StartTimer(10);        // 启动10ms定时器
                currentState = STATE_DEBOUNCE;
            }
            break;

        case STATE_DEBOUNCE:
            if (Timer_IsExpired()) {
                if (ReadKeyPin() == 0) {   // 电平仍然为低
                    keyPressed = 1;        // 确认按下
                    currentState = STATE_CONFIRMED;
                } else {
                    currentState = STATE_IDLE;  // 抖动,回到空闲
                }
            }
            break;

        case STATE_CONFIRMED:
            if (input == 1) {          // 检测到上升沿
                keyPressed = 0;        // 清除标志
                currentState = STATE_IDLE;
            }
            break;

        default:
            currentState = STATE_IDLE;
            break;
    }
}
我曾经踩过的坑:在消抖态里,定时器超时后一定要重新读取引脚电平,而不是用之前保存的值。因为抖动可能发生在定时器运行期间,之前的值已经不准了。这个 bug 我调了整整一个下午才找到。

4.6 状态机的设计原则

写多了状态机,我总结了几条铁律:

  • 状态要互斥:任何时候只能处于一个状态。别搞出“半空闲半消抖”这种怪胎。
  • 事件要明确:每个转移只能由一个事件触发。别让两个事件同时抢着转移。
  • 动作要干净:进入状态时做的事,离开状态时记得清理。不然状态切来切去,资源就泄漏了。
  • 状态图先画再写代码:我见过太多人上来就写 switch-case,写到一半发现状态漏了。先画图,再编码,省心省力。
一个小技巧:刚开始设计状态机时,可以用 Excel 画一张“状态转移表”。行是当前状态,列是事件,交叉格写下一个状态和动作。这张表画清楚了,代码就是照着填。

4.7 本章小结

状态机不是什么高深理论。它就是一套“当前状态 + 输入事件 → 下一个状态 + 执行动作”的规则表。按键消抖只是它的一个应用,后面你会看到状态机在菜单导航、通信协议解析、任务调度里大显身手。

嗯,记住四要素:状态、事件、转移、动作。先把这张图画明白,再动手写代码。顺序别搞反了。