热文:状态机编程实例:嵌套switch-case法
在嵌入式软件开发中,状态机编程是一个比较实用的代码实现方式,特别适用于事件驱动的系统。
本篇将以一个炸弹拆除的小游戏为例,介绍一下状态机编程的思路。
【资料图】
C/C++语言实现状态机编程的方式有很多,本篇先来介绍最简单最容易理解的switch-case方法。
还有一个屏幕,用于显示倒计时时间,输入的拆除密码等。
游戏的玩法:
2、状态图
使用状态机思路进行编程,首先要画出对应的UML状态图,在画图之前,需要先明确此状态机有哪些状态,以及哪些事件。
对于本篇介绍的炸弹拆除小游戏,可以归纳为两个状态:
对于事件(或称信号),有3个按键事件,还有一个Tick节拍事件:
相关的结构定义如下:
// 炸弹状态机的所有状态enum BombStates{ SETTING_STATE, // 设置状态 TIMING_STATE // 倒计时状态};// 炸弹状态机的所有信号(事件)enum BombSignals{ UP_SIG, // UP键信号 DOWN_SIG, // DOWN键信号 ARM_SIG, // ARM键信号 TICK_SIG, // Tick节拍信号 SIG_MAX};
为了便于维护状态机所需要用到一些变量,可以将其定义为一个数据结构体,如下:
// 超时的初始值#define INIT_TIMEOUT 10// 炸弹状态机数据结构typedef struct Bomb1Tag{ uint8_t state; // 标量状态变量 uint8_t timeout; // 爆炸前的秒数 uint8_t code; // 当前输入的解除炸弹的密码 uint8_t defuse; // 解除炸弹的拆除密码 uint8_t errcnt; // 当前拆除失败的次数} Bomb1;
数据结构定义好之后,可以设计UML状态图了,关于UML状态图的画法与介绍,可参考之前的文章:UML状态图详解,这里使用visio画图。
分析这个状态图:
3、事件表示
对于上述的状态机事件,可以分为两类,一类是按键事件:UP、DOWN和ARM,一类是Tick。对于第一类事件,指需要单一的事件变量即可区分,对于第二类的Tick,由于引入了1/10s的精细时间,所以这个时间还需要一个额外的事件参数表示此次Tick事件的精细时间(fine_time)。
这里再介绍一个编程技巧,通过结构体的继承关系(实际就是嵌套),实现对事件数据结构的设计,如下图所示:
子图(a)表示TickEvt与Event是继承关系,这是UML类图的画法,关于UML类图的介绍可参考之前的文章:UML简介与类图详解。
子图(b)是这两个结构体的定义,可以看到TickEvt结构体内部的第1个成员,就是Event结构体,第2个成员,用于表示Tick事件的事件参数。
子图(c)是TickEvt数据结构在内存中的存储示意,先存储的是基类结构体的super实例,也就是Event这个结构体,然后存储的是子类结构的自定义成员,也就是Tick事件的事件参数fine_time。
这两个结构体的定义如下:
typedef struct EventTag{ uint16_t sig; // 事件的信号} Event;typedef struct TickEvtTag{ Event super; // 派生自Event结构 uint8_t fine_time; // 精细的1/10秒计数器} TickEvt;
这样定义的好处是,对于状态机事件调度函数Bomb1_dispatch的参数形式,可以统一使用(Event *)类型,将TickEvt类型传入时,可以取其地址,再转为(Event *)类型,如下面实例代码中loop函数中的使用;而在Bomb1_dispatch函数内部需要处理TICK_SIG事件时,又可以再将(Event *)类型强制转为(TickEvt *)类型,如下面实例代码中Bomb1_dispatch函数中的使用。
//状态机事件调度void Bomb1_dispatch(Bomb1 *me, Event const *e){ //省略... case TICK_SIG: //Tick信号 { if (((TickEvt const *)e)->fine_time == 0) { --me->timeout; bsp_display_remain_time(me->timeout); //显示倒计时时间 if (me->timeout == 0) { bsp_display_bomb(); //显示爆炸效果 Bomb1_init(me); } } break; } //省略...}//状态机循环void loop(void){ static TickEvt tick_evt = {TICK_SIG, 0}; delay(100); /*状态机以100ms的循环运行*/ if (++tick_evt.fine_time == 10) { tick_evt.fine_time = 0; } Bomb1_dispatch(&l_bomb, (Event *)&tick_evt); /*调度处理tick事件*/ //省略...}
二、switch-case嵌套法
状态图设计好之后,就可以对照着状态图,进行编程实现了。
本篇先使用最简单最容易理解的switch-case方法,来实现状态机编程。
1、状态机处理
使用switch-case法实现状态机,一般需要两层switch结构。
(1)第一层switch处理状态
void Bomb1_dispatch(Bomb1 *me, Event const *e){ //第一层switch处理状态 switch (me->state) { //设置状态 case SETTING_STATE: { //... break; } //倒计时状态 case TIMING_STATE: {//... break; } }}
(2)第二层switch处理事件
这里以状态机处于“设置状态”时,对事件(信号)的处理为例。
//设置状态case SETTING_STATE:{ //第二层switch处理事件(信号) switch (e->sig) { //UP按键信号 case UP_SIG: { //... break; } //DOWN按键信号 case DOWN_SIG: { //... break; } //ARM按键信号 case ARM_SIG: { //... break; } } break;}
(3)两层switch-case状态机完整代码
// 用于进行状态转换的宏#define TRAN(target_) (me->state = (uint8_t)(target_))//状态机事件调度void Bomb1_dispatch(Bomb1 *me, Event const *e){ //第一层switch处理状态 switch (me->state) { //设置状态 case SETTING_STATE: { //第二层switch处理事件(信号) switch (e->sig) { //UP按键信号 case UP_SIG: { if (me->timeout < 60) { ++me->timeout; //设置超时时间+1 bsp_display_set_time(me->timeout); //显示设置的超时时间 } break; } //DOWN按键信号 case DOWN_SIG: { if (me->timeout > 1) { --me->timeout; //设置超时时间-1 bsp_display_set_time(me->timeout); //显示设置的超时时间 } break; } //ARM按键信号 case ARM_SIG: { me->code = 0; TRAN(TIMING_STATE); //转换到倒计时状态 break; } } break; } //倒计时状态 case TIMING_STATE: { switch (e->sig) { case UP_SIG: //UP按键信号 { me->code <<= 1; me->code |= 1; //添加一个1 bsp_display_user_code(me->code); break; } case DOWN_SIG: //DWON按键信号 { me->code <<= 1; //添加一个0 bsp_display_user_code(me->code); break; } case ARM_SIG: //ARM按键信号 { if (me->code == me->defuse) { TRAN(SETTING_STATE); //转换到设置状态 bsp_display_user_success(); //炸弹拆除成功 Bomb1_init(me); } else { me->code = 0; bsp_display_user_code(me->code); bsp_display_user_err(++me->errcnt); } break; } case TICK_SIG: //Tick信号 { if (((TickEvt const *)e)->fine_time == 0) { --me->timeout; bsp_display_remain_time(me->timeout); //显示倒计时时间 if (me->timeout == 0) { bsp_display_bomb(); //显示爆炸效果 Bomb1_init(me); } } break; } } break; } }}
两层switch-case状态机逻辑编写好之后,还需要将状态机运行起来。
运行状态机的本质,就是周期性的调用状态机(上面实现的两层switch-case),当有事件触发时,设置对应的事件,状态机在运行时,即可处理对应的事件,从而实现状态的切换,或是其它的逻辑处理。
(1)状态机的运行
状态机运行的整体逻辑如下:
void loop(void){ static TickEvt tick_evt = {TICK_SIG, 0}; delay(100); /*状态机以100ms的循环运行*/ if (++tick_evt.fine_time == 10) { tick_evt.fine_time = 0; } char tmp_buffer[256]; sprintf(tmp_buffer, "T(%1d)%c", tick_evt.fine_time, (tick_evt.fine_time == 0) ? "\n" : " "); Serial.print(tmp_buffer); Bomb1_dispatch(&l_bomb, (Event *)&tick_evt); /*调度处理tick事件*/ BombSignals userSignal = bsp_key_check_signal(); if (userSignal != SIG_MAX) { static Event const up_evt = {UP_SIG}; static Event const down_evt = {DOWN_SIG}; static Event const arm_evt = {ARM_SIG}; Event const *e = (Event *)0; switch (userSignal) { //监测按键是否按下, 按下则设置对应的事件e } if (e != (Event *)0) /*有指定的按键按下*/ { Bomb1_dispatch(&l_bomb, e); /*调度处理按键事件*/ } }}
(2)事件的触发
在状态机的每个状态循环执行前,都检测一下是否有事件触发,本例中就是UP、DOWN和ARM的按键事件,另外Tick事件是周期性的触发的。UP、DOWN和ARM的按键事件的触发检测代码如下,检测到对应的按键事件后,则设置对应的事件给状态机,状态机即可在下次状态循环中进行处理。
switch (userSignal){ case UP_SIG: //UP键事件 { Serial.print("\nUP : "); e = &up_evt; break; } case DOWN_SIG: //DOWN键事件 { Serial.print("\nDOWN: "); e = &down_evt; break; } case ARM_SIG: //ARM键事件 { Serial.print("\nARM : "); e = &arm_evt; break; } default:break;}
三、测试
本例程,使用Arduino作为控制器进行测试,外接3个独立按键和一个IIC接口的OLED显示屏。
演示视频:
四、总结
本篇以一个炸弹拆除的小游戏为例,介绍了嵌入式软件开发中,状态机编程的思路:
另外,本篇中还需要体会的是,对事件的表示,通过结构体继承(嵌套)的方式,实现一个额外的事件参数这种用法。