4、状态表设计:状态转换表的结构、状态表与状态图的关系、状态表的C语言实现

好,咱们接着聊状态机设计。前面我们画了状态图,那玩意儿看着直观,但真要写到代码里,还得靠状态表。说白了,状态图是给人看的,状态表是给机器看的——或者说,是给程序员写代码时对照的。

我个人习惯,拿到一个状态机需求,先画图,再列表,最后写代码。三步走,不容易乱。今天咱们就把中间这一步——状态表设计,掰开揉碎了讲清楚。

4.1 状态转换表的结构

状态转换表,英文叫 State Transition Table,简称 STT。它的核心思想很简单:用表格的形式,把状态机里所有的“当前状态”、“输入事件”、“下一状态”、“输出动作”一一列出来。

我见过不少新手,一上来就写代码,写到一半发现漏了一种情况,回头改代码改得头大。其实你花10分钟列个表,后面能省2小时调试时间。

一个标准的状态转换表,通常包含这几列:

当前状态 输入事件 条件(可选) 下一状态 输出动作
待机 按键短按 轻柔模式 启动电机,LED亮绿灯
轻柔模式 按键短按 强力模式 切换PWM占空比,LED亮蓝灯
强力模式 按键短按 待机 停止电机,LED熄灭
任意状态 按键长按 待机 紧急停止,蜂鸣器响一声

嗯,这里要注意:条件列不是必须的。如果你的状态机里,同一个事件在不同条件下会走向不同状态,那这列就很有用了。比如“电量低于10%时,按键短按不切换模式,而是进入低电量提示状态”。

核心原则:状态转换表必须覆盖所有状态与所有合法事件的组合。遗漏一种组合,就可能让设备在某个奇怪的操作下“死机”。

4.2 状态表与状态图的关系

状态图和状态表,其实是同一个东西的两种表达方式。状态图是图形化的,适合做方案评审和团队沟通;状态表是结构化的,适合做代码实现和测试用例设计。

我在项目中遇到过这么一件事:有一次做一款筋膜枪的控制板,画状态图的时候觉得挺完美,结果一列状态表,发现漏了“充电中”这个状态下的按键响应逻辑。你想想看,要是直接写代码,这bug得藏到测试阶段才能发现。

它们之间的关系,我总结了三句话:

  • 状态图是状态表的“可视化预览”——每个箭头对应表里的一行。
  • 状态表是状态图的“完整性检查”——表格里有没有空着的格子?有,那就是遗漏了。
  • 两者互补,缺一不可——画图靠直觉,列表靠逻辑。

我的小技巧:画完状态图后,先别急着写代码。拿张纸,把状态表画出来,然后对照状态图,一条一条检查。我曾经用这个方法,在项目评审会上提前发现了3个逻辑漏洞,被组长夸了一顿。

4.3 状态表的C语言实现

好了,重头戏来了。状态表怎么变成C代码?最经典的做法,就是用二维数组或者结构体数组来实现“查表法”。

我个人偏爱结构体数组的方式,因为可读性好,后期维护也方便。咱们以按摩仪为例,先定义状态和事件:

// 状态枚举
typedef enum {
    STATE_IDLE,       // 待机
    STATE_SOFT,       // 轻柔模式
    STATE_STRONG,     // 强力模式
    STATE_PULSE,      // 脉冲模式
    STATE_MAX
} state_t;

// 事件枚举
typedef enum {
    EVENT_KEY_SHORT,  // 按键短按
    EVENT_KEY_LONG,   // 按键长按
    EVENT_TIMEOUT,    // 定时超时
    EVENT_MAX
} event_t;

然后定义状态转换表的结构体:

// 状态转换表条目
typedef struct {
    state_t  current_state;   // 当前状态
    event_t  event;           // 输入事件
    state_t  next_state;      // 下一状态
    void     (*action)(void); // 动作函数指针
} trans_t;

接下来,把状态表的内容填进去:

// 状态转换表
const trans_t state_table[] = {
    {STATE_IDLE,   EVENT_KEY_SHORT, STATE_SOFT,   action_start_soft},
    {STATE_SOFT,   EVENT_KEY_SHORT, STATE_STRONG, action_switch_strong},
    {STATE_STRONG, EVENT_KEY_SHORT, STATE_PULSE,  action_switch_pulse},
    {STATE_PULSE,  EVENT_KEY_SHORT, STATE_IDLE,   action_stop},
    {STATE_ANY,    EVENT_KEY_LONG,  STATE_IDLE,   action_emergency_stop},
};

// 注意:STATE_ANY 是一个特殊值,表示匹配任意状态
#define STATE_ANY 0xFF

最后,状态机的主循环就变得非常简洁:

state_t current_state = STATE_IDLE;

void state_machine_run(event_t event) {
    for (int i = 0; i < sizeof(state_table)/sizeof(trans_t); i++) {
        const trans_t *p = &state_table[i];
        // 匹配当前状态和事件
        if ((p->current_state == current_state || p->current_state == STATE_ANY) 
            && p->event == event) {
            // 执行动作
            if (p->action) {
                p->action();
            }
            // 状态转移
            current_state = p->next_state;
            break;
        }
    }
}

注意:查表法虽然优雅,但要注意表格的查找顺序。如果有多条匹配规则,应该把“更具体的规则”放在前面,“STATE_ANY”这种通配规则放在后面。我曾经因为顺序搞反,导致紧急停止功能被普通规则抢先匹配了,差点出安全事故。

这种实现方式的好处很明显:

  • 代码量少——状态机核心就一个循环加一个表格。
  • 容易修改——要加一个新状态?往表里加一行就行,不用改逻辑代码。
  • 便于测试——你可以写个脚本,自动遍历表格里的每一条,验证覆盖率。

说白了,状态表就是状态机的“配置文件”。你把逻辑写在表里,而不是写在 if-else 里,代码的健壮性和可维护性会提升一个档次。

总结一下:状态表设计是状态机落地的关键一步。先画图,再列表,最后写代码——这个顺序我用了十年,没出过大问题。你也不妨试试。