(本文撰写于2021年情人节)
【说在前面的话】
在前面的一篇文章《从零开始的状态机漫谈(1)——万物之始的语言》中,我们介绍了状态机在整个计算机科学中宛如“世界基石”般的地位,同时介绍了一种“面向嵌入式环境”“高度简化”了的实用型状态图绘制方法——这里的“简化”是相对UML状态图的“繁杂”而言、且更接近课本上所使用的状态机图例;而这里的“实用”体现在:基于这套方法绘制的状态图是可以“无脑”而“严格”的翻译成C语言代码的。
在展开后续内容之前,不得不为大家解释清楚一个非常具有误导性的错误认知,即:状态机天然是非阻塞(non-blocking)的,因而可以用于在裸机状态下实现多任务。实际上,这种说法后半段是正确的,错就错在前半部分,比如,就前一篇文章中所提到的一个状态图:
翻译成下面的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);}
怎么样?发现之前说法的错误之处了吧?——是的,状态机(状态图)所描述的逻辑与翻译后的代码是否具有“非阻塞”的特性是无关的——翻译的方式不同,代码的特性也不同——但无论使用何种翻译方式,只要翻译是正确的,最终代码所对应的“状态机逻辑”就是“等效”的,比如,上面的状态机也可以翻译成如下的非阻塞形式:
#include <stdbool.h>#include <stdint.h>
typedef enum { fsm_rt_err = -1, fsm_rt_on_going = 0, fsm_rt_cpl = 1,} fsm_rt_t;
#define PRINT_HELLO_RESET_FSM() \ 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;}
对比两个代码,可以清楚的发现这两个事实:
所以说,与上述情况类似,市面上不少关于状态机的说法其实都是“有待商榷”、甚至是“错误的”,比如:
相信上述诸多误解和偏见中一定有一款是让你大为吃惊的。然而,如果你认为我这里列举出来的说法都是“错误的”,那么你就又错了。
这里的要点是——以上说法并不是“非黑即白”的,而是来源于某一些具体的状态机翻译方式,错就错在把某一种状态机翻译方式所具有的优点/缺点当成了整个状态机固有的优点/缺点——脱离了具体的状态机翻译方式,从而导致了“不准确”。
说了这么多,无非就是想让你们知道以下几点:
下面我们就以大部分人第一次接触和使用状态机时常用的 switch 状态机为例,为大家介绍前一章所属状态图的翻译规则。
让我们上路吧!
(本文撰写于2021年情人节)
【状态函数返回值的“小心思”】
对很多人来说,即便状态机“初恋”不是使用switch编写的函数,也一定逃不开使用函数作为状态机载体的形式(比如使用大量if-else作为基础的状态机)。观察状态图,你会发现状态机是有返回状值的:
比如图中右上角的“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状态机函数是非阻塞的,那么用户最关心的最基本问题恐怕就是:
对于第一个问题,显然其答案是一个布尔量:
基于这样的原因,完全可以根据 <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;
至此,我们完成了一个状态机返回值的定义过程,并隐含了以下的规则:
需要特别强调的是,错误码表示发生了“状态机发生了预期之外、无法继续正常工作的情况”,比如,状态机函数需要一个指针,但你传了一个空指针;或是状态机函数收到了一个无效的输入参数,导致后续工作都无法正常执行,等等。
与错误码不同,这类用返回值是状态机正常工作的结果,属于状态机逻辑本身所能预期和处理的。所以,哪怕“超时”听起来像是一个“错误”,但它本质上还是状态机逻辑所预期会发生并能正确检测和处理的,因此并不会作为一个负数错误码来返回。
在这个系列后面的文章中,我们还会引入两个默认的正整数状态返回值到 fsm_rt_t这里就先不赘述了:
//! \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 类型的帮助,我们的状态机函数终于有了一个像样的外壳,比如:
fsm_rt_t <状态机函数的名字>([形参列表]){ ... return fsm_rt_on_going; //!< 默认的返回值}
为了方便大家的理解,我们就以“带超时功能的字符接收状态机”为例子,为大家介绍对应的状态图绘制方法以及对应的代码片段:
观察上图可以发现,状态机read_byte会在读取字符的同时进行一个简单的倒计数;如果在s_wCounter为0之前成功读取到了一个字节,则返回cpl(pchByte所指向的字节buffer将保存对应的字节);如果读取字节失败,但计数器还未到零,则返回 on_going——表明状态机还在工作中;如果计数器到达了0,则返回一个自定义的状态信息(timeout),用以表明发生了超时。在图中,不光矩形框内部多了一个名为 timeout 的黑色小圆点;在矩形框的外部(右侧)也出现了一个对应的扇出箭头,同样也标记了 timeout——这实际上是告诉我们,当状态机迁移到 timeout 终点时,将通过 timeout 箭头扇出,而状态机也将复位。
它对应的一个可能代码为:
enum { fsm_rt_timeout = 4, //!< 额外定义的状态返回值};
#ifndef TIMEOUT_CNT# define TIMEOUT_CNT (1000000ul)#endif
extern bool serial_in(uint8_t *pchByte);
#define READ_BYTE_RESET_FSM() \ 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;}
这个代码有几个细节值得大家注意:
【不要小看了状态的定义】
与返回值类似,状态机的状态也可以用枚举来定义,但这里有一些细节是需要注意的:
以前面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不是状态】
如果你认真阅读《从零开始的状态机漫谈(1)——万物之始的语言》并观察状态图会发现: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)——状态机设计原则:清晰!清晰!还是清晰!》为您详细介绍,敬请期待。
【复位是一门大学问】
读到这里,很多小伙伴可能已经在前面的代码中发现了如下的细节:
#define READ_BYTE_RESET_FSM() \ do {s_tState = START;} while(0)
或是:
#define PRINT_HELLO_RESET_FSM() \ do {s_tState = START;} while(0)
于是心中升起了疑问:如果复位就是把状态变量重新设置为 START,
要回答第一个问题并不困难:
对于第二个问题,我们要从更长远的角度来考虑:现阶段的状态机也许很简单,所以复位仅仅是重置状态变量就够了;然而,随着应用结构的复杂,以及状态机翻译方式的改进或者变化,每个状态机函数所需的复位操作可能都是不同的,因此从养成好习惯的角度出发,应该给每一个状态机都配备一个专属的复位宏。
很多小伙伴在编写状态机的时候,可能会有这样一类要求:即,出于某种原因,应用程序的某些模块需要“从外部”复位某些状态机,换句话说——就是杀死状态机——这其实很类似RTOS里面,杀死某个任务线程的情况。对此我要说说我的看法:
【细数那些绝对要杜绝的“骚操作”】
在设计状态机或者翻译switch状态机的过程中,以下常见“骚操作”是应该避免的:
【后记】
相信对很多人来说,switch状态机都是它们裸机环境下的“制胜法宝”,我并不准备否认这一点,相反,我希望通过这篇文章,能够分享一下我在使用switch方式翻译状态图的一些做法以及背后的思考。
希望大家不要误解我——认为我这里介绍的方法就是 switch 状态机编写方式的“权威”,很遗憾的是,如果你有这种想法,那么我在本文开头处所作的努力就化为乌有了——也许状态图的所表达的逻辑是唯一的,但翻译它的方法从来都不是唯一的;同时每一个方法都有自己的利弊,希望大家在讨论喜好的时候,不要动辄就把某一类方法的特点强加到“状态机”整体身上加以评判。
原创不易。