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 状态机设计的一般步骤
- 列出所有可能的状态:画个圆圈,每个圈代表一个状态
- 找出所有触发事件:哪些条件会导致状态变化?
- 画出状态转移图:用箭头连接状态,标注触发事件
- 确定每个状态的动作:进入状态后要做什么?
- 选择实现方式:switch-case还是函数指针表?
- 编写代码并测试:重点测试边界条件和异常路径
下面这张图,就是按键消抖状态机的完整状态转移图:
我的习惯:在写状态机代码之前,我一定会先在纸上画出状态转移图。哪怕只有3个状态,也要画。因为画图的过程就是梳理逻辑的过程,很多潜在的问题在画图阶段就能暴露出来。别嫌麻烦,这个习惯帮我省了无数调试时间。
状态机编程,说白了就是把复杂的时间逻辑,拆解成简单的空间逻辑。每个时刻,系统只在一个状态里,只关心这个状态下该做的事。这样写出来的代码,逻辑清晰、易于维护、不容易出bug。