3、状态机设计方法:状态转移图绘制、状态表设计、状态编码策略
好,咱们进入状态机设计的核心环节。说实话,很多新手工程师一上来就写代码,结果写到一半发现状态跳飞了,或者漏了某个条件。我个人的习惯是——先画图,再填表,最后才编码。这三步走稳了,代码基本不会出大问题。
3.1 状态转移图绘制
状态转移图,说白了就是给状态机画一张「地图」。每个状态是一个圆圈,箭头代表跳转方向,箭头旁边写上触发条件。
我在项目中遇到过最典型的例子:一个自动灌溉控制器,需要根据土壤湿度、时间、雨量传感器三个条件来决定是否浇水。如果直接写代码,逻辑会乱成一锅粥。但先画图,思路就清晰了。
画图时要注意几点:
- 每个状态必须有明确的出口——不能有状态卡死在那里
- 条件要互斥且完备——同一时刻只能触发一个跳转,且所有可能的情况都要覆盖
- 初始状态要标出来——通常用双圈或箭头指向
举个例子:一个简单的灌溉状态机有四个状态:IDLE(空闲)、CHECK(检测)、WATER(浇水)、ALARM(报警)。
从IDLE出发,当定时器到点 → 跳转到CHECK。CHECK检测到湿度低于阈值 → 跳转到WATER。WATER浇水时间到 → 跳回IDLE。如果检测到传感器故障 → 跳转到ALARM。
嗯,这里要注意:画图时别追求「一步到位」。我习惯先用铅笔在纸上画草稿,确认逻辑没问题了,再用工具画正式图。你想想看,改纸上的图比改代码快多了。
3.2 状态表设计
状态转移图画好了,下一步就是把它翻译成表格。状态表是连接「图形思维」和「代码思维」的桥梁。
状态表通常包含这几列:
| 当前状态 | 输入条件 | 下一状态 | 输出动作 |
|---|---|---|---|
| IDLE | 定时器到点 | CHECK | 启动传感器 |
| CHECK | 湿度 < 阈值 | WATER | 打开电磁阀 |
| CHECK | 湿度 >= 阈值 | IDLE | 无 |
| CHECK | 传感器故障 | ALARM | 触发蜂鸣器 |
| WATER | 浇水时间到 | IDLE | 关闭电磁阀 |
| ALARM | 手动复位 | IDLE | 关闭蜂鸣器 |
我建议你每画完一个状态转移图,就立刻把状态表填好。为什么?因为画图时容易漏掉一些边界条件,填表的时候就会暴露出来。比如上面这个例子,CHECK状态下如果传感器故障怎么办?画图时可能忘了画这个箭头,但填表时发现「传感器故障」这个条件没地方放,就知道漏了。
小技巧:状态表里每一行都是一个「原子跳转」。写代码时,每一行对应一个case分支。这样代码结构跟表格一一对应,后期维护时看表就能定位代码位置。
3.3 状态编码策略
状态编码,就是给每个状态分配一个二进制数字。别小看这一步,编码选错了,轻则浪费资源,重则出现「毛刺」导致系统误动作。
常见的编码策略有三种:
- 顺序编码(二进制编码):IDLE=00, CHECK=01, WATER=10, ALARM=11。优点是状态数少时最省寄存器。缺点是相邻状态跳转时可能有多位同时变化,产生毛刺。
- 独热编码(One-Hot):IDLE=0001, CHECK=0010, WATER=0100, ALARM=1000。每个状态只有一位为1。优点是译码简单、速度快、无毛刺。缺点是状态多时寄存器用量大。
- 格雷编码(Gray Code):相邻状态只有一位不同。适合状态按顺序跳转的场景,比如流水线控制。
避坑指南:我曾经在一个项目中用了顺序编码,结果状态从CHECK(01)跳转到WATER(10)时,两位同时变化,中间出现了短暂的11状态,触发了ALARM。后来改成独热编码,问题就解决了。所以如果你的状态机对安全性要求高,或者有组合逻辑直接读取状态值,我建议优先考虑独热编码。
对于灌溉系统这种场景,状态数通常不超过10个,我个人习惯用独热编码。虽然多用了几个寄存器,但换来的是稳定性和代码可读性,值了。
最后给个编码示例:
// 独热编码定义
#define STATE_IDLE 0x01 // 0001
#define STATE_CHECK 0x02 // 0010
#define STATE_WATER 0x04 // 0100
#define STATE_ALARM 0x08 // 1000
// 状态切换示例
uint8_t current_state = STATE_IDLE;
void state_machine_run(void) {
switch(current_state) {
case STATE_IDLE:
if(timer_expired) {
current_state = STATE_CHECK;
// 启动传感器
}
break;
case STATE_CHECK:
if(humidity < threshold) {
current_state = STATE_WATER;
// 打开电磁阀
} else if(sensor_fault) {
current_state = STATE_ALARM;
// 触发蜂鸣器
} else {
current_state = STATE_IDLE;
}
break;
// ... 其他状态处理
}
}
你看,代码结构跟状态表完全对应。以后改需求,先改表,再改代码,思路不会乱。这就是我反复强调的——先画图、再填表、最后编码,顺序别搞反了。