simple fsm状态机模板应用笔记(一)——simple fsm的设计思维和哲学

原文地址:https://www.amobbs.com/thread-5668532-1-1.html

说在前面的话

好久没有整理代码了,最近一直在做ARMv8-M系统安全设计相关的研究,虽然忙,但不代表我对自己无聊的爱好——整理一些好玩的代码模板,或者说语法糖——失去了兴趣。人总是会变的,一段时间过去以后,发现过去写的代码真心看着“心累”——宏一律大写看着辣眼睛,比如以前写的状态机脚本,所有做“状态机脚本语法辅助”的宏都是大写,看着果然还是不舒服。这次,我修正了一下自己的编码风格:
“所有宏和枚举都是大写除非对应的宏或者枚举用于辅助脚本语法”,比如后面你们看到的那个例子。
所有的状态机关键字都小写了,是不是舒服很多?

如果只是换个格式,那未免也显得太没诚意了,这次的新模板具有以下特性:

- 针对ARM架构进行效率上的优化
- 为每一个状态机提供一个控制块,用于参数封装,并且每个控制块在内部都用掩码结构体进行私
  有化保护
- 状态机模板可以独立存在,实现上更简洁

首先,我们来说说这次的模板在效率上作了什么优化?是什么原理?
ARM的Thumb指令集有一个特点:所有的寻址都是间接寻址,尤其是对变量的访问,通常都要借助一个寄存器来保存变量的地址,例如下面的语句:

    LDR   r0, =<某个数组的基地址>  ; 步骤1: 这是一个汇编伪代码,将某个数组的基地址复制到r0中,汇编器可以识别这种语法
    LDR   r1,  [r0]                ; 步骤2:r0里保存的是一个uint32_t变量的地址,我们把它读出来保存到r1里面
    LDR   r2,  [r0, #4]          ; 步骤3:读取uint32_t 数组的第二个word

这种方式实际上对面向对结构体的访问非常友好,而且如果你仔细观察你会发现:
1. 如果你访问的是一个静态变量或者全局变量,那么生成的汇编包含“步骤1”和“步骤2”
2. 如果你访问的是一个数组,那么一定会包含“步骤1”,然后每个元素的访问都对应一个步骤,也就是“步骤2”、“步骤三”
3. 你会发现,无论是单个静态变量的访问,还是批量数组或者结构体的访问,“步骤1”——也就是加载基地址的过程都是省不掉的。
在这种情况下,数组和结构体元素的访问共享同一个步骤一,这就比单个变量的访问要节省很多。
举一个例子:总有人问,外设寄存器是单独定义成类似全局变量的形式好,还是用结构体访问的形式好?根据上面的描述,答案
就很清楚了。同样的,普通的switch状态机,横竖要包含一个静态的状态变量,另外还有若干静态的参数,那么
“为什么不把状态变量和状态机用到的静态变量打包成一个结构体——也就是状态机控制块呢?”
实际上,根据上面的分析,哪怕这个状态机控制块只包含一个状态变量,它也不会比直接使用状态变量的方式增加更多的开销,相反,如果这个控制块包含更多其他的变量,我们就赚了!所以,我在模板上加入了以下的内容:

#define __simple_fsm(__FSM_TYPE, ...)                               \
        DECLARE_CLASS(__FSM_TYPE)                                   \
        DEF_CLASS(__FSM_TYPE)                                       \
            uint_fast8_t chState;                                   \
            __VA_ARGS__                                             \
        END_DEF_CLASS(__FSM_TYPE)
 
#define simple_fsm(__NAME, ...)                                     \
        __simple_fsm(fsm(__NAME), __VA_ARGS__)

可以看到,状态机控制块至少包含了一个状态变量 chState,而用状态机要用到的其它变量可以通过 “…” 对应的 VA_ARGS 加入到结构体中来。例如,一个用于延时的状态机delay_1s需要一个计数器,我们可以写成如下的形式:

simple_fsm( delay_1s,
    
    /* define all the parameters used in the fsm */ 
   uint32_t wCounter;                  //!< a uint32_t counter
)

这里,delay_1s 是状态机的名字,uint32_t wCounter; 是我们定义的参数(可以定义更多的参数)。显然,这两个东西放在一起让人有点不知所措,所以我们增加了一个语法的辅助宏:

  #define def_params(...)         __VA_ARGS__

借助它,我们写出来的代码即便没有注释,也好懂多了:

simple_fsm( delay_1s,
    def_params(
        uint32_t wCounter;                  
    )
)

那么,实现状态机的时候,我们如何访问控制块里面的成员变量呢?这就要看看状态机的实现宏了:

#define fsm_implementation(__NAME, ...)                                              \
    fsm_rt_t __NAME( fsm(__NAME) *ptFSM __VA_ARGS__ )                           \
    {                                                                           \
        CLASS(fsm_##__NAME##_t) *ptThis = (CLASS(fsm_##__NAME##_t) *)ptFSM;     \
        if (NULL == ptThis) {                                                   \
            return fsm_rt_err;                                                  \
        }  
 
#define body(...)                                                               \
        switch (ptThis->chState) {                                              \
            case 0:                                                             \
                ptThis->chState++;                                              \
            __VA_ARGS__                                                         \
        }                                                                       \
                                                                                \
        return fsm_rt_on_going;                                                 \
    }

这里我们可以发现, implement_fsm() 和 body() 是配对使用的。你也许已经猜到了,状态机的具体实现代码是写在body的括号里的。具体可以看后面的例子,这里我们继续来讨论状态机控制块成员变量 的访问。
implement_fsm() 实际上规定了状态机的函数原形,它包含了一个指向状态机控制块的指针ptFSM,而这个指针随后就被还原为原始形式(控制块默认情况下实际上是一个掩码结构体,所以要访问内部成员必须要还原为原始形式):ptThis实际上就指向了我们实际使用的控制块,通过这个结构体指针,我们 就可以轻松的访问任何的成员变量。但到这里,不要急,为了让代码更好看一点,我们引入了一个专门 的辅助宏:

#ifndef this
#   define this    (*ptThis)
#endif

借助这一语法糖,我们可以毫无代价的在body()内部通过 “this.” 的方式访问成员变量,例如:

fsm_implementation (  delay_1s)
    def_states(DELAY_1S)               
 
    body (
        state(  DELAY_1S,               
            if (0 == this.wCounter) {
                fsm_cpl();              
            }
            this.wCounter--;
            fsm_on_going();             
        )
    )

如果我们的状态机要作为一个字模块提供给外部使用怎么办呢?别着急,这里有一个简单的宏,你可以放在头文件里面提供给别的.c文件来引用:

#define __extern_simple_fsm(__NAME, __FSM_TYPE, ...)                \
        DECLARE_CLASS(__FSM_TYPE)                                   \
        EXTERN_CLASS(__FSM_TYPE)                                    \
            uint_fast8_t chState;                                   \
            __VA_ARGS__                                             \
        END_EXTERN_CLASS(__FSM_TYPE)                                \
        extern fsm_rt_t __NAME( __FSM_TYPE *ptThis __VA_ARGS__ );
 
#define extern_simple_fsm(__NAME, ...)                              \
        __extern_simple_fsm(__NAME, fsm(__NAME), __VA_ARGS__)  

比如,我们要把delay_1s作为一个字状态机提供出去,我们可以在头文件里这么写:

extern_simple_fsm( delay_1s,
    def_params(
        uint32_t wCounter;                  
    )
)

好吧,我承认,其实就是把定义的部分又抄了一遍并加了一个extern_的前缀,简单吧?通过上面的 宏定义,容易发现,因为使用了掩码结构体的形式,所以使用者是无法直接访问控制块内的成员变量的。
至此,控制块定义、使用和优化的部分我们就解释完毕了。如果你有任何疑问,欢迎跟贴讨论。

最后谈谈设计思维和哲学

这个状态机模板从发布第一个版本到小范围试用已经过去大半年了,其间,我被问得最多的问题是:
“你这已经不是C语言了”、“你实际上是制作了另外一个状态机脚本语言语法”、“为什么要做一个四不像的东西呢?”、“这个模板本质上和protoThread一样,你为什么要重复发明轮子呢?” 针对这些大家感兴趣的问题,如果我不从设计思维的角度给出答案,这个模板是很难让人接受的。下面我就以上问题,从设计思维上给出一个系统的答案:

首先,C语言原生态就不支持状态机,用C语言实现的状态机,本质上只是一种模拟。这跟C语言并不原生态支持面向对象,如果真的要大量使用面向对象进行编程,最好的办法是使用C++,而不使用OOPC去模拟是一样的——为什么呢?因为程序设计要专注于“应用逻辑的实现”本身,应该尽量避免被“某种技术”分心——对需要大量使用面向对象技术进行开发的程序来说,应用逻辑是我们应该更多关心的,而使用C模拟OO则是需要避免的。

同样的问题发生在状态机上,C语言不仅不支持状态机,甚至我们模拟状态机的技术本身也相当复杂、混乱。不像面向对象有C++,状态机的开发并没有一种语言与C具有传承关系(别说verlog,谢谢,有本事你去找个verlog编译器,编译出来的机器码主流MCU都能运行的)。这可怎么办呢?回到我们的目的本身:

程序设计要专注于“应用逻辑的实现”本身,应该尽量避免被“某种技术”分心

为了达到这个目的,一个可行的方案就是想方设法构造一种基于C语言的 “脚本语言”,使得状态机的开发者得以关注“状态机应用逻辑的实现”,而不必关心“状态机具体是如何使用C语言进行构造的”。也就是说,从一开始我们建立这个模板的目的就是要构造一种 状态机专用的脚本语言,使得这种语言可以极大的简化状态机的开发和表达。这种脚本语言根本就不用“看起来是C语言”,因为它从一开始就不是C语言。
另一方面,新的脚本语言在使用时,应该能“无缝”的与其它C语言代码(函数)融合在一起,这表现于:状态机的调用、参数传递、基本类似C的函数调用。简而言之,新的脚本语言:

设计的时候看起来是状态机,使用的时候看起来就像C语言

这与C++设计的时候是面向对象,使用的时候(可以)看起来就像C语言是类似的。基于上述思想,我们得以“狡辩说”:现在的状态机模板导致的结果是一个对C很友好的状态机脚本语言,而不是一个用C实现的“四不像”——当然,这对一部分人来说“其狡辩的本质是不随个人意志转移而改变的” 。
针对和protoThread技术原理类似的问题,其实如果你真的使用过protoThread就会发现,这两个模板在出发点上就是截然相反的:

  • protoThread 试图让人产生“我是在使用RTOS进行线程开发”的错觉,它极力隐藏的是它“状态机的本质”
  • simple fsm 从一开始,就让开发人员明确知道“我是在开发状态机”

足可见,虽然技术原理相同,但思维不同,最终使用的设计哲学也大相径庭。

最后,一个决定性的因素说明 simple fsm 不是一个简单的模板而是一个“新的(基于C的)脚本语言”,即simple fsm 使用了面向对象技术来封装状态机,这就从根本上决定了它不只是一种设计状态机的方式,而是一整套面向对象状态机设计的哲学,比如:

  • 一个状态机就是一个类
  • 状态机函数只是这个类的一个方法
  • 状态机所要用到的变量都作为成员变量封装在类中(每个状态机都有自己的上下文)
  • 状态机及其数据被封装在一起,且对外界提供私有化保护(掩码结构体实现的private)
  • 状态机类是可以多实例的
  • 每个状态机从一开始就是一个任务(有自己的上下文——注意,这里的上下文是一个广义的概念,并不局限于stack)
  • 支持面向对象开发带来的种种好处
  • 支持面向接口开发(注意,面向接口开发不是面向对象的专利)

综上所述:使用simple fsm开发的时候,我们只关心状态机如何设计,这也是为什么写出来的代码 从字面上看 更像状态机而不是C语言;而调用状态机的时候,又对C语言很友好——这当然是个优点。另外,如果你并不知道如何设计状态机,也不喜欢,那么推荐你用protoThread或者干脆RTOS,因为你用simple fsm就要清楚你写的就是TMD状态机!

欢迎大家踊跃讨论,拍砖。
—— 傻孩子 吐槽于 2017-10-14日夜

猜你喜欢

转载自blog.csdn.net/sinat_31039061/article/details/106041589