3. 状态机设计模式:单次触发、循环扫描、层次状态机(HSM)

状态机这东西,说白了就是让单片机知道「现在该干嘛」。我见过太多工程师把逻辑写成一锅粥——一个 switch-case 里塞了上百行,改一个功能崩三个地方。其实状态机就那么几种套路,选对了,功耗和可维护性都能上一个台阶。

今天咱们就聊聊三种最常用的模式:单次触发、循环扫描、还有层次状态机。每种我都踩过坑,咱们边看代码边聊。

3.1 单次触发状态机

这种模式最适合「按一下就执行一次」的场景。比如按键消抖、单次传感器采集、或者一次性配置外设。

它的核心逻辑是:事件来了,状态跳转,执行动作,然后停在终态。不会自己循环,除非外部再次触发。

典型应用场景:

  • 按键短按/长按识别
  • 单次 ADC 转换
  • 初始化序列
  • 错误恢复流程

我举个例子。以前做一款智能门锁,按键唤醒后需要执行「检测按下 → 消抖 → 确认有效 → 触发开锁」这个序列。用单次触发状态机,代码干净得像白纸。

typedef enum {
    STATE_IDLE,
    STATE_DEBOUNCE,
    STATE_CONFIRM,
    STATE_TRIGGER,
    STATE_DONE
} KeyState_t;

KeyState_t state = STATE_IDLE;

void KeyFSM_SingleShot(Event_t event) {
    switch (state) {
        case STATE_IDLE:
            if (event == KEY_PRESSED) {
                startDebounceTimer(10);  // 10ms 消抖
                state = STATE_DEBOUNCE;
            }
            break;
        case STATE_DEBOUNCE:
            if (event == TIMEOUT) {
                if (readKey() == PRESSED) {
                    state = STATE_CONFIRM;
                } else {
                    state = STATE_IDLE;  // 抖动,回到空闲
                }
            }
            break;
        case STATE_CONFIRM:
            if (event == KEY_RELEASED) {
                state = STATE_TRIGGER;
            }
            break;
        case STATE_TRIGGER:
            unlockDoor();
            state = STATE_DONE;
            break;
        case STATE_DONE:
            // 等待外部复位或下次触发
            break;
    }
}

我的习惯:单次触发状态机里,终态一定要明确。我曾经漏写 STATE_DONE 的处理,结果状态机跑飞了,门锁反复开合——嗯,那次被测试同事追着骂了一周。

3.2 循环扫描状态机

这是嵌入式里最常见的模式。主循环里不断扫描状态,每个状态执行一小块任务,然后跳转到下一个状态。适合周期性任务,比如 LED 呼吸灯、通信协议解析、或者多步传感器校准。

它的特点是:状态机自己会跑,不需要外部事件驱动。每个循环周期,状态机前进一次。

特性 单次触发 循环扫描
触发方式 外部事件 定时/主循环
执行次数 一次 无限循环
典型周期 毫秒级 微秒~毫秒级
功耗特点 空闲时可深度睡眠 需周期性唤醒
适用场景 按键、单次采集 LED 效果、协议解析

来看一个呼吸灯的例子。LED 从暗到亮再到暗,循环往复。用循环扫描状态机,每个状态只做一件事:调整 PWM 占空比。

typedef enum {
    RAMP_UP,
    RAMP_DOWN,
    HOLD_ON,
    HOLD_OFF
} BreathState_t;

BreathState_t breathState = RAMP_UP;
uint16_t pwmDuty = 0;

void BreathFSM_Cyclic(void) {
    switch (breathState) {
        case RAMP_UP:
            pwmDuty += 5;
            if (pwmDuty >= 1000) {
                pwmDuty = 1000;
                breathState = HOLD_ON;
            }
            break;
        case HOLD_ON:
            holdCounter++;
            if (holdCounter >= 50) {
                holdCounter = 0;
                breathState = RAMP_DOWN;
            }
            break;
        case RAMP_DOWN:
            pwmDuty -= 5;
            if (pwmDuty <= 0) {
                pwmDuty = 0;
                breathState = HOLD_OFF;
            }
            break;
        case HOLD_OFF:
            holdCounter++;
            if (holdCounter >= 30) {
                holdCounter = 0;
                breathState = RAMP_UP;
            }
            break;
    }
    setPWMDuty(pwmDuty);
}

避坑指南:循环扫描状态机最怕「死循环状态」。我曾经在 HOLD_ON 里忘了递增计数器,LED 直接卡在最高亮度——功耗从 5mA 飙到 50mA,电池半天就废了。记住:每个状态要么改变条件,要么有超时保护。

3.3 层次状态机(HSM)

这个就高级一点了。当状态多到一定程度,你会发现很多状态有「共同行为」。比如一个通信协议栈:空闲态、接收态、发送态、错误态——每个态都可能需要处理「超时」和「复位」这两个事件。

如果每个状态都写一遍超时处理,代码量翻倍不说,改一个超时逻辑得改 N 个地方。层次状态机的思路就是:把公共行为提取到父状态,子状态只关心自己的特殊逻辑。

HSM 的核心思想:

  • 父状态处理通用事件(超时、复位、错误)
  • 子状态处理具体事件(收到数据、发送完成)
  • 事件从子状态向上冒泡,直到被处理

我画了一张图,帮你理解这个层级关系。

层次状态机(HSM)结构图 父状态:通信状态 (CommState) 处理:超时、复位、错误计数 空闲态 (Idle) 等待起始条件 低功耗休眠 事件:收到同步头 接收态 (Receive) 逐字节接收数据 CRC 校验 事件:字节到达 发送态 (Transmit) 发送缓冲区数据 等待发送完成 事件:发送完成 错误态 (Error) 记录错误码 尝试重连 事件:超时/CRC错误 说明:子状态未处理的事件(如超时)自动冒泡到父状态处理

看明白了吗?父状态 CommState 处理「超时」和「复位」,四个子状态都不用再写一遍。代码实现上,我习惯用函数指针表来做事件冒泡。

typedef struct StateNode {
    void (*entry)(void);
    void (*exit)(void);
    uint8_t (*handleEvent)(Event_t event);
    struct StateNode *parent;
} StateNode_t;

uint8_t HSM_Dispatch(StateNode_t *current, Event_t event) {
    StateNode_t *node = current;
    while (node != NULL) {
        if (node->handleEvent(event)) {
            return 1;  // 事件被处理
        }
        node = node->parent;  // 冒泡到父状态
    }
    return 0;  // 无人处理
}

我的经验:HSM 的父状态不要超过三层。我曾经在一个项目里堆了五层,结果调试时自己都搞不清事件冒泡到哪了。三层以内,逻辑清晰,代码可读性最好。

3.4 三种模式怎么选?

你想想看,选模式其实就三个问题:

  1. 这个任务需要重复执行吗? 不需要 → 单次触发;需要 → 循环扫描或 HSM
  2. 状态数量多吗? 少于 5 个 → 循环扫描就够了;多于 5 个 → 考虑 HSM
  3. 有没有公共行为? 有 → HSM;没有 → 循环扫描
判断条件 推荐模式 理由
一次性任务,状态少 单次触发 代码简单,功耗最低
周期性任务,状态少 循环扫描 实现直接,容易调试
状态多,有公共逻辑 层次状态机 复用代码,减少重复
状态多,无公共逻辑 循环扫描 + 表驱动 避免 HSM 的复杂度

最后提醒一句:别为了用 HSM 而用 HSM。我见过有人把两个状态的状态机硬写成 HSM,结果代码量翻了三倍,还多了一堆 bug。简单就是美,够用就好。