第4章:状态表设计——状态表的构成、状态转移表、输出表的设计方法
好,咱们进入状态机设计的核心环节——状态表。说实话,很多初学者喜欢一上来就画状态图,画得挺漂亮,但一到写代码就卡壳。我个人习惯是:先画图理清思路,再用表格把逻辑固化下来。表格这东西,看着枯燥,但它是从“想法”到“代码”最可靠的桥梁。
4.1 状态表的构成
状态表,说白了就是一张“如果…那么…”的对照表。它由三大部分组成:
- 当前状态:机器现在处于哪个状态
- 输入条件:触发状态变化的事件或信号
- 下一状态:满足条件后,机器要跳到哪里去
嗯,这里要注意,输出表有时候是单独拎出来的,有时候和状态转移表合并在一起。这取决于你的输出是摩尔型还是米利型。我待会儿会细讲。
举个例子,一个简单的交通灯控制器,它的状态表长这样:
| 当前状态 | 输入(定时器超时) | 下一状态 |
|---|---|---|
| 红灯 | 是 | 绿灯 |
| 绿灯 | 是 | 黄灯 |
| 黄灯 | 是 | 红灯 |
你看,三行就把逻辑说清楚了。我在项目中遇到过有人画了十几页的状态图,最后自己都看不懂。用表格整理一下,立马清爽。
4.2 状态转移表的设计方法
状态转移表,重点在“转移”二字。它要回答的问题是:在什么条件下,从哪来到哪去?
设计步骤我总结为三步:
- 列出所有状态:给每个状态起个名字,比如 S0、S1、S2,或者更有语义的 IDLE、BUSY、DONE。
- 列出所有输入组合:把每个输入信号的可能取值都列出来。注意,如果有多个输入,要列出所有组合,别漏了。
- 填充下一状态:对于每一行,决定当前状态+输入组合下,下一状态是什么。
来看一个更完整的例子——一个简单的按键消抖状态机。它有四个状态:
- IDLE:等待按键按下
- DEBOUNCE_PRESS:检测到按下,开始延时消抖
- PRESSED:确认按下,输出有效信号
- DEBOUNCE_RELEASE:检测到释放,开始延时消抖
状态转移表如下:
| 当前状态 | 输入(key_in) | 定时器超时 | 下一状态 |
|---|---|---|---|
| IDLE | 0 | - | IDLE |
| IDLE | 1 | - | DEBOUNCE_PRESS |
| DEBOUNCE_PRESS | - | 否 | DEBOUNCE_PRESS |
| DEBOUNCE_PRESS | - | 是 | PRESSED |
| PRESSED | 1 | - | PRESSED |
| PRESSED | 0 | - | DEBOUNCE_RELEASE |
| DEBOUNCE_RELEASE | - | 否 | DEBOUNCE_RELEASE |
| DEBOUNCE_RELEASE | - | 是 | IDLE |
你想想看,这个表里有个细节:在 DEBOUNCE_PRESS 状态下,我用了“-”表示输入无关。为什么?因为消抖期间我们只关心定时器,不关心按键电平的抖动。这就是设计经验——明确哪些输入是“无关项”,可以大大简化表格。
4.3 输出表的设计方法
输出表,就是定义“在每个状态下,输出什么信号”。这里要分两种情况:
- 摩尔型状态机:输出只取决于当前状态。输出表很简单,每个状态对应一组输出值。
- 米利型状态机:输出取决于当前状态+输入。输出表要列出所有状态和输入组合下的输出值。
我个人更偏爱摩尔型,因为它的输出稳定,没有毛刺。但米利型在某些场景下更省状态,响应也更快。怎么选?看你的应用场景。
还是用按键消抖的例子,它的输出表(摩尔型)如下:
| 当前状态 | 输出(key_pressed) | 输出(debounce_busy) |
|---|---|---|
| IDLE | 0 | 0 |
| DEBOUNCE_PRESS | 0 | 1 |
| PRESSED | 1 | 0 |
| DEBOUNCE_RELEASE | 0 | 1 |
你看,输出表就是一张“状态→输出”的映射表。写代码的时候,直接用一个 case 语句或者查表法就能实现。
4.4 从状态表到代码的映射
有了状态表,写代码就是体力活了。这里给一个简单的 C 语言框架,你感受一下:
// 状态枚举
typedef enum {
IDLE,
DEBOUNCE_PRESS,
PRESSED,
DEBOUNCE_RELEASE
} state_t;
// 状态转移表(用二维数组或查表法)
state_t next_state[4][2] = {
/* IDLE: */ {IDLE, DEBOUNCE_PRESS},
/* DEBOUNCE_PRESS: */ {DEBOUNCE_PRESS, PRESSED},
/* PRESSED: */ {DEBOUNCE_RELEASE, PRESSED},
/* DEBOUNCE_RELEASE:*/ {IDLE, DEBOUNCE_RELEASE}
};
// 输出表
uint8_t output_key_pressed[4] = {0, 0, 1, 0};
uint8_t output_debounce_busy[4] = {0, 1, 0, 1};
// 状态机主循环
void state_machine_run(void) {
state_t current = IDLE;
while(1) {
uint8_t input = read_key();
uint8_t timeout = read_timer_timeout();
// 查转移表
current = next_state[current][input | (timeout << 1)];
// 查输出表
set_key_pressed(output_key_pressed[current]);
set_debounce_busy(output_debounce_busy[current]);
delay(10); // 简单轮询
}
}
这段代码里,我用了查表法,而不是一堆 if-else。为什么?因为查表法性能高、易维护。你想想看,如果以后要加一个新状态,只需要在表格里加一行,不用改逻辑代码。这就是状态表设计的威力——把逻辑和数据分离。
4.5 总结一下
状态表设计,说白了就是三件事:
- 列出所有状态和输入
- 填好“下一状态”这一列
- 填好“输出”这一列
嗯,就这么简单。但越是简单的东西,越容易在细节上翻车。我建议你每设计完一张状态表,都拿几个典型场景手动走一遍,看看有没有死循环、有没有漏掉的条件。这一步花不了几分钟,但能省下后面几天的调试时间。
下一章,咱们聊聊状态机的编码方式——二进制编码、格雷码、独热码,到底该怎么选。到时候我会结合一个实际项目中的坑来聊,敬请期待。