上篇文章,使用嵌套switch-case法的状态机编程,实现了一个炸弹拆除小游戏。
本篇,继续介绍状态机编程的第二种方法:状态表法,来实现炸弹拆除小游戏的状态机编程。
1 状态表法
状态表法,顾名思义,就是通过一个状态表,来实现状态机中的状态转换,下面就先介绍下状态表的基础知识。
1.1 状态表
状态表 ,最常用的是使用一个2维状态表:
- 水平方向是各个事件
- 竖直方向是各个状态
- 单元的内容是通过(执行动作,下一状态)来表示各种转换关系
结合上一篇设计炸弹拆除小游戏的状态图(2个状态和4个事件):
可以设计出对应的状态表,如下图:
- 水平方向的4种事件:UP、DOWN和ARM按键事件,TICK事件
- 竖直方向的2种状态:设置状态和倒计时状态
- 单元的内容表示执行指定动作后,下一状态是什么。比如设置状态时按下UP键,执行setting_UP函数中的动作后,下一状态还是留在设置状态
注意:
- (*):仅当(me->code == me->defuse),即密码输入正确时,才进行状态转换至“设置状态”
- ( ):仅当(me->fine_time == 0)和(me->timeout != 0),即每过一秒且倒计时未减到0时,才进行状态转换至“倒计时状态”**
1.2 事件处理器
由于状态表法可以使用一个非常有规律的数据结构(状态表)来表现一个状态机,因此编程时可以编写一个通用的“事件处理器”来实现状态机功能。
如下图,通用的状态表事件处理器,包含两个主要结构:
- 一个外部转换的StateTable结构
- 一个带有事件参数和没有事件参数的Event结构
此外,StateTable结构有两个相关的函数:
- init()函数用于触发状态机的初始转换
- dispatch()函数用于派送一个事件给状态机处理
需体会的是,StateTable结构是一个抽象的结构,按照UML类图的画法,这是一个抽象类(使用《abstract》或斜体类名表示),需要通过派生出一个实例类,如图中的Bomb2,来实现具体的业务功能。
在状态机的应用程序中,状态表仅包含执行转换函数的指针,即函数指针,而不是(执行动作,下一状态)的形式,使用这种方式,实际就是把状态改变的逻辑,放到了转换函数中,这样做,使得编程更加灵活,因为状态函数能方便地判断某些监护条件并随之改变。
2 状态表法的实现
上面介绍了状态表法的基础知识,下面就来通过代码来介绍状态表法的具体实现。
2.1 通用状态表事件处理器
上面说到,状态表法可以使用一个非常有规律的状态表数据结构来表现一个状态机,因而在程序设计时,可以编写一个通用的状态表事件处理器。
2.1.1 接口定义
通用的状态表事件处理器,先来通过接口定义,看下它的功能。
注意上面提到的它包含两个主要结构:
- 一个外部转换的StateTable结构
- 一个带有事件参数和没有事件参数的Event结构
以及StateTable结构的两个相关的函数:
- init()函数:用于触发状态机的初始转换
- dispatch()函数:用于派送一个事件给状态机处理
// 用于进行状态转换的宏
#define TRAN(target) (((StateTable *)me)- >state = (uint8_t)(target))
typedef struct EventTag
{
uint16_t sig; // 事件的信号
} Event;
struct StateTableTag; //提前声明此变量
// 函数指针
typedef void (*Tran)(struct StateTableTag *me, Event const *e);
// 状态表数据结构
typedef struct StateTableTag
{
uint8_t state; //当前状态
Tran const *state_table; //状态表
uint8_t n_states; //状态的个数
uint8_t n_signals; //事件(信号)的个数
Tran initial; //初始转换
} StateTable;
void StateTable_ctor(StateTable *me, Tran const *table, uint8_t n_states, uint8_t n_signals, Tran initial);
void StateTable_init(StateTable *me);
void StateTable_dispatch(StateTable *me, Event const *e);
void StateTable_empty(StateTable *me, Event const *e);
StateTable_ctor是状态表的“构造函数”,仅指向一个基本的初始化动作,不会触发初始转换。
StateTable_empty是一个默认的空动作,用于状态表初始化时,某些需要空单元的地方使用。
另外,这里还要体会函数指针的用法。什么是函数指针,下面再来复习一下。
2.1.2 体会函数指针的用法
函数指针,本质是一个指针,其指向的一个函数,其类型定义为:
返回值类型 (* 函数名) ([形参列表]);
注意和指针函数的区别:
何为指针函数?
*指针函数,本质是一个函数,例如 int pfun(int, int); 其返回值是指针类型,即返回一个指针(或称地址),这个指针指向的数据是什么类型都可以。
一个记忆小技巧:指针函数,可以类比int函数,它们都是函数,只是返回值不一样,一个是返回指针,一个返回int。
首先来看函数指针的定义,以及基础用法:
//定义一个函数指针pFUN,它指向一个返回类型为void,有一个参数类型为int的函数
void (*pFun)(int);
//定义一个返回类型为void,参数为int的函数。从指针层面上理解该函数,其函数名实际上是一个指针,该指针指向函数在内存中的首地址
void glFun(int a)
{
printf("%d
", a);
}
int main()
{
pFun = glFun; //将函数glFun的地址赋值给变量pFun
(*pFun)(2);//“*pFun”是取pFun所指向地址的内容,即取出了函数glFun()的内容,然后给定参数为2
return 0;
}
实际使用时,常常通过typedef的方式让函数指针更直观方便的进行使用:
//定义新的类型PTRFUN, 此类型的实际含义为函数指针,指向的函数的返回值是void,参数是int
typedef void (*PTRFUN)(int);
//定义一个返回类型为void,参数为int的函数
void glFun(int a)
{
printf("%d
", a);
}
int main()
{
PTRFUN pFun; //使用定义的(函数指针)类型,实例化一个函数指针
pFun = glFun; //把定义的glFun函数,以函数名(本质即指针)的形式为其赋值
(*pFun)(2); //执行该函数指针指向的内容,即指向指向的函数,并指定参数2
return 0;
}
关于函数指针的实际应用,也可参考我之前的这篇文章: STM32简易多级菜单(数组查表法)
2.1.3 具体实现
看完了通用的状态表事件处理器的接口定义,下面再来看下具体实现。
//状态表的构造
void StateTable_ctor(StateTable *me,
Tran const *table, uint8_t n_states, uint8_t n_signals,
Tran initial)
{
//第一个参数me为StateTable结构,由具体业务的派生状态表的tateTable结构传入
me- >state_table = table; //状态表, 由具体业务的二维状态表传入
me- >n_states = n_states; //二维状态表的状态数量
me- >n_signals = n_signals; //二维状态表的信号(事件)数量
me- >initial = initial; //状态表的初始准换函数
}
//状态表的初始化
void StateTable_init(StateTable *me)
{
me- >state = me- >n_states;
(*me- >initial)(me, (Event *)0); //初始转换
assert(me- >state < me- >n_states); //确保事件范围的合理
}
//状态表的调度(派送一个事件给状态机处理)
void StateTable_dispatch(StateTable *me, Event const *e)
{
Tran t;
assert(e- >sig < me- >n_signals); //确保信号范围的合理
//通过当前状态与当前的信号,以及信号的总数,计算得到状态表中要执行的转换函数在状态表(二维的函数指针数组)中的位置
t = me- >state_table[me- >state * me- >n_signals + e- >sig];
(*t)(me, e); //然后执行转换函数
assert(me- >state < me- >n_states); //确保状态范围的合理
}
//状态表的空元素
void StateTable_empty(StateTable *me, Event const *e)
{
(void)me; //用于消除参数未使用的警告
(void)e;
}
这里要体会一下状态表的调度,即派送一个事件给状态机处理的代码逻辑,StateTable_dispatch的两个参数,一个是StateTable结构的二维表,一个是Event结构的信号(事件),注意这个二维状态表,存储的函数指针(各种转换函数),所以是一个二维的函数指针数组,根据信号,如何知道要执行二维数组中的哪个函数呢?还要借助当前状态机所处的状态,即可通过简单的数学运算得出,示意如下图:
2.2 应用逻辑(具体业务代码)
看完了通用的状态表事件处理器,就可以在此基础上,编写具体的状态机业务代码,实现上一篇介绍的炸弹拆除小游戏。
2.2.1 接口定义
还是先看下炸弹拆除小游戏这个具体业务逻辑用到的数据结构与接口定义,主要包括:
- 炸弹状态机的状态与信号(事件)
- 从状态表事件处理器的Event结构派生的带有事件参数的TickEvt结构
- 从状态表事件处理器的StateTable结构派生的具体的炸弹状态机数据结构
- 状态表中用到的所有的转换函数
// 炸弹状态机的所有状态
enum BombStates
{
SETTING_STATE, // 设置状态
TIMING_STATE, // 倒计时状态
STATE_MAX
};
// 炸弹状态机的所有信号(事件)
enum BombSignals
{
UP_SIG, // UP键信号
DOWN_SIG, // DOWN键信号
ARM_SIG, // ARM键信号
TICK_SIG, // Tick节拍信号
SIG_MAX
};
typedef struct TickEvtTag
{
Event super; // 派生自Event结构
uint8_t fine_time; // 精细的1/10秒计数器
} TickEvt;
// 炸弹状态机数据结构
typedef struct Bomb2Tag
{
StateTable super; // 派生自StateTable结构
uint8_t timeout; // 爆炸前的秒数
uint8_t code; // 当前输入的解除炸弹的密码
uint8_t defuse; // 解除炸弹的拆除密码
uint8_t errcnt; // 当前拆除失败的次数
} Bomb2;
//炸弹构造
void Bomb2_ctor(Bomb2 *me, uint8_t defuse);
//状态表中需要用到的转换函数(函数指针)
void Bomb2_initial(Bomb2 *me, Event const *e); //初始转换
void Bomb2_setting_UP(Bomb2 *me, Event const *e); //转换函数, 设置状态时, 处理UP事件
void Bomb2_setting_DOWN(Bomb2 *me, Event const *e); //转换函数, 设置状态时, 处理DOWN事件
void Bomb2_setting_ARM(Bomb2 *me, Event const *e); //转换函数, 设置状态时, 处理ARM事件
void Bomb2_timing_UP(Bomb2 *me, Event const *e); //转换函数, 倒计时状态时, 处理UP事件
void Bomb2_timing_DOWN(Bomb2 *me, Event const *e); //转换函数, 倒计时状态时, 处理DOWN事件
void Bomb2_timing_ARM(Bomb2 *me, Event const *e); //转换函数, 倒计时状态时, 处理ARM事件
void Bomb2_timing_TICK(Bomb2 *me, Event const *e); //转换函数, 倒计时状态时, 处理Tick事件