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 里,代码的健壮性和可维护性会提升一个档次。
总结一下:状态表设计是状态机落地的关键一步。先画图,再列表,最后写代码——这个顺序我用了十年,没出过大问题。你也不妨试试。