状态机漫谈——switch:你的状态机初恋

共 5136字,需浏览 11分钟

 ·

2021-02-23 11:29

(本文撰写于2021年情人节)


【说在前面的话】


在前面的一篇文章从零开始的状态机漫谈(1)——万物之始的语言中,我们介绍了状态机在整个计算机科学中宛如“世界基石”般的地位,同时介绍了一种“面向嵌入式环境”“高度简化”了的实用型状态图绘制方法——这里的“简化”是相对UML状态图的“繁杂”而言、且更接近课本上所使用的状态机图例;而这里的“实用”体现在:基于这套方法绘制的状态图是可以“无脑”而“严格”的翻译成C语言代码的


在展开后续内容之前,不得不为大家解释清楚一个非常具有误导性的错误认知,即:状态机天然是非阻塞(non-blocking)的,因而可以用于在裸机状态下实现多任务。实际上,这种说法后半段是正确的,错就错在前半部分,比如,就前一篇文章中所提到的一个状态图:

翻译成下面的C语言代码,在逻辑上毫无问题:
#include #include 
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 #include 
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;}

对比两个代码,可以清楚的发现这两个事实:

  • 在状态机逻辑层面,两个代码都正确的翻译(表达)了状态图的逻辑

  • 在C代码的实际执行层面,一个是“不完成任务就绝不回来”的阻塞代码;一个是在状态执行间隙还会“悄悄”退出函数——释放处理器的非阻塞代码


所以说,与上述情况类似,市面上不少关于状态机的说法其实都是“有待商榷”、甚至是“错误的”,比如:

  • 状态机天然的是非阻塞代码;

  • 因为状态机经常切换,因此实时性好;

  • 状态机经常切换,没法以最快的速度响应事件,所以实时性差;

  • 状态机执行效率低下;

  • 状态机执行效率高;

  • 状态机占用代码空间大;

  • 状态机占用资源小,适合资源有限的小单片机;

  • 任何状态机都可以翻译成普通的RTOS任务(注意,这里的说法强调的不是不是状态机代码在RTOS任务里执行,而是把状态图翻译成RTOS任务)

  • ……


相信上述诸多误解和偏见中一定有一款是让你大为吃惊的。然而,如果你认为我这里列举出来的说法都是“错误的”,那么你就又错了


这里的要点是——以上说法并不是“非黑即白”的,而是来源于某一些具体的状态机翻译方式,错就错在把某一种状态机翻译方式所具有的优点/缺点当成了整个状态机固有的优点/缺点——脱离了具体的状态机翻译方式,从而导致了“不准确”


说了这么多,无非就是想让你们知道以下几点:
  • 状态机/状态图的翻译方式众多;

  • 不同翻译方式在代码的行为特性上存在天壤之别;

  • 抛开具体翻译方式谈状态机特性都是耍流氓

  • 如果说状态图才是“新的源代码”,翻译C代码就是“新的汇编”,根据一定规则翻译状态图为C代码的过程就是”新的编译“。


下面我们就以大部分人第一次接触和使用状态机时常用的 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_goingfsm_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)的正数。


与错误码不同,这类用返回值是状态机正常工作的结果,属于状态机逻辑本身所能预期和处理的。所以,哪怕“超时”听起来像是一个“错误”,但它本质上还是状态机逻辑所预期会发生并能正确检测和处理的,因此并不会作为一个负数错误码来返回。


在这个系列后面的文章中,我们还会引入两个默认的正整数状态返回值到 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_wCounter0之前成功读取到了一个字节,则返回cplpchByte所指向的字节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;}

这个代码有几个细节值得大家注意:

  • fsm_rt_timeout 是一个额外定义的枚举,其实我们并不需要给它配备一个所谓的类型——毕竟只是拿它当一个常数用,直接用匿名枚举就行了;

  • fsm_rt_timeout 本质上是属于匿名枚举的,因此作为兼容 fsm_rt_t 的值返回时,有些编译器还是会报告 warning——提示我们返回值并不是 fsm_rt_t 的一部分——这里我们直接使用强制类型转换让编译器“闭嘴即可”;

  • 状态函数需要用户传入一个指针 pchByte,容易发现,如果传入值是NULL,整个状态机就无法正常工作了,因而视作错误,需要返回负数错误码;又由于这里我们很懒,没有定义专门定义这一情况的错误码,因此以 fsm_rt_err 来凑数。一般来说错误码的返回值是不用在状态图上进行明确标注的


【不要小看了状态的定义】


与返回值类似,状态机的状态也可以用枚举来定义,但这里有一些细节是需要注意的:
  • 由于定义状态的枚举实际上是状态机函数的“私有财产”,也就是说只有状态机函数会“使用且只用一次”,因此:

    • 没有必要为其使用 typedef 来定义一个类型;

    • 应该放在状态机函数的内部——由花括号限制枚举的作用范围;

    • 由于这一枚举类型的作用范围被限制在了函数内部,因此状态机之间不存在“重名”或者“命名空间污染”的问题——换句话说,

      • 每个状态的名称都可以尽可能的简单;

      • START在每个状态机函数里都可以被定义一次,而且永远叫START

  • 状态的命名上应该尽可能以状态图上的状态名为“蓝本”;

  • 状态名应该尽可能的有意义,而不是像STATE_ASTATE_B, ... STATE_X 这样“用一个英文字母序号”去代表“0,1,2...n这样的数字序号”——二者无论是谁都没有为“状态是做什么的”提供任何有意义的信息。相对的,例如 READ_BYTEIS_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不是状态】


