3. 状态机基础:有限状态机概念、状态/事件/转移三要素、状态图绘制方法
好,咱们今天聊聊状态机。说实话,我做了十几年嵌入式,最怕的就是那种「if-else 堆成山」的代码。你想想看,一个按键处理,按一下是启动,长按是设置,双击是切换模式……全写在中断里?迟早要出事的。
状态机,就是来解决这个问题的。它不是什么高深理论,说白了就是一种思考方式——把系统行为拆成「状态」和「状态之间的跳转」。我当年第一次用状态机重构一个红外遥控解码程序,代码量直接砍掉一半,bug 也少了很多。嗯,从那以后我就爱上了这种写法。
3.1 有限状态机到底是什么?
有限状态机,英文叫 Finite State Machine,简称 FSM。核心思想就一句话:系统在任何时刻,只能处于有限个状态中的一个。每个状态都定义了系统在该时刻的行为,而状态的切换由事件驱动。
举个例子,一个简单的台灯:
- 状态:关、弱光、强光
- 事件:按键按下
- 转移:关→弱光→强光→关
你看,就这么简单。三个状态,一个事件,一个循环。这就是一个完整的有限状态机。
核心要点:有限状态机的「有限」二字很重要。状态数量必须是可枚举的、有限的。你不能说「状态是温度值」,因为温度是连续的,那叫无限状态,没法用 FSM 处理。
我在项目中遇到过一位同事,非要把 ADC 采样值直接当状态用,结果状态图画出来有 4096 个状态……我看了半天,跟他说:「兄弟,你这不叫状态机,这叫查表法。」后来我们改成用阈值划分成「低温、正常、高温」三个状态,代码瞬间清爽了。
3.2 三要素:状态、事件、转移
任何一个有限状态机,都离不开这三个东西。我习惯把它们叫做「状态机三剑客」。
3.2.1 状态(State)
状态是系统在某个时刻的「快照」。它决定了系统接下来能做什么、不能做什么。比如:
- 空闲态:等待用户操作
- 测量态:正在采集数据
- 显示态:刷新屏幕
- 错误态:传感器异常
我个人习惯给每个状态一个枚举值,清晰明了:
typedef enum {
STATE_IDLE,
STATE_MEASURING,
STATE_DISPLAY,
STATE_ERROR,
STATE_COUNT // 用于边界检查
} system_state_t;
注意最后那个 STATE_COUNT,这是我踩坑踩出来的习惯。有了它,你可以在代码里加断言,防止数组越界。我曾经在一个项目里忘了加这个,结果状态表越界访问,系统跑着跑着就飞了……查了两天才找到原因。
3.2.2 事件(Event)
事件是触发状态转移的「导火索」。它可以是外部输入(按键、串口数据)、内部定时(超时、计数到达)、或者系统条件变化(缓冲区满、传感器就绪)。
事件的定义也要枚举化:
typedef enum {
EVENT_NONE,
EVENT_BUTTON_PRESS,
EVENT_TIMEOUT,
EVENT_DATA_READY,
EVENT_ERROR_OCCURRED
} event_t;
我的经验:事件要尽量「原子化」。一个事件只表达一个意思,不要搞复合事件。比如「按键按下并超时」这种,应该拆成两个独立事件,由状态机内部逻辑组合处理。否则你的状态图会乱成一锅粥。
3.2.3 转移(Transition)
转移就是「当前状态 + 事件 → 下一个状态」的映射关系。它通常包含三个信息:
- 源状态(从哪里来)
- 触发事件(为什么跳)
- 目标状态(到哪里去)
有时候还会带上「动作(Action)」,也就是转移发生时执行的操作。比如退出状态时关灯、进入状态时初始化硬件。
我一般用结构体来定义转移表:
typedef struct {
state_t src_state;
event_t trigger_event;
state_t dst_state;
void (*action)(void); // 转移时执行的动作
} transition_t;
然后整个状态机就变成了一张表:
transition_t g_trans_table[] = {
{STATE_IDLE, EVENT_BUTTON_PRESS, STATE_MEASURING, start_measurement},
{STATE_MEASURING, EVENT_DATA_READY, STATE_DISPLAY, update_display},
{STATE_MEASURING, EVENT_TIMEOUT, STATE_ERROR, handle_timeout},
{STATE_DISPLAY, EVENT_BUTTON_PRESS, STATE_IDLE, clear_display},
// ...
};
这种表驱动的方式,我用了快十年了。好处是:状态转移逻辑一目了然,新增状态或事件只需要加一行表项,不用改 if-else 结构。你想想看,如果哪天产品经理说「再加一个校准状态」,你只需要加两行表项,而不是去翻那个几百行的 switch-case。
3.3 状态图怎么画?
画状态图,我推荐用 UML 状态图的标准符号。别嫌麻烦,一张好的状态图,比一千行注释都有用。
3.3.1 基本符号
| 符号 | 含义 | 说明 |
|---|---|---|
| 圆角矩形 | 状态 | 内部写状态名称,如「空闲」 |
| 实心圆 | 初始状态 | 系统启动时所在的点 |
| 带边框的圆 | 终止状态 | 嵌入式里很少用,系统一般不终止 |
| 箭头 | 转移 | 上面标注触发事件,可加动作 |
3.3.2 画图步骤
我画状态图有个固定流程,分享给你:
- 列出所有状态——先不管转移,把系统可能处于的所有「稳定状态」写出来
- 确定初始状态——上电后系统进入哪个状态?画一个实心圆指向它
- 找出所有事件——哪些事情能改变系统行为?列出来
- 连接转移——对每个状态,问自己:在这个状态下,发生每个事件,系统应该去哪里?
- 检查完整性——有没有遗漏的事件?有没有状态永远跳不出去?
避坑指南:我曾经画过一个万用表的状态图,画到一半发现有个状态没有任何事件能跳出去——说白了就是死锁了。后来查代码,发现是某个转移条件写错了。所以画完图之后,一定要检查每个状态是否都有「出口」。没有出口的状态,就是一颗定时炸弹。
3.3.3 一个简单的万用表状态图示例
假设我们做一个简易万用表,功能只有:电压测量、电阻测量、关机。状态图大概长这样:
[初始]
|
v
+---------+
| 待机 |<---------+
+---------+ |
| |
[旋钮切换] [旋钮切换]
| |
v |
+---------+ +---------+
| 电压测量 | | 电阻测量 |
+---------+ +---------+
| |
[超时30秒] [超时30秒]
| |
+-------+-------+
|
v
+---------+
| 休眠 |
+---------+
|
[按键唤醒]
|
v
[待机]
你看,这个图里:
- 4 个状态:待机、电压测量、电阻测量、休眠
- 3 个事件:旋钮切换、超时、按键唤醒
- 每个状态都有明确的出口,没有死循环
画完图之后,我一般会对着图写代码。先写状态枚举,再写事件枚举,然后照着箭头一条一条填转移表。这样写出来的代码,基本不用调试就能跑。真的,我试过很多次。
3.4 小结
有限状态机不是什么玄学,它就是一套「分而治之」的思考工具。把复杂的行为拆成有限的状态,把混乱的逻辑变成清晰的转移表。你想想看,一个万用表可能有几十种功能、上百种操作组合,但如果你用状态机来设计,核心代码可能只需要几百行。
下一节,我会带你手写一个完整的万用表状态机框架。到时候你会发现,状态机写起来比想象中简单得多——前提是你把状态图画清楚了。
课后小练习:试着画一下你家电视遥控器的状态图。就三个键:电源、音量+、音量-。看看你能画出几个状态?提示:别忘了「静音」这个隐藏状态。