状态机:一种裸机编程框架的介绍与应用实例
嵌入式之入坑笔记 2023-11-17

不知道大家有没有这样一种感觉,就是感觉自己玩单片机还可以,各个功能模块也都会驱动,但是如果让你完整的写一套代码,却无逻辑与框架可言,上来就是开始写!东抄抄西抄抄。说明编程还处于比较低的水平,那么如何才能提高自己的编程水平呢?学会一种好的编程框架或者一种编程思想,可能会受用终生!比如模块化编程,框架式编程,状态机编程等等,都是一种好的框架。今天说的就是状态机编程,由于篇幅较长,大家慢慢欣赏。那么状态机是一个这样的东东?状态机(state machine)有5个要素,分别是
1)状态(state)2)迁移(transition)3)事件(event)4)动作(action)5)条件(guard)


1、什么是状态机?

状态机是一个这样的东东:状态机(state machine)有 5 个要素,分别是状态(state)、迁移(transition)、事件(event)、动作(action)、条件(guard)。状态:一个系统在某一时刻所存在的稳定的工作情况,系统在整个工作周期中可能有多个状态。例如一部电动机共有正转、反转、停转这 3 种状态。一个状态机需要在状态集合中选取一个状态作为初始状态。迁移:系统从一个状态转移到另一个状态的过程称作迁移,迁移不是自动发生的,需要外界对系统施加影响。停转的电动机自己不会转起来,让它转起来必须上电。事件:某一时刻发生的对系统有意义的事情,状态机之所以发生状态迁移,就是因为出现了事件。对电动机来讲,加正电压、加负电压、断电就是事件。动作:在状态机的迁移过程中,状态机会做出一些其它的行为,这些行为就是动作,动作是状态机对事件的响应。给停转的电动机加正电压,电动机由停转状态迁移到正转状态,同时会启动电机,这个启动过程可以看做是动作,也就是对上电事件的响应。条件:状态机对事件并不是有求必应的,有了事件,状态机还要满足一定的条件才能发生状态迁移。还是以停转状态的电动机为例,虽然合闸上电了,但是如果供电线路有问题的话,电动机还是不能转起来。只谈概念太空洞了,上一个小例子:一单片机、一按键、俩 LED 灯(记为L1和L2)、一人, 足矣!

规则描述:

1、L1L2状态转换顺序OFF/OFF--->ON/OFF--->ON/ON--->OFF/ON--->OFF/OFF2、通过按键控制L1L2的状态,每次状态转换需连续按键5次3、L1L2的初始状态OFF/OFF下面这段程序是根据功能要求写成的代码。
void main(void){ sys_init(); led_off(LED1); led_off(LED2); g_stFSM.u8LedStat = LS_OFFOFF; g_stFSM.u8KeyCnt = 0;  while(1) { if(test_key()==TRUE) { fsm_active(); } else { ; /*idle code*/ } }} void fsm_active(void){ if(g_stFSM.u8KeyCnt > 3) /*击键是否满 5 次*/ { switch(g_stFSM.u8LedStat) { case LS_OFFOFF: led_on(LED1); /*输出动作*/ g_stFSM.u8KeyCnt = 0; g_stFSM.u8LedStat = LS_ONOFF; /*状态迁移*/ break; case LS_ONOFF: led_on(LED2); /*输出动作*/ g_stFSM.u8KeyCnt = 0; g_stFSM.u8LedStat = LS_ONON; /*状态迁移*/ break; case LS_ONON: led_off(LED1); /*输出动作*/ g_stFSM.u8KeyCnt = 0; g_stFSM.u8LedStat = LS_OFFON; /*状态迁移*/ break; case LS_OFFON: led_off(LED2); /*输出动作*/ g_stFSM.u8KeyCnt = 0; g_stFSM.u8LedStat = LS_OFFOFF; /*状态迁移*/ break; default: /*非法状态*/ led_off(LED1); led_off(LED2); g_stFSM.u8KeyCnt = 0; g_stFSM.u8LedStat = LS_OFFOFF; /*恢复初始状态*/ break; } } else { g_stFSM.u8KeyCnt++; /*状态不迁移,仅记录击键次数*/ }}
实际上在状态机编程中,正确的顺序应该是先有状态转换图,后有程序,程序应该是根据设计好的状态图写出来的。不过考虑到有些童鞋会觉得代码要比转换图来得亲切,我就先把程序放在前头了。这张状态转换图是用UML(统一建模语言)的语法元素画出来的,语法不是很标准,但拿来解释问题足够了。


2、状态机编程的优点

说了这么多,大家大概明白状态机到底是个什么东西了,也知道状态机化的程序大体怎么写了,那么单片机的程序用状态机的方法来写有什么好处呢?

(1)提高CPU使用效率

话说我只要见到满篇都是delay_ms()的程序就会蛋疼,动辄十几个ms几十个ms的软件延时是对CPU资源的巨大浪费,宝贵的CPU机时都浪费在了NOP指令上。那种为了等待一个管脚电平跳变或者一个串口数据而岿然不动的程序也让我非常纠结,如果事件一直不发生,你要等到世界末日么?把程序状态机化,这种情况就会明显改观,程序只需要用全局变量记录下工作状态,就可以转头去干别的工作了,当然忙完那些活儿之后要再看看工作状态有没有变化。只要目标事件(定时未到、电平没跳变、串口数据没收完)还没发生,工作状态就不会改变,程序就一直重复着“查询—干别的—查询—干别的”这样的循环,这样CPU就闲不下来了。在程序清单 List3 中,if{}else{}语句里else下的内容(代码中没有添加,只是加了一条/*idle code*/的注释示意)就是上文所说的“别的工作” 。这种处理方法的实质就是在程序等待事件的过程中间隔性地插入一些有意义的工作,好让CPU不是一直无谓地等待。

(2) 逻辑完备性

我觉得逻辑完备性是状态机编程最大的优点。不知道大家有没有用C语言写过计算器的小程序,我很早以前写过,写出来一测试,那个惨不忍睹啊!当我规规矩矩的输入算式的时候,程序可以得到正确的计算结果,但要是故意输入数字和运算符号的随意组合,程序总是得出莫名其妙的结果。后来我试着思维模拟一下程序的工作过程,正确的算式思路清晰,流程顺畅,可要碰上了不规矩的式子,走着走着我就晕菜了,那么多的标志位,那么多的变量,变来变去,最后直接分析不下去了。很久之后我认识了状态机,才恍然明白,当时的程序是有逻辑漏洞的。如果把这个计算器程序当做是一个反应式系统,那么一个数字或者运算符就可以看做一个事件,一个算式就是一组事件组合。对于一个逻辑完备的反应式系统,不管什么样的事件组合,系统都能正确处理事件,而且系统自身的工作状态也一直处在可知可控的状态中。反过来,如果一个系统的逻辑功能不完备,在某些特定事件组合的驱动下,系统就会进入一个不可知不可控的状态,与设计者的意图相悖。状态机就能解决逻辑完备性的问题。状态机是一种以系统状态为中心,以事件为变量的设计方法,它专注于各个状态的特点以及状态之间相互转换的关系。状态的转换恰恰是事件引起的,那么在研究某个具体状态的时候,我们自然而然地会考虑任何一个事件对这个状态有什么样的影响。这样,每一个状态中发生的每一个事件都会在我们的考虑之中,也就不会留下逻辑漏洞。这样说也许大家会觉得太空洞,实践出真知,某天如果你真的要设计一个逻辑复杂的程序,我保证你会说:哇!状态机真的很好用哎!

(3)程序结构清晰

用状态机写出来的程序的结构是非常清晰的。程序员最痛苦的事儿莫过于读别人写的代码。如果代码不是很规范,而且手里还没有流程图,读代码会让人晕了又晕,只有顺着程序一遍又一遍的看,很多遍之后才能隐约地明白程序大体的工作过程。有流程图会好一点,但是如果程序比较大,流程图也不会画得多详细,很多细节上的过程还是要从代码中理解。相比之下,用状态机写的程序要好很多,拿一张标准的UML状态转换图,再配上一些简明的文字说明,程序中的各个要素一览无余。程序中有哪些状态,会发生哪些事件,状态机如何响应,响应之后跳转到哪个状态,这些都十分明朗,甚至许多动作细节都能从状态转换图中找到。可以毫不夸张的说,有了UML状态转换图,程序流程图写都不用写。

3、状态机的三种实现方

状态机的实现无非就是 3 个要素:状态事件响应。转换成具体的行为就 3 句话。
1)发生了什么事?2)现在系统处在什么状态?3)在这样的状态下发生了这样的事,系统要干什么?
用 C 语言实现状态机主要有 3 种方法:switch—case 法表格驱动法函数指针法

(1)switch—case 法

状态用 switch—case 组织起来, 将事件也用switch—case 组织起来, 然后让其中一个 switch—case 整体插入到另一个 switch—case 的每一个 case 项中 。
switch(StateVal){ case S0: switch(EvntID) { case E1: action_S0_E1(); /*S0 状态下 E1 事件的响应*/ StateVal = new state value;/*状态迁移,不迁移则没有此行*/ break; case E2: action_S0_E2(); /*S0 状态下 E2 事件的响应*/ StateVal = new state value; break; ...... case Em: action_S0_Em(); /*S0 状态下 Em 事件的响应*/ StateVal = new state value; break; default: break; } break; case S1: ...... break; ...... case Sn: ...... break; default: break;}
上面的伪代码示例只是通用的情况,实际应用远没有这么复杂。虽然一个系统中事件可能有很多种,但在实际应用中,许多事件可能对某个状态是没有意义的。例如在程序清单 List4中,如果 E2、······ Em 对处在 S0 状态下的系统没有意义,那么在 S0 的 case 下有关事件E2、······ Em 的代码根本没有必要写,状态 S0 只需要考虑事件 E1 的处理就行了。既然是两个 switch—case 之间的嵌套, 那么就有一个谁嵌套谁的问题, 所以说 switch—case法有两种写法:状态嵌套事件和事件嵌套状态。这两种写法都可以, 各有利弊, 至于到底选用哪种方式就留给设计人员根据具体情况自行决断吧。关于 switch—case 法还有最后一点要说明, 因为 switch—case 的原理是从上到下挨个比较,越靠后,查找耗费的时间就越长,所以要注意状态和事件在各自的 switch 语句中的安排顺序,出现频率高或者实时性要求高的状态和事件的位置应该尽量靠前。

(2)表格驱动法

如果说 switch—case 法是线性的,那么表格驱动法则是平面的。表格驱动法的实质就是将状态和事件之间的关系固化到一张二维表格里, 把事件当做纵轴,把状态当做横轴,交点[Sn , Em]则是系统在 Sn 状态下对事件 Em 的响应 。

如上图, 我把表格中的 Node_SnEm 叫做状态机节点, 状态机节点 Node_SnEm 是系统在 Sn状态下对事件 Em 的响应。这里所说的响应包含两个方面:输出动作和状态迁移。状态机节点一般是一个类似结构体的变量 ,如下:

struct fsm_node{void (*fpAction)(void* pEvnt); INT8U u8NxtStat;};
程序中的这个结构体有两个成员:fpAction 和 u8NxtStat。fpAction 是一个函数指针, 指向一个形式为 void func(void * pEvnt)的函数, func 这个函数是对状态转移中动作序列的标准化封装。也就是说, 状态机在状态迁移的时候, 不管输出多少个动作、操作多少个变量、调用多少个函数,这些行为统统放到函数 func 中去做。把动作封装好了之后,再把封装函数 func 的地址交给函数指针 fpAction,这样,想要输出动作,只需要调用函数指针 fpAction 就行了。再看看上面的 func 函数,会发现函数有一个形参 pEvnt,这是一个类型为 void * 的指针, 在程序实际运行时指向一个能存储事件的变量,通过这个指针我们就能获知关于事件的全部信息,这个形参是很有必要的。事件一般包括两个属性:事件的类型和事件的内容。例如一次按键事件,我们不仅要知道这是一个按键事件,还要知道按下的到底是哪个键。事件的类型和状态机当前的状态可以让我们在图 4 的表格中迅速定位,确定该调用哪个动作封装函数, 但是动作封装函数要正确响应事件还需要知道事件的内容是什么, 这也就是形参pEvnt 的意义。由于事件的多样性,存储事件内容的数据格式不一定一样,所以就把 pEvnt 定义成了 void * 型,以增加灵活性。有关 fpAction 的最后一个问题:如果事件 Em 对状态 Sn 没有意义,那么状态机节点Node_SnEm 中的 fpAction 该怎么办?我的答案是:那就让它指向一个空函数呗!前面不是说过么,什么也不干也叫响应。u8NxtStat 存储的是状态机的一个状态值。我们知道, 状态机响应事件要输出动作, 也就是调用函数指针 fpAction 所指向的那个封装函数, 函数调用完毕后程序返回主调函数, 状态机对事件的响应就算结束了, 下一步就要考虑状态迁移的问题了。可能要保持本状态不变, 也可能要迁移到一个新的状态,该如何抉择呢?u8NxtStat 存储的状态就是状态机想要的答案!表格驱动法查找目标实际上就是一次二维数组的寻址操作,所以它的平均效率要远高于switch—case 法。

(3)函数指针法

上面说过,用 C 语言实现状态机主要有 3 种方法(switch—case 法、表格驱动法、函数指针法), 其中函数指针法是最难理解的, 它的实质就是把动作封装函数的函数地址作为状态来看待。不过,有了之前压缩表格驱动法的铺垫,函数指针法就变得好理解了,因为两者本质上是相同的。

压缩表格驱动法的实质就是一个整数值(状态机的一个状态)到一个函数地址(动作封装函数)的一对一映射, 压缩表格驱动法的驱动表格就是全部映射关系的直接载体。在驱动表格中通过状态值就能找到函数地址,通过函数地址同样能反向找到状态值。

我们用一个全局的整型变量来记录状态值,然后再查驱动表格找函数地址,那干脆直接用一个全局的函数指针来记录状态得了,还费那劳什子劲干吗?!这就是函数指针法的前世今生。

用函数指针法写出来的动作封装函数的返回值不再是整型的状态值, 而是下一个动作封装函数的函数地址, 函数返回后, 框架代码再把这个函数地址存储到全局函数指针变量中。

相比压缩表格驱动法,在函数指针法中状态机的安全运行是个大问题,我们很难找出一种机制来检查全局函数指针变量中的函数地址是不是合法值。如果放任不管, 一旦函数指针变量中的数据被篡改,程序跑飞几乎就不可避免了。


声明: 本文转载自其它媒体或授权刊载,目的在于信息传递,并不代表本站赞同其观点和对其真实性负责,如有新闻稿件和图片作品的内容、版权以及其它问题的,请联系我们及时删除。(联系我们,邮箱:evan.li@aspencore.com )
0
评论
  • 相关技术文库
  • C语言
  • 编程
  • 软件开发
  • 程序
下载排行榜
更多
评测报告
更多
广告