(本文撰写于2021年情人节)
【说在前面的话】
在前面的一篇文章《从零开始的状态机漫谈(1)——万物之始的语言》中,我们介绍了状态机在整个计算机科学中宛如“世界基石”般的地位,同时介绍了一种“面向嵌入式环境”“高度简化”了的实用型状态图绘制方法——这里的“简化”是相对UML状态图的“繁杂”而言、且更接近课本上所使用的状态机图例;而这里的“实用”体现在:基于这套方法绘制的状态图是可以“无脑”而“严格”的翻译成C语言代码的。
#include <stdbool.h>
#include <stdint.h>
void print_hello(void)
{
//! 对应 start部分
uint8_t *s_pchSrc = "Hello";
do {
//! 对应 Print Hello 状态
while(!serial_out(*s_pchSrc));
//! serial_out返回值为true的状态迁移
s_pchSrc++;
//! 对应 "Is End of String"状态
if (*s_pchSrc == '\0') {
//! true分支,结束状态机
return ;
}
//! false分支,跳转到 "Print Hello" 状态
} while(true);
}
typedef enum {
fsm_rt_err = -1,
fsm_rt_on_going = 0,
fsm_rt_cpl = 1,
} fsm_rt_t;
do {s_tState = START;} while(0)
fsm_rt_t print_hello(void)
{
static enum {
START = 0,
PRINT_HELLO,
IS_END_OF_STRING,
} s_tState = {START};
static const uint8_t *s_pchSrc = NULL;
switch (s_tState) {
case START:
//! 这个赋值写法只在嵌入式环境下“可能”是安全的
s_pchSrc = "Hello world";
s_tState++;
//break;
case PRINT_HELLO:
if (!serial_out(*s_pchSrc)) {
break;
};
s_tState = IS_END_OF_STRING;
s_pchSrc++;
//break;
case IS_END_OF_STRING:
if (*s_pchSrc == '\0') {
PRINT_HELLO_RESET_FSM();
return fsm_rt_cpl;
}
s_tState = PRINT_HELLO;
break;
}
return fsm_rt_on_going;
}
对比两个代码,可以清楚的发现这两个事实:
在状态机逻辑层面,两个代码都正确的翻译(表达)了状态图的逻辑;
在C代码的实际执行层面,一个是“不完成任务就绝不回来”的阻塞代码;一个是在状态执行间隙还会“悄悄”退出函数——释放处理器的非阻塞代码。
所以说,与上述情况类似,市面上不少关于状态机的说法其实都是“有待商榷”、甚至是“错误的”,比如:
状态机天然的是非阻塞代码;
因为状态机经常切换,因此实时性好;
状态机经常切换,没法以最快的速度响应事件,所以实时性差;
状态机执行效率低下;
状态机执行效率高;
状态机占用代码空间大;
状态机占用资源小,适合资源有限的小单片机;
任何状态机都可以翻译成普通的RTOS任务(注意,这里的说法强调的不是不是状态机代码在RTOS任务里执行,而是把状态图翻译成RTOS任务)
……
相信上述诸多误解和偏见中一定有一款是让你大为吃惊的。然而,如果你认为我这里列举出来的说法都是“错误的”,那么你就又错了。
状态机/状态图的翻译方式众多;
不同翻译方式在代码的行为特性上存在天壤之别;
抛开具体翻译方式谈状态机特性都是耍流氓;
如果说状态图才是“新的源代码”,翻译C代码就是“新的汇编”,根据一定规则翻译状态图为C代码的过程就是”新的编译“。
(本文撰写于2021年情人节)
【状态函数返回值的“小心思”】
比如图中右上角的“on-going”和右下角的“cpl”,分别表示状态机“正在工作(on-going)”和“已经完成(complete)”。图上的状态机算是比较简单的了,其它状态机可能还有返回其它信息的需求——比如,一个接收字符的状态机可能还需要返回“超时(timeout)”这样的信息——因此,定义一个专门的枚举类型来作为状态机函数的返回值就显得非常有必要:
typedef enum {
fsm_rt_on_going,
fsm_rt_cpl,
} fsm_rt_t;
到了这里,有一个细节问题需要考虑,fsm_rt_on_going和fsm_rt_cpl分别对应怎样的具体值好呢?(或者干脆不管?)。要解决这个问题,实际上只有是站在状态机函数用户角度考虑进行考虑,才能找到不会违反用户直觉(屁股决定脑袋)的答案。从状态机调用者的角度来看,既然我们告诉TA状态机函数是非阻塞的,那么用户最关心的最基本问题恐怕就是:
状态机是否执行完成了?
状态机有没有遇到什么自己不能处理的错误?
对于第一个问题,显然其答案是一个布尔量:
如果返回false,则表明状态机还没有执行完成——需要继续执行(on-going);
如果返回true,则表明状态机已经执行完成(complete)
基于这样的原因,完全可以根据 <stdbool.h> 中的定义,给我们的 fsm_rt_t 一个兼容的值,即:
typedef enum {
fsm_rt_on_going = 0,
fsm_rt_cpl = 1,
} fsm_rt_t;
对于第二个问题,实际上,程序员之间有一个不成文的规定,即:错误码用负数表示,因此,我们可以引入一个“不问缘由的默认的错误码” (-1),并允许用户可以用除去(-1)以外的其它负数来编码更为具体的错误——这里就把这种自由度留给用户自己去发挥了,我们只需要在 fsm_rt_t 中引入(-1)就可以了:
typedef enum {
fsm_rt_err = -1,
fsm_rt_on_going = 0,
fsm_rt_cpl = 1,
} fsm_rt_t;
至此,我们完成了一个状态机返回值的定义过程,并隐含了以下的规则:
对于“确定”不会返回错误码的状态机函数来说,状态机函数的使用与bool量是兼容的;
用户可以使用负数来“自定义”错误码,并使用(-1)表示“不问缘由的默认错误码”;
用户定义的其它状态值,比如超时之类的,它们必须是大于(1)的正数。
//! \name finit state machine return value
//! @{
typedef enum {
fsm_rt_err = -1, //!< fsm error, error code can be get from other interface
fsm_rt_cpl = 0, //!< fsm complete
fsm_rt_on_going = 1, //!< fsm on-going
fsm_rt_wait_for_obj = 2, //!< fsm wait for object
fsm_rt_asyn = 3, //!< fsm asynchronose mode, you can check it later.
} fsm_rt_t;
//! @}
fsm_rt_t <状态机函数的名字>([形参列表])
{
...
return fsm_rt_on_going; //!< 默认的返回值
}
为了方便大家的理解,我们就以“带超时功能的字符接收状态机”为例子,为大家介绍对应的状态图绘制方法以及对应的代码片段:
enum {
fsm_rt_timeout = 4, //!< 额外定义的状态返回值
};
extern bool serial_in(uint8_t *pchByte);
do {s_tState = START;} while(0)
fsm_rt_t read_byte(uint8_t *pchByte)
{
static enum {
START = 0,
READ_BYTE,
IS_TIMEOUT,
} s_tState = {START};
static uint32_t s_wCounter;
if (NULL == pchByte) {
READ_BYTE_RESET_FSM();
return fsm_rt_err; //!< 检测到无效的输入参数
}
switch (s_tState) {
case START:
s_wCounter = TIMEOUT_CNT;
s_tState++;
//break;
case READ_BYTE:
if (serial_in(pchByte)) {
READ_BYTE_RESET_FSM();
return fsm_rt_cpl;
}
s_wCounter--;
s_tState = IS_TIMEOUT;
//break;
case IS_TIMEOUT:
if (0 == s_wCounter) {
READ_BYTE_RESET_FSM();
return (fsm_rt_t) fsm_rt_timeout;
}
s_tState = READ_BYTE;
break;
}
return fsm_rt_on_going;
}
这个代码有几个细节值得大家注意:
fsm_rt_timeout 是一个额外定义的枚举,其实我们并不需要给它配备一个所谓的类型——毕竟只是拿它当一个常数用,直接用匿名枚举就行了;
fsm_rt_timeout 本质上是属于匿名枚举的,因此作为兼容 fsm_rt_t 的值返回时,有些编译器还是会报告 warning——提示我们返回值并不是 fsm_rt_t 的一部分——这里我们直接使用强制类型转换让编译器“闭嘴即可”;
状态函数需要用户传入一个指针 pchByte,容易发现,如果传入值是NULL,整个状态机就无法正常工作了,因而视作错误,需要返回负数错误码;又由于这里我们很懒,没有定义专门定义这一情况的错误码,因此以 fsm_rt_err 来凑数。一般来说错误码的返回值是不用在状态图上进行明确标注的。
【不要小看了状态的定义】
由于定义状态的枚举实际上是状态机函数的“私有财产”,也就是说只有状态机函数会“使用且只用一次”,因此:
没有必要为其使用 typedef 来定义一个类型;
应该放在状态机函数的内部——由花括号限制枚举的作用范围;
由于这一枚举类型的作用范围被限制在了函数内部,因此状态机之间不存在“重名”或者“命名空间污染”的问题——换句话说,
每个状态的名称都可以尽可能的简单;
START在每个状态机函数里都可以被定义一次,而且永远叫START;
状态的命名上应该尽可能以状态图上的状态名为“蓝本”;
状态名应该尽可能的有意义,而不是像STATE_A,STATE_B, ... STATE_X 这样“用一个英文字母序号”去代表“0,1,2...n这样的数字序号”——二者无论是谁都没有为“状态是做什么的”提供任何有意义的信息。相对的,例如 READ_BYTE,IS_TIMEOUT 这样的名称就非常简洁明了。
以前面read_byte状态机代码为例,一些错误的或者说不推荐的做法为:
//!< 错误一:只用一次的枚举,没必要定义类型
//!< 错误二:这个枚举是 read_byte 的私有财产,应该放到函数内部
typedef enum {
FSM_RB_START = 0, //!< 不推荐一:没必要加前缀
FSM_RB_STATE_A, //!< 不推荐二:用字母序号替代数字序号,脱裤子放屁,完全没提供任何有意义的信息
FSM_RB_STATE_B,
} read_byte_state_t;
fsm_rt_t read_byte(uint8_t *pchByte)
{
static read_byte_state_t s_tState = {FSM_RB_START};
...
}
作为对比,正确的做法如下:
fsm_rt_t read_byte(uint8_t *pchByte)
{
static enum {
START = 0,
READ_BYTE,
IS_TIMEOUT,
} s_tState = {START};
...
}
【START不是状态】
START 不是一个可以保持的状态,它也不能被看作一个特殊的状态;因此,翻译代码的时候,虽然START是0,但在对应的case分支中,一定要自动切换到下一个状态而绝对不能在此停留——这就是纪律!
另外一个“START不能被当做状态来使用”的原因是,start作为一个跃迁条件,它是可以拥有“发生跃迁时执行且只执行一次的动作的”——又由于START是处于复位状态的状态机第一次执行时的起点,因此START所携带的执行动作一般用作状态机的初始化——比如初始化状态机所使用的变量等等;
如果状态机需要动态申请资源,比如malloc,考虑到失败的可能,如果允许重试,则这类资源分配代码就不能放置在START中,因为我们说过,START不是状态——在状态机复位之前不应该重复执行;如果分配失败被视作错误,会返回负数的错误码,并复位状态机,则允许将这类资源分配代码放置到START中——因为逻辑上我们遵守了规则。
作为例子,不要尝试干出这种事情:
fsm_rt_t example(...)
{
static enum {
START = 0,
...
} s_tState = {START};
static uint32_t s_pchArray;
switch (s_tState) {
case START:
s_pchArray = malloc(64);
if (NULL == s_pchArray) {
break;
}
s_tState++;
...
}
应该专门给这类允许重试的资源分配一个独立的状态:
fsm_rt_t example(...)
{
static enum {
START = 0,
MALLOC,
...
} s_tState = {START};
static uint32_t s_pchArray;
switch (s_tState) {
case START:
s_tState++;
//break;
case MALLOC:
s_pchArray = malloc(64);
if (NULL == s_pchArray) {
break;
}
s_tState = XXXXX;
break;
...
}
【如何实现从状态到代码的“无脑翻译”】
它可以简单的对应到下面的代码结构:
case <状态名称>:
状态具体执行了什么有返回值d的动作;
if (返回值 满足 跃迁条件1) {
s_tState = XXXXX; //!< 执行状态跃迁
执行对应的跃迁动作
} else if (返回值 满足 跃迁条件2) {
s_tState = XXXXX; //!< 执行状态跃迁
执行对应的跃迁动作
}
break;
一般来说,我们既可以用上面的公式无脑翻译代码,也可以进行必要的等效改编。比如,对于READ_BYTE状态:
我们可以无脑翻译成如下的代码:
case READ_BYTE:
if (serial_in(pchByte)) {
READ_BYTE_RESET_FSM();
return fsm_rt_cpl;
}
s_wCounter--;
s_tState = IS_TIMEOUT;
break;
如果你的状态很复杂,那么一定可以拆分成多个状态彼此配合的形式;
拆分后每个状态都应该功能单一;
拆分后的逻辑应该更加清晰;
所以,不要问我“一个状态很复杂怎么翻译”,先看看你是不是做了所谓的“超级状态”——尝试把很多事情都在一个状态里做了——如果发生了这种事情,请反思这跟“把所有应用代码都写在超级循环里,而且还不涉及函数调用”有啥区别。最后,关于把“超级状态”拆分成多个简单状态的组合以后可能面临的“所谓”性能优化问题,我们将在本系列后面的文章《从零开始的状态机漫谈(3)——状态机设计原则:清晰!清晰!还是清晰!》为您详细介绍,敬请期待。
【复位是一门大学问】
do {s_tState = START;} while(0)
或是:
do {s_tState = START;} while(0)
于是心中升起了疑问:如果复位就是把状态变量重新设置为 START,
为什么不直接在图上所有要复位的地方直接画一条箭头——跃迁到到第一状态?
为什么不定一个统一的宏,比如叫 RESET_FSM() 就好了,而是给每个状态机都定义一个自己的宏?
要回答第一个问题并不困难:
复位并不是普通的状态跃迁,它表示将状态机“重置”——复位后的第一次执行,状态机会从START那里开始,并且完成必要的状态机初始化操作;
统一采用START作为状态机的起点,可以避免第一个状态出现恐怖数量的扇入箭头,从而极大的简化了状态图(你也不想看到蜘蛛网一样密集的箭头吧);
避免了每个扇入的跃迁所拥有的“初始化代码”可能会存在“不同”而导致的代码陷阱——因为我们统一从START进入,因此只要维护一份初始化代码就足够了。
对于第二个问题,我们要从更长远的角度来考虑:现阶段的状态机也许很简单,所以复位仅仅是重置状态变量就够了;然而,随着应用结构的复杂,以及状态机翻译方式的改进或者变化,每个状态机函数所需的复位操作可能都是不同的,因此从养成好习惯的角度出发,应该给每一个状态机都配备一个专属的复位宏。
首先,应该尽最大可能避免从状态机外部复位状态机,或者说,状态机的生命周期应该掌控在自己手里。这么做的原因很简单,也很关键,即理论上没有任何人比状态机自己更清楚如何安全而有效的复位一个状态机。如果这么说你不能理解,考虑如下几种情况:
状态机中可能存在动态分配的资源,状态机自己内部的复位过程中会正确的释放这些资源;而来自外部的“它杀”在杀手掌握的信息不充分的情况下,可能会导致这类资源未被正确释放;
状态机非常适合用作各类机械控制,然而,出于机械机构的特殊原因,为了防止损害设备,或者伤害到人员,这类状态机都会根据当前的工作状态,有一套针对性的(通常是不同的)复位序列(甚至某些状态下根本不允许复位),而且复位过程本身也是需要时间的,因此在这种情况下直接由外部进行“他杀”实际上是不可承受之重。
其次,应该用“自杀请求”来代替“直接他杀”,即:状态机在设计时即提供一个“复位请求”信号,并在状态机内部适当的状态检测这一信号;外部应用只能通过这一信号来“请求”状态机复位;当复位成功后,状态机应该通过某种手段,比如特定的返回值或者回调函数来告知请求者“复位完成”。
【细数那些绝对要杜绝的“骚操作”】
在一个函数里塞入多个switch状态机实现——请记住,每个switch状态机都应该有自己专属的一个函数;
在 switch 外部添加各类功能性的代码。这种做法,本质上就是模拟了“多线程”,也就是switch状态机逻辑被看作一个“线程”、switch外部的功能代码客观上就充当了另外一个“线程”。这种情况完全可以通过将两份代码拆分到独立的任务函数中,并以某种形式的“任务间通信”完成协调——最终实现一样的功能。
把状态变量定义到状态机函数外部,从而方便别人“偷窥”或者“复位”——请参考前面一个章节的内容,用“自杀”替代“它杀”。
【后记】
如果你喜欢我的思维、觉得我的文章对你有所启发,
请务必 “点赞、收藏、转发” 三连,这对我很重要!谢谢!
欢迎订阅 裸机思维