一、状态机基础:从生活到代码的思维模型

说实话,状态机这个概念,我第一次接触是在大学做单片机实验的时候。当时老师让我们做一个按键控制LED的程序,我写了一大堆if-else,结果逻辑乱成一团。老师看了一眼说:「小伙子,你该学学状态机了。」

那什么是状态机?说白了,它就是一套描述「系统在不同时刻处于什么状态、遇到什么事件会怎么变化」的规则。你想想看,我们生活中到处都是状态机——红绿灯、电梯、自动售货机,甚至你早上起床的过程,都是一个状态机。

1.1 状态机的三要素:状态、事件、动作

我个人习惯把状态机拆成三个核心部件来理解。这三个东西搞明白了,状态机就学会了一半。

  • 状态(State):系统在某个时刻的「快照」。比如按键是按下还是松开,协议解析是在等待帧头还是等待数据。
  • 事件(Event):触发状态变化的东西。可能是定时器超时、引脚电平变化、收到一个字节的数据。
  • 动作(Action):状态变化时执行的操作。比如点亮LED、保存数据、启动定时器。

嗯,这里要注意:动作不一定只在状态切换时发生。有些状态机允许在「进入状态时」或「离开状态时」执行动作,这取决于你的设计需求。

核心公式: 当前状态 + 事件 → 下一个状态 + 动作

这个公式,我建议你贴在工位上。每次写状态机逻辑时,对着它检查一遍,能避免80%的bug。

1.2 状态机的分类:有限状态机(FSM)

我们常说的状态机,全称是有限状态机(Finite State Machine, FSM)。「有限」两个字很关键——状态的数量是确定的、有限的。你不能让系统无限地产生新状态,那叫失控。

FSM有两种经典模型:

模型 特点 典型应用
Moore型 输出只取决于当前状态 按键消抖、LED控制
Mealy型 输出取决于当前状态 + 输入事件 协议解析、数据流处理

我在项目中遇到过一个问题:用Moore型做串口协议解析,结果发现每次状态切换后要等一个时钟周期才能输出正确数据。后来换成Mealy型,在事件触发的瞬间就输出,问题就解决了。所以选型很重要,别一上来就抄网上的代码。

1.3 状态机的应用场景

你可能会问:状态机到底能解决什么问题?我挑三个最常见的场景聊聊。

场景一:按键消抖

按键消抖是嵌入式入门的第一道坎。机械按键按下和松开时,会产生几毫秒到几十毫秒的抖动。用延时消抖?太浪费CPU了。用状态机?优雅又高效。

我早期做的一个项目,按键控制电机的启停。一开始用延时消抖,电机响应慢得像蜗牛。后来改成状态机消抖,响应速度提升了3倍,而且代码结构清晰多了。

// 按键消抖状态机示例
typedef enum {
    KEY_IDLE,       // 空闲状态
    KEY_PRESS_DEB,  // 按下消抖
    KEY_PRESSED,    // 确认按下
    KEY_RELEASE_DEB // 松开消抖
} KeyState;

KeyState key_state = KEY_IDLE;

void key_fsm(uint8_t key_level) {
    switch (key_state) {
        case KEY_IDLE:
            if (key_level == 0) {  // 检测到按下
                key_state = KEY_PRESS_DEB;
                start_timer(10);   // 启动10ms定时器
            }
            break;
        case KEY_PRESS_DEB:
            if (timer_expired()) {
                if (key_level == 0) {
                    key_state = KEY_PRESSED;
                    key_action_press();  // 执行按下动作
                } else {
                    key_state = KEY_IDLE;  // 抖动,回到空闲
                }
            }
            break;
        // ... 其他状态类似
    }
}

避坑指南: 我曾经在按键消抖中犯过一个低级错误——消抖时间设得太短(2ms),结果按键还是抖动。后来查资料才知道,机械按键的抖动时间一般在5-20ms,建议设10-15ms。另外,定时器不要用阻塞延时,用硬件定时器或系统滴答计时。

场景二:协议解析

协议解析是状态机的「主场」。不管是UART、I2C、SPI,还是自定义的通信协议,用状态机来解析数据帧,逻辑清晰、不易出错。

我记得有一次做Modbus协议解析,对方发来的数据包长度不定、校验方式复杂。如果用逐字节判断的if-else写法,代码会膨胀到几百行,而且调试起来非常痛苦。用状态机后,整个解析逻辑压缩到50行以内,每个状态只做一件事,bug一眼就能看出来。

// 简单帧协议解析状态机
typedef enum {
    FRAME_WAIT_HEAD,  // 等待帧头
    FRAME_GET_LEN,    // 获取长度
    FRAME_GET_DATA,   // 获取数据
    FRAME_CHECK_CRC   // 校验CRC
} FrameState;

FrameState frame_state = FRAME_WAIT_HEAD;

void frame_parser(uint8_t byte) {
    switch (frame_state) {
        case FRAME_WAIT_HEAD:
            if (byte == 0xAA) {  // 帧头
                frame_state = FRAME_GET_LEN;
            }
            break;
        case FRAME_GET_LEN:
            frame_len = byte;
            frame_index = 0;
            frame_state = FRAME_GET_DATA;
            break;
        // ... 后续状态
    }
}

场景三:任务调度

在裸机系统中,任务调度也可以用状态机来实现。每个任务就是一个状态机,系统根据优先级或时间片来切换任务状态。

我曾经在一个资源受限的MCU上做多任务调度,没有RTOS,就用状态机硬扛。每个任务维护自己的状态,主循环里轮询所有任务的状态机。效果还不错,至少没有出现任务饿死的情况。

注意: 用状态机做任务调度时,每个任务的执行时间必须可控。如果一个任务在某个状态里卡住了(比如等待外设响应),整个系统都会停摆。我建议每个状态机都加一个超时检测,防止死锁。

1.4 状态机的知识体系

下面这张图是我自己总结的状态机知识体系,涵盖了从基础概念到实际应用的完整链路。你可以把它当作学习路线图。

状态机知识体系 三要素 状态 · 事件 · 动作 分类 Moore型 · Mealy型 应用场景 按键消抖 · 协议解析 · 任务调度 实现方式 switch-case 函数指针表 状态表驱动 层次状态机 设计原则 状态唯一性 事件驱动 动作原子性 超时保护

1.5 什么时候该用状态机?

不是所有场景都需要状态机。我个人总结了一个判断标准:

  • 适合用状态机: 系统行为可以拆成几个明确的阶段,每个阶段对输入的反应不同。比如按键有「空闲-消抖-按下-消抖-松开」五个阶段。
  • 不适合用状态机: 逻辑是纯线性的,没有分支和循环。比如计算一个数学公式,用函数就够了,没必要上状态机。

嗯,这里再补充一点:状态机的状态数量最好控制在10个以内。超过10个状态,代码的可读性会急剧下降。如果你发现状态超过10个,考虑用层次状态机或者把大状态机拆成几个小状态机。

一句话总结: 状态机就是把「混乱的if-else」变成「有序的状态转换」。它不会让你的代码变少,但会让你的代码变「聪明」——每个状态只关心自己该做的事,不会越界。

好了,这一章的基础概念就讲到这里。下一章我们会深入状态机的实现方式,从最简单的switch-case到高级的函数指针表,我会把每种方式的优缺点和适用场景都讲清楚。


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