如果你认真阅读从零开始的状态机漫谈(1)——万物之始的语言并观察状态图会发现: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)——状态机设计原则:清晰!清晰!还是清晰!为您详细介绍,敬请期待。


【复位是一门大学问】


读到这里,很多小伙伴可能已经在前面的代码中发现了如下的细节:
#define READ_BYTE_RESET_FSM() \    do {s_tState = START;} while(0)

或是:

#define PRINT_HELLO_RESET_FSM() \    do {s_tState = START;} while(0)

于是心中升起了疑问:如果复位就是把状态变量重新设置为 START

  • 为什么不直接在图上所有要复位的地方直接画一条箭头——跃迁到到第一状态?

  • 为什么不定一个统一的宏,比如叫 RESET_FSM() 就好了,而是给每个状态机都定义一个自己的宏?


要回答第一个问题并不困难:

  • 复位并不是普通的状态跃迁,它表示将状态机“重置”——复位后的第一次执行,状态机会从START那里开始,并且完成必要的状态机初始化操作;

  • 统一采用START作为状态机的起点,可以避免第一个状态出现恐怖数量的扇入箭头,从而极大的简化了状态图(你也不想看到蜘蛛网一样密集的箭头吧);

  • 避免了每个扇入的跃迁所拥有的“初始化代码”可能会存在“不同”而导致的代码陷阱——因为我们统一从START进入,因此只要维护一份初始化代码就足够了。


对于第二个问题,我们要从更长远的角度来考虑:现阶段的状态机也许很简单,所以复位仅仅是重置状态变量就够了;然而,随着应用结构的复杂,以及状态机翻译方式的改进或者变化,每个状态机函数所需的复位操作可能都是不同的,因此从养成好习惯的角度出发,应该给每一个状态机都配备一个专属的复位宏


很多小伙伴在编写状态机的时候,可能会有这样一类要求:即,出于某种原因,应用程序的某些模块需要“从外部”复位某些状态机,换句话说——就是杀死状态机——这其实很类似RTOS里面,杀死某个任务线程的情况。对此我要说说我的看法:
  • 首先,应该尽最大可能避免从状态机外部复位状态机,或者说,状态机的生命周期应该掌控在自己手里。这么做的原因很简单,也很关键,即理论上没有任何人比状态机自己更清楚如何安全而有效的复位一个状态机。如果这么说你不能理解,考虑如下几种情况:

    • 状态机中可能存在动态分配的资源,状态机自己内部的复位过程中会正确的释放这些资源;而来自外部的“它杀”在杀手掌握的信息不充分的情况下,可能会导致这类资源未被正确释放

    • 状态机非常适合用作各类机械控制,然而,出于机械机构的特殊原因,为了防止损害设备,或者伤害到人员,这类状态机都会根据当前的工作状态,有一套针对性的(通常是不同的)复位序列(甚至某些状态下根本不允许复位),而且复位过程本身也是需要时间的,因此在这种情况下直接由外部进行“他杀”实际上是不可承受之重

  • 其次,应该用“自杀请求”来代替“直接他杀”,即:状态机在设计时即提供一个“复位请求”信号,并在状态机内部适当的状态检测这一信号;外部应用只能通过这一信号来“请求”状态机复位;当复位成功后,状态机应该通过某种手段,比如特定的返回值或者回调函数来告知请求者“复位完成”。



【细数那些绝对要杜绝的“骚操作”】


在设计状态机或者翻译switch状态机的过程中,以下常见“骚操作”是应该避免的:
  • 在一个函数里塞入多个switch状态机实现——请记住,每个switch状态机都应该有自己专属的一个函数; 

  • 在 switch 外部添加各类功能性的代码。这种做法,本质上就是模拟了“多线程”,也就是switch状态机逻辑被看作一个“线程”、switch外部的功能代码客观上就充当了另外一个“线程”。这种情况完全可以通过将两份代码拆分到独立的任务函数中,并以某种形式的“任务间通信”完成协调——最终实现一样的功能

  • 把状态变量定义到状态机函数外部,从而方便别人“偷窥”或者“复位”——请参考前面一个章节的内容,用“自杀”替代“它杀”。



【后记】


相信对很多人来说,switch状态机都是它们裸机环境下的“制胜法宝”,我并不准备否认这一点,相反,我希望通过这篇文章,能够分享一下我在使用switch方式翻译状态图的一些做法以及背后的思考。
希望大家不要误解我——认为我这里介绍的方法就是 switch 状态机编写方式的“权威”,很遗憾的是,如果你有这种想法,那么我在本文开头处所作的努力就化为乌有了——也许状态图的所表达的逻辑是唯一的,但翻译它的方法从来都不是唯一的;同时每一个方法都有自己的利弊,希望大家在讨论喜好的时候,不要动辄就把某一类方法的特点强加到“状态机”整体身上加以评判。




原创不易,

如果你喜欢我的思维、觉得我的文章对你有所启发,

请务必 “点赞、收藏、转发” 三连,这对我很重要!谢谢!


欢迎订阅 裸机思维


浏览 89
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报