3. 状态机编程基础:有限状态机(FSM)概念,switch-case实现,函数指针表实现,状态机在按键消抖中的应用

状态机这东西,说白了就是让单片机学会“看情况办事”。

我刚开始做嵌入式那会儿,写程序就是一条路走到黑——按键检测就死循环读IO,LED控制就硬塞延时。结果呢?按键一抖,程序就乱跳;任务一多,逻辑就拧成麻花。后来我才明白,没有状态机思维的程序,就像没有红绿灯的路口——早晚得出事。

3.1 有限状态机(FSM)核心概念

有限状态机,英文叫Finite State Machine,简称FSM。它有三个核心要素:

  • 状态(State):系统当前所处的“模式”或“阶段”
  • 事件(Event):触发状态切换的外部或内部条件
  • 动作(Action):进入某个状态后执行的操作

举个例子,一个简单的台灯:

  • 状态:关灯、弱光、强光
  • 事件:按键按下
  • 动作:点亮/熄灭LED、调节亮度

你想想看,这不就是咱们日常写单片机程序最常见的场景吗?

FSM三要素速记表

要素 含义 单片机中的例子
状态 系统当前所处的稳定阶段 按键空闲、按键按下、按键释放
事件 触发状态切换的条件 IO电平变化、定时器超时、串口收到数据
动作 进入状态后执行的操作 读取ADC、发送数据、切换LED

3.2 switch-case实现:最直观的状态机

我个人习惯,在项目初期或者状态数量不多(比如少于10个)的时候,直接用switch-case。为什么?因为代码可读性最好,调试的时候一眼就能看出当前在哪个状态。

来看一个按键检测的简单例子:

// 按键状态枚举
typedef enum {
    KEY_STATE_IDLE,      // 空闲状态
    KEY_STATE_PRESSED,   // 按下状态
    KEY_STATE_RELEASED   // 释放状态
} KeyState_t;

KeyState_t currentState = KEY_STATE_IDLE;

void KeyFSM_Process(uint8_t keyLevel) {
    switch (currentState) {
        case KEY_STATE_IDLE:
            if (keyLevel == 0) {  // 检测到低电平(按键按下)
                currentState = KEY_STATE_PRESSED;
                // 这里可以启动一个定时器,用于消抖
            }
            break;

        case KEY_STATE_PRESSED:
            if (keyLevel == 1) {  // 检测到高电平(按键释放)
                currentState = KEY_STATE_RELEASED;
                // 执行按键动作
                KeyAction_Execute();
            }
            break;

        case KEY_STATE_RELEASED:
            // 释放状态只停留一个周期,然后回到空闲
            currentState = KEY_STATE_IDLE;
            break;

        default:
            // 异常状态处理
            currentState = KEY_STATE_IDLE;
            break;
    }
}

我的经验:switch-case状态机里,每个case后面一定要加break。我曾经在一个项目里漏了一个break,结果状态从IDLE直接穿透到了PRESSED,按键逻辑全乱套了。排查了整整一个下午才找到问题——从那以后,我写switch-case都会先检查break。

3.3 函数指针表实现:高性能状态机

当状态数量超过10个,或者状态切换逻辑特别复杂的时候,switch-case就开始显得臃肿了。这时候,我推荐用函数指针表。

函数指针表的核心思想是:把每个状态的处理函数存到一个数组里,用状态编号直接索引调用。这样就没有了switch-case的跳转开销,执行效率更高。

// 定义状态处理函数类型
typedef void (*StateHandler_t)(void);

// 声明各个状态的处理函数
void State_Idle(void);
void State_Pressed(void);
void State_Released(void);

// 函数指针表
StateHandler_t stateTable[] = {
    State_Idle,      // 索引0对应KEY_STATE_IDLE
    State_Pressed,   // 索引1对应KEY_STATE_PRESSED
    State_Released   // 索引2对应KEY_STATE_RELEASED
};

// 当前状态索引
uint8_t currentStateIndex = 0;

// 状态机主循环
void FSM_Run(void) {
    // 直接通过索引调用对应的处理函数
    stateTable[currentStateIndex]();
}

// 各个状态的具体实现
void State_Idle(void) {
    if (ReadKeyLevel() == 0) {
        currentStateIndex = 1;  // 切换到按下状态
    }
}

void State_Pressed(void) {
    // 消抖处理...
    if (ReadKeyLevel() == 1) {
        currentStateIndex = 2;  // 切换到释放状态
    }
}

void State_Released(void) {
    KeyAction_Execute();
    currentStateIndex = 0;  // 回到空闲状态
}

注意:函数指针表虽然高效,但调试起来不如switch-case直观。你没法在调试器里直接看到当前执行到哪个case。我的建议是:项目初期用switch-case快速验证逻辑,后期优化性能时再改成函数指针表。

3.4 两种实现方式的对比

对比维度 switch-case实现 函数指针表实现
代码可读性 高,逻辑一目了然 中等,需要理解函数指针
执行效率 中等,有跳转开销 高,直接索引调用
扩展性 差,新增状态要改switch结构 好,只需添加函数和表项
调试友好度 高,单步执行清晰 低,需要跟踪函数指针
适用场景 状态数<10,逻辑简单 状态数≥10,追求性能

3.5 状态机在按键消抖中的应用

按键消抖,是状态机最经典的应用场景之一。机械按键在按下和释放的瞬间,会产生电平抖动——你明明只按了一次,单片机可能检测到几十次跳变。

传统做法是用延时消抖:检测到电平变化后,延时10-20ms再读一次。但延时会阻塞CPU,在实时系统中是大忌。

用状态机消抖,就能做到非阻塞、零延时。

来看一个完整的按键消抖状态机:

typedef enum {
    KEY_DEBOUNCE_IDLE,       // 空闲
    KEY_DEBOUNCE_SHAKE,      // 抖动中
    KEY_DEBOUNCE_CONFIRM,    // 确认按下
    KEY_DEBOUNCE_RELEASE     // 等待释放
} DebounceState_t;

DebounceState_t debounceState = KEY_DEBOUNCE_IDLE;
uint16_t debounceTimer = 0;

void KeyDebounceFSM(uint8_t keyLevel) {
    switch (debounceState) {
        case KEY_DEBOUNCE_IDLE:
            if (keyLevel == 0) {  // 检测到低电平
                debounceState = KEY_DEBOUNCE_SHAKE;
                debounceTimer = 0;  // 启动消抖计时
            }
            break;

        case KEY_DEBOUNCE_SHAKE:
            debounceTimer++;
            if (debounceTimer >= 10) {  // 10ms消抖时间
                if (keyLevel == 0) {
                    // 10ms后仍然是低电平,确认按下
                    debounceState = KEY_DEBOUNCE_CONFIRM;
                    KeyAction_Execute();  // 执行按键动作
                } else {
                    // 10ms内电平变高了,说明是抖动
                    debounceState = KEY_DEBOUNCE_IDLE;
                }
            }
            break;

        case KEY_DEBOUNCE_CONFIRM:
            if (keyLevel == 1) {  // 等待按键释放
                debounceState = KEY_DEBOUNCE_RELEASE;
                debounceTimer = 0;
            }
            break;

        case KEY_DEBOUNCE_RELEASE:
            debounceTimer++;
            if (debounceTimer >= 5) {  // 释放后等待5ms
                if (keyLevel == 1) {
                    debounceState = KEY_DEBOUNCE_IDLE;  // 回到空闲
                } else {
                    // 释放过程中又有抖动,回到确认状态
                    debounceState = KEY_DEBOUNCE_CONFIRM;
                }
            }
            break;
    }
}

避坑指南:我曾经在一个产品里,消抖时间设成了5ms,结果用户反映按键偶尔会误触发。后来用示波器一抓,发现那个按键的抖动时间最长有8ms。所以消抖时间要根据实际按键的电气特性来调整,一般建议10-20ms。别偷懒,拿示波器测一下再定参数。

3.6 状态机设计的一般步骤

  1. 列出所有可能的状态:画个圆圈,每个圈代表一个状态
  2. 找出所有触发事件:哪些条件会导致状态变化?
  3. 画出状态转移图:用箭头连接状态,标注触发事件
  4. 确定每个状态的动作:进入状态后要做什么?
  5. 选择实现方式:switch-case还是函数指针表?
  6. 编写代码并测试:重点测试边界条件和异常路径

下面这张图,就是按键消抖状态机的完整状态转移图:

按键消抖状态机状态转移图 空闲 (IDLE) 抖动 (SHAKE) 确认 (CONFIRM) 释放 (RELEASE) 检测到低电平 10ms内电平变高(抖动) 10ms后仍为低电平 检测到高电平 释放后5ms稳定 释放过程中有抖动 说明:每个状态都对应一个稳定的系统行为,事件驱动状态切换,动作在进入状态时执行

我的习惯:在写状态机代码之前,我一定会先在纸上画出状态转移图。哪怕只有3个状态,也要画。因为画图的过程就是梳理逻辑的过程,很多潜在的问题在画图阶段就能暴露出来。别嫌麻烦,这个习惯帮我省了无数调试时间。

状态机编程,说白了就是把复杂的时间逻辑,拆解成简单的空间逻辑。每个时刻,系统只在一个状态里,只关心这个状态下该做的事。这样写出来的代码,逻辑清晰、易于维护、不容易出bug。