2. 状态机建模方法:状态转移图、状态转移表、状态机描述语言
好,咱们进入正题。状态机这东西,说白了就是「在什么状态下,遇到什么事件,做什么动作,然后跳到哪个状态」。但光有这个概念还不够,你得有工具把它画出来、写出来、描述清楚。
我刚开始做嵌入式那会儿,接过一个项目,需求文档写得云里雾里。我硬着头皮写代码,结果状态跳转逻辑乱成一锅粥。后来老工程师丢给我一句话:「先把状态图画清楚,再动键盘。」嗯,从那以后,我再也没跳过建模这一步。
今天咱们就聊三种最常用的建模方法:状态转移图、状态转移表、状态机描述语言。它们各有各的脾气,你得知道什么时候用哪个。
2.1 状态转移图(State Transition Diagram)
状态转移图,说白了就是一张「状态怎么走」的图。它用圆圈表示状态,用箭头表示转移方向,箭头旁边标上触发事件和动作。
我个人习惯在项目初期先用状态转移图。为什么?因为它直观。你跟硬件工程师、测试同事开会时,往白板上一画,大家一眼就能看懂。
核心要素:
- 状态(State):用圆圈或圆角矩形表示。比如 IDLE、RUNNING、ERROR。
- 转移(Transition):用箭头表示。从一个状态指向另一个状态。
- 事件(Event):写在箭头旁边。比如「按键按下」、「定时器超时」。
- 动作(Action):写在事件后面,用斜杠隔开。比如「按键按下 / 启动定时器」。
举个例子,一个简单的按键消抖状态机:
状态:IDLE → PRESSED → CONFIRMED → RELEASED
转移:
IDLE → 按键按下 / 启动定时器 → PRESSED
PRESSED → 定时器超时 / 记录按键 → CONFIRMED
PRESSED → 按键释放 / 取消定时器 → IDLE
CONFIRMED → 按键释放 / 执行动作 → RELEASED
RELEASED → 定时器超时 / 回到空闲 → IDLE
你想想看,如果不用图,光用文字描述这个逻辑,是不是绕得慌?但画成图,三秒钟就懂了。
我的小技巧:画图时,把「初始状态」用双圈标出来,把「终止状态」用粗圈标出来。这样别人一看就知道从哪里开始、到哪里结束。
2.2 状态转移表(State Transition Table)
状态转移图虽然直观,但一旦状态多了(比如超过10个),图就变成一团乱麻。这时候,我建议你换成状态转移表。
状态转移表,说白了就是一个表格。行是当前状态,列是触发事件,交叉格子里写「下一个状态 / 动作」。
我在项目中遇到过一个大坑:一个通信协议解析模块,有20多个状态。用状态转移图画出来,打印到A3纸上都看不清。后来改成表格,每个状态一行,每个事件一列,清清楚楚。调试时对着表格查,比看图快多了。
来看一个简单的例子,还是按键消抖:
| 当前状态 | 事件:按键按下 | 事件:按键释放 | 事件:定时器超时 |
|---|---|---|---|
| IDLE | PRESSED / 启动定时器 | IDLE / 无动作 | IDLE / 无动作 |
| PRESSED | PRESSED / 无动作 | IDLE / 取消定时器 | CONFIRMED / 记录按键 |
| CONFIRMED | CONFIRMED / 无动作 | RELEASED / 执行动作 | CONFIRMED / 无动作 |
| RELEASED | RELEASED / 无动作 | RELEASED / 无动作 | IDLE / 无动作 |
看到没?表格里每个格子都写清楚了「下一个状态 / 要执行的动作」。你写代码时,直接对着这个表,一个 switch-case 就搞定了。
注意:状态转移表里一定要留一个「默认」列。万一出现没定义的事件组合,你得知道怎么处理。我见过有人没写默认处理,结果系统跑飞了,查了三天才发现是漏了一个事件组合。
2.3 状态机描述语言(State Machine Description Language)
图也画了,表也列了,但最终还是要落到代码里。这时候,状态机描述语言就派上用场了。
说白了,这是一种介于「自然语言」和「代码」之间的描述方式。它用固定的语法格式,把状态、事件、转移、动作都写清楚。常见的格式有 SDL(Specification and Description Language)、Statechart、以及各种轻量级的自定义格式。
我个人习惯在写代码之前,先用描述语言把状态机「写」一遍。为什么?因为写的过程就是一次逻辑验证。你写着写着就会发现:「咦,这个状态怎么没有出口?」或者「这个事件在某个状态下没处理?」
来看一个用类 C 风格描述的状态机:
state_machine ButtonDebounce {
state IDLE {
on entry: 无动作
on exit: 无动作
event BUTTON_PRESSED -> PRESSED {
action: start_timer(10ms)
}
event BUTTON_RELEASED -> IDLE {
action: 无动作
}
}
state PRESSED {
on entry: 无动作
on exit: stop_timer()
event TIMEOUT -> CONFIRMED {
action: record_key_press()
}
event BUTTON_RELEASED -> IDLE {
action: stop_timer()
}
}
state CONFIRMED {
on entry: 无动作
on exit: 无动作
event BUTTON_RELEASED -> RELEASED {
action: execute_action()
}
}
state RELEASED {
on entry: start_timer(5ms)
on exit: stop_timer()
event TIMEOUT -> IDLE {
action: 无动作
}
}
}
你发现没有?这种描述语言把「进入状态时做什么」、「离开状态时做什么」、「事件来了做什么」都分开了。写代码时,直接翻译成 switch-case 或者函数指针表就行。
我的建议:如果你用的是 C 语言,可以自己定义一套宏,把状态机描述语言直接映射成代码。比如用 STATE()、EVENT()、TRANSITION() 这些宏。这样代码和文档就统一了,维护起来省心很多。
2.4 三种方法怎么选?
好,三种方法都聊完了。你可能会问:「那我到底用哪个?」
我的经验是这样的:
- 项目初期、需求讨论、方案评审:用状态转移图。画在白板上,大家都能看懂,方便沟通。
- 状态多、逻辑复杂、需要精确验证:用状态转移表。一行一列清清楚楚,不容易遗漏。
- 写代码之前、代码生成、自动化测试:用状态机描述语言。它最接近代码,可以直接翻译成实现。
其实,这三种方法不是互斥的。我经常是:先画图理清思路,再列表格查漏补缺,最后写描述语言准备编码。三步走下来,状态机基本不会出大问题。
避坑指南:我曾经在一个项目里,直接用状态转移图写代码,没列表格。结果漏了一个事件组合,导致系统在某个特殊情况下死锁。后来花了整整两天才找到原因。从那以后,我坚持「先列表,再编码」。你想想看,表格里每个格子都填满了,代码还能漏吗?
嗯,今天就聊到这儿。下一章咱们会讲怎么把状态机描述语言翻译成实际的 C 代码,包括 switch-case 实现和函数指针表实现。到时候你会看到,建模这一步做扎实了,写代码就是水到渠成的事。