1. 状态机基础:从概念到实战

大家好,我是你们这门课的老朋友。今天咱们聊聊状态机。说实话,我做了十几年嵌入式,要是有人问我哪个设计模式最实用,我第一个想到的就是它。

状态机,说白了就是一套规则。它告诉系统:你现在在哪儿,遇到什么事该干什么,然后去哪儿。听起来简单吧?但就是这套规则,帮我在无数个项目里避免了逻辑混乱。

1.1 什么是状态机

先看一个生活中的例子。你按一下遥控器的电源键,电视从「关机」变成「开机」。再按一下,又从「开机」变回「关机」。这就是一个最简单的两状态状态机。

在嵌入式世界里,状态机就是一个抽象模型。它把系统行为拆成若干个稳定状态,然后定义好状态之间怎么跳转。我习惯把它想象成一张地图:每个状态是一个城市,事件就是连接城市的公路,动作就是你在城市里做的事。

核心定义:状态机 = 状态 + 事件 + 动作。三者缺一不可。

1.2 三要素详解

咱们一个一个说。这三个要素,我在项目里天天跟它们打交道。

1.2.1 状态(State)

状态就是系统在某个时刻的稳定存在形式。它代表系统正在做什么、等待什么。

举个例子,一个串口协议解析模块,可能有这些状态:

  • IDLE:空闲,等待帧头
  • HEADER:已收到帧头,正在收长度字段
  • DATA:正在收数据载荷
  • CHECK:正在收校验码
  • DONE:一帧收完,准备处理

每个状态里,系统只关心跟自己相关的事。比如在 IDLE 状态,我只关心下一个字节是不是帧头,别的统统忽略。这种关注点分离,是状态机最大的好处。

我的习惯:给状态命名时,用大写字母加下划线,比如 STATE_IDLE、STATE_HEADER。这样在代码里一眼就能认出是状态常量。

1.2.2 事件(Event)

事件是触发状态跳转的外部或内部信号。没有事件,状态机就是死的。

事件可以来自很多地方:

事件类型 举例 说明
外部硬件 按键按下、GPIO 电平变化 最常见的事件源
定时器 超时、周期性触发 用于超时保护、轮询
通信数据 收到一个字节、帧结束 协议解析的核心
内部信号 计算完成、错误发生 模块间通信

我记得有一次做智能家居网关,设备上报的数据包格式老变。后来我把每个协议字段的到达都定义成独立事件,状态机根据当前状态决定怎么处理。改协议?改状态表就行,主逻辑不用动。

1.2.3 动作(Action)

动作是进入状态、离开状态或跳转时执行的具体操作。它可以是:

  • 设置一个 GPIO 电平
  • 发送一帧数据
  • 启动一个定时器
  • 更新一个全局变量
  • 调用一个回调函数

动作分三种:

  1. 入口动作:进入状态时执行一次。比如进入「报警」状态时,拉高蜂鸣器引脚。
  2. 出口动作:离开状态时执行一次。比如离开「报警」状态时,拉低蜂鸣器引脚。
  3. 转移动作:跳转过程中执行。比如从「待机」跳到「运行」时,初始化传感器。

我曾经踩过的坑:把动作写在状态机主循环里,而不是状态跳转时。结果每次轮询都执行一遍动作,蜂鸣器响个不停。后来我强制自己:动作只跟状态跳转绑定,不在循环里写业务逻辑。

1.3 状态机的应用场景

说了这么多理论,咱们看看实际中怎么用。我挑三个最典型的场景。

1.3.1 按键消抖

按键按下时,机械触点会弹跳,产生多个电平跳变。如果不处理,一次按下会被误判成多次。

用状态机解决这个问题,干净利落:

// 按键消抖状态机
typedef enum {
    KEY_IDLE,       // 空闲
    KEY_PRESS_WAIT, // 等待稳定按下
    KEY_PRESSED,    // 已确认按下
    KEY_RELEASE_WAIT // 等待稳定释放
} KeyState_t;

KeyState_t keyState = KEY_IDLE;

void KeyFSM_Process(uint8_t rawLevel) {
    switch (keyState) {
        case KEY_IDLE:
            if (rawLevel == 0) { // 检测到低电平
                keyState = KEY_PRESS_WAIT;
                // 启动10ms定时器
            }
            break;
        case KEY_PRESS_WAIT:
            if (timerExpired) {
                if (rawLevel == 0) {
                    keyState = KEY_PRESSED;
                    KeyAction_OnPress(); // 执行按下动作
                } else {
                    keyState = KEY_IDLE; // 抖动,回到空闲
                }
            }
            break;
        // ... 其他状态类似
    }
}

你看,状态机把消抖逻辑拆成了几个清晰的状态。每个状态只关心一件事,代码可读性高,还容易调试。

1.3.2 协议解析

解析通信协议,是状态机的拿手好戏。不管是 UART、SPI 还是网络协议,本质都是字节流的状态机。

我做过一个 Modbus RTU 从站,协议帧格式是:地址(1B) + 功能码(1B) + 数据(NB) + CRC(2B)。状态机这样设计:

typedef enum {
    FRAME_IDLE,
    FRAME_ADDR,
    FRAME_FUNC,
    FRAME_DATA,
    FRAME_CRC1,
    FRAME_CRC2
} FrameState_t;

FrameState_t frameState = FRAME_IDLE;
uint8_t rxBuffer[256];
uint8_t rxIndex = 0;

void ModbusFSM_Process(uint8_t byte) {
    switch (frameState) {
        case FRAME_IDLE:
            if (byte == DEV_ADDR) {
                rxBuffer[0] = byte;
                rxIndex = 1;
                frameState = FRAME_FUNC;
            }
            break;
        case FRAME_FUNC:
            rxBuffer[1] = byte;
            // 根据功能码决定数据长度
            dataLen = GetDataLen(byte);
            rxIndex = 2;
            frameState = (dataLen > 0) ? FRAME_DATA : FRAME_CRC1;
            break;
        // ... 后续状态类似
    }
}

每个字节进来,状态机都知道该往哪儿放。出错?超时?直接回到 IDLE,干净。

1.3.3 任务调度

在裸机系统里,状态机也能当调度器用。我习惯把每个任务看作一个状态机,主循环轮询所有任务的状态机实例。

任务 状态 触发事件 动作
LED 闪烁 ON / OFF 定时器超时 翻转 GPIO
按键扫描 IDLE / DEBOUNCE / PRESSED GPIO 中断 / 定时器 更新按键状态
温度采集 IDLE / CONVERT / READY 定时器 / ADC 完成 启动转换 / 读取结果

这种架构的好处是:每个任务独立,互不干扰。加一个新任务?加一个状态机实例就行,不影响现有的。

1.4 本章知识体系

下面这张图,把本章的核心内容串起来了。你保存好,后面每章我都会画类似的图。

状态机基础 - 知识体系图 状态机基础 - 知识体系 状态机 (FSM) 状态 (State) 事件 (Event) 动作 (Action) 稳定存在形式 关注点分离 外部/内部信号 触发跳转 入口/出口/转移 具体操作 按键消抖 协议解析 任务调度 三要素 + 应用场景 = 状态机实战基础

1.5 本章小结

咱们这一章,把状态机的底子打好了。你记住三句话:

  • 状态是系统在哪儿,事件是发生了什么,动作是干什么。
  • 状态机让复杂逻辑变得可拆分、可理解、可调试。
  • 按键消抖、协议解析、任务调度,都是状态机的经典战场。

给你一个建议:从今天开始,试着把你手头项目里的一个复杂 if-else 逻辑,改写成状态机。不用多,一个就行。你试过之后,会回来感谢我的。