一、状态机基础:从生活到代码的思维模型
说实话,状态机这个概念,我第一次接触是在大学做单片机实验的时候。当时老师让我们做一个按键控制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 状态机的知识体系
下面这张图是我自己总结的状态机知识体系,涵盖了从基础概念到实际应用的完整链路。你可以把它当作学习路线图。
1.5 什么时候该用状态机?
不是所有场景都需要状态机。我个人总结了一个判断标准:
- 适合用状态机: 系统行为可以拆成几个明确的阶段,每个阶段对输入的反应不同。比如按键有「空闲-消抖-按下-消抖-松开」五个阶段。
- 不适合用状态机: 逻辑是纯线性的,没有分支和循环。比如计算一个数学公式,用函数就够了,没必要上状态机。
嗯,这里再补充一点:状态机的状态数量最好控制在10个以内。超过10个状态,代码的可读性会急剧下降。如果你发现状态超过10个,考虑用层次状态机或者把大状态机拆成几个小状态机。
一句话总结: 状态机就是把「混乱的if-else」变成「有序的状态转换」。它不会让你的代码变少,但会让你的代码变「聪明」——每个状态只关心自己该做的事,不会越界。
好了,这一章的基础概念就讲到这里。下一章我们会深入状态机的实现方式,从最简单的switch-case到高级的函数指针表,我会把每种方式的优缺点和适用场景都讲清楚。