第4章:状态表设计——状态表的构成、状态转移表、输出表的设计方法

好,咱们进入状态机设计的核心环节——状态表。说实话,很多初学者喜欢一上来就画状态图,画得挺漂亮,但一到写代码就卡壳。我个人习惯是:先画图理清思路,再用表格把逻辑固化下来。表格这东西,看着枯燥,但它是从“想法”到“代码”最可靠的桥梁。

4.1 状态表的构成

状态表,说白了就是一张“如果…那么…”的对照表。它由三大部分组成:

  • 当前状态:机器现在处于哪个状态
  • 输入条件:触发状态变化的事件或信号
  • 下一状态:满足条件后,机器要跳到哪里去

嗯,这里要注意,输出表有时候是单独拎出来的,有时候和状态转移表合并在一起。这取决于你的输出是摩尔型还是米利型。我待会儿会细讲。

举个例子,一个简单的交通灯控制器,它的状态表长这样:

当前状态 输入(定时器超时) 下一状态
红灯 绿灯
绿灯 黄灯
黄灯 红灯

你看,三行就把逻辑说清楚了。我在项目中遇到过有人画了十几页的状态图,最后自己都看不懂。用表格整理一下,立马清爽。

4.2 状态转移表的设计方法

状态转移表,重点在“转移”二字。它要回答的问题是:在什么条件下,从哪来到哪去?

设计步骤我总结为三步:

  1. 列出所有状态:给每个状态起个名字,比如 S0、S1、S2,或者更有语义的 IDLE、BUSY、DONE。
  2. 列出所有输入组合:把每个输入信号的可能取值都列出来。注意,如果有多个输入,要列出所有组合,别漏了。
  3. 填充下一状态:对于每一行,决定当前状态+输入组合下,下一状态是什么。
重要提醒: 一定要处理“非法状态”和“未定义输入”。我曾经在一个项目中漏掉了一个输入组合,结果机器在特定条件下直接跑飞了。从那以后,我每张状态表都会留一行“其他”作为兜底。

来看一个更完整的例子——一个简单的按键消抖状态机。它有四个状态:

  • 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 总结一下

状态表设计,说白了就是三件事:

  • 列出所有状态和输入
  • 填好“下一状态”这一列
  • 填好“输出”这一列

嗯,就这么简单。但越是简单的东西,越容易在细节上翻车。我建议你每设计完一张状态表,都拿几个典型场景手动走一遍,看看有没有死循环、有没有漏掉的条件。这一步花不了几分钟,但能省下后面几天的调试时间。

下一章,咱们聊聊状态机的编码方式——二进制编码、格雷码、独热码,到底该怎么选。到时候我会结合一个实际项目中的坑来聊,敬请期待。