用C语言写一个有限状态机(finite-state machine, FSM) – 无动态内存管理版本
0. FAQ
Q: 什么是FSM?
A: 有限状态机(finite-state machine, FSM),是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。看不懂?没关系,下面举一个例子吧:-)
如上图所示,这是通过一个按钮来控制灯状态的状态机,它总共有2个状态:灭灯状态、亮灯状态。其中,灭灯状态是默认状态(我这里用空状态指向它,来表示这个状态是默认状态),即执行这个状态机的时候会先默认为灭灯状态。2个状态之间的转移需要触发事件:按下按钮。状态机一旦检测到按下按钮这个事件被触发,就会执行状态之间的转移,例如从灭灯状态转移到亮灯状态,或从亮灯状态转移到灭灯状态
Q: FSM有什么用?
A: 用于简化和梳理复杂的流程控制代码!当遇到复杂的流程控制时,我们可以从流程中抽象出n个状态,确认好状态之间的转移条件,之后,我们一般只需要关心当前状态下需要执行的动作是什么,以及触发什么事件之后需要执行状态的转移就OK了。这里我们举一个游戏开发的例子吧:-D
假设我们现在是“超级⚪里奥兄弟”的游戏开发者,为了完成主角水管工马⚪奥大叔的动作控制代码:控制站立,跑步,跳跃这三个动作,我们画出了下面的状态转移图:
这里,马里⚪的默认状态是站立状态。从站立状态出发的转移事件有两个:移动键被按住、跳跃键被按下。在站立状态下,我们需要一直检测这两个事件。这里我们假设现在其中一个事件“移动键被按下”被触发了,那么状态机就执行状态转移,将当前的站立状态转移到跑步状态去。在完成转移之后,当前状态就变成了跑步状态。在跑步状态下,我们让代码执行跑步状态的动作即可(例如⚪里奥撒腿就往一个方向一直跑的动作)
有了FSM的帮助,流程的控制变得容易了起来,我们不需要时刻关心整一个流程要怎么控制,我们只需要关心当前状态存在哪些转移动作,以及在当前状态下需要做什么动作即可
Q: 道理我都懂,为什么要用C实现FSM啊?
A: 咳咳.……正好最近工作需要用到FSM来整理优化流程控制的代码,于是就顺便写一写博客了……
Q: (lll¬ω¬) 好吧,那为什么不做动态内存管理?
A: 也是工作原因_(:з)∠)_,单片机尽量不要做动态内存管理,因为内存太小了~
1. 用C实现一个FSM吧
按我自己的习惯,我喜欢把状态称为节点(FsmNode),状态之间的转移线称为线(FsmLine)或事件(FsmEvent),下面分类讨论实现(代码中注释较详细)
1.0 头文件
1.0.0 FSM参数设置 – FsmCfg.h
#ifndef FSM_CFG_H
#define FSM_CFG_H
#include <string.h> // 字符串操作
// #pragma warning(disable:4996) // 在vs下编译不通过可以加上这一行
// typedef
typedef struct Fsm Fsm; // 有限状态机
typedef struct Fsm_ops Fsm_ops; // 有限状态机的成员函数列表
typedef struct FsmNode FsmNode; // 节点
typedef struct FsmLine FsmLine; // 线(事件)
typedef void (*FsmCall)(void); // 状态执行函数
typedef unsigned char FsmSize; // 节点或线的数量类型, unsigned char最大值是256, 在这里已经够用了
typedef enum { False = 0, True = 1 } Bool; // C中没有bool, true和false, 于是这里我自定义了一个
// 重要常量设置, 请根据需要设置合适的大小, 防止浪费内存
enum {
FsmName_MaxLen = 30, // 节点或线的名称的最大长度
FsmNode_MaxLineNum = 8, // 一个节点可存储的线的最大数量
Fsm_MaxNodeNum = 64 // 状态机内可存储的节点的最大数量
};
#define FsmNone "None" // 空节点或空线的名称定义
#endif // FSM_CFG_H
在FsmCfg.h中,完成了几个结构体的名称、一些类型和常量的定义。用于没有做动态内存管理,所以这里用几个常量值手动确定状态机的大小
1.0.1 FSM节点 – FsmNode.h
#ifndef FSM_NODE_H
#define FSM_NODE_H
#include "FsmCfg.h"
struct FsmNode { // 节点
char name[FsmName_MaxLen]; // 节点名称
FsmLine* lines[FsmNode_MaxLineNum]; // 与该节点链接的线, 因为不做动态内存管理, 所以先定好最大数量
FsmSize lineSize; // 记录线的数量
FsmCall Func; // 处于该节点时持续运行的函数
};
void FsmNode_Init( // 初始化节点
FsmNode* this, // 要初始化的节点
const char name[FsmName_MaxLen], // 节点名称
FsmCall Func // 处于该节点时周期运行的函数
);
#endif // FSM_NODE_H
在FsmNode.h中,完成了节点结构体FsmNode的定义,以及声明初始化节点的函数FsmNode_Init(后面1.1源文件中讲解其定义)
需要注意的是,节点内只存储从该节点出发的线,例如上面举的例子中,从跑步状态节点出发的线有两条:移动键松开、跳跃键被按下,而移动键被按下这一条线因为不是从跑步状态出发的,所以不会被储存到跑步状态节点内。结构体内,Func是在处于该节点时回持续运行的函数,例如处于跑步状态节点下,就会持续执行人物往一个方向奔跑位移的动作的函数了
1.0.2 FSM线 – FsmLine.h
#ifndef FSM_LINE_H
#define FSM_LINE_H
#include "FsmCfg.h"
struct FsmLine { // 线
FsmNode* prevNode; // 上一个节点
FsmNode* nextNode; // 下一个节点
char name[FsmName_MaxLen]; // 线名称
FsmCall Func; // 转移节点时执行的函数
};
void FsmLine_Init( // 线初始化
FsmLine* this, // 要初始化的线
FsmNode* prevNode, // 上一个节点
FsmNode* nextNode, // 下一个节点
const char name[FsmName_MaxLen], // 线名称
FsmCall Func // 转移节点时执行的函数
);
#endif // FSM_LINE_H
在FsmLine.h中,完成了线结构体FsmLine的定义,以及声明初始化节点的函数FsmLine_Init(后面1.1源文件中讲解其定义)
线结构体储存了它的上一个节点和下一个节点是什么,以及转移状态时执行的动作的函数
1.0.3 FSM – Fsm.h
#ifndef NFLIB_FSM_H
#define NFLIB_FSM_H
#include "FsmCfg.h"
#include "FsmNode.h"
#include "FsmLine.h"
struct Fsm { // 有限状态机
FsmNode* currNode; // 当前节点
FsmNode* nodes[Fsm_MaxNodeNum]; // 状态机内的节点
FsmSize size; // 节点数量
Fsm_ops* ops; // 成员函数列表
};
struct Fsm_ops { // 有限状态机的函数列表
Bool (*AddNode )(Fsm* this, FsmNode* node); // 添加节点
Bool (*AddLine )(Fsm* this, FsmLine* line); // 添加线
Bool (*Start )(Fsm* this, const char name[FsmName_MaxLen]); // 设置开始节点
Bool (*HandleEvent)(Fsm* this, const char name[FsmName_MaxLen]); // 处理事件
};
void Fsm_Init(Fsm* this); // 初始化有限状态机
#endif // NFLIB_FSM_H
在Fsm.h中,完成了有限状态机结构体Fsm的定义,有限状态机的函数列表结构体Fsm_ops的定义,以及声明初始化有限状态机的函数Fsm_Init(后面1.1源文件中讲解其定义)
在Fsm结构体中,我们需要记录当前节点currNode是什么,状态机内有哪些节点nodes,节点数量size,以及供用户调用的状态机的函数列表ops
在Fsm_ops结构体中,提供给了用户如下操作:添加节点AddNode,添加线AddLine,设置开始节点Start以及处理事件HandleEvent
1.1 源文件
1.1.0 Fsm节点初始化 – FsmNode.c
#include "FsmNode.h"
// 初始化节点
void FsmNode_Init(FsmNode* this, const char name[FsmName_MaxLen], FsmCall Func) {
FsmSize i = 0;
strcpy(this->name, name); // 设置节点名称
for (; i < FsmNode_MaxLineNum; ++i) { this->lines[i] = 0; } // 初始化线全为0
this->lineSize = 0; // 线数量为0
this->Func = Func; // 设置处于改节点时执行的函数
}
1.1.1 Fsm线初始化 – FsmLine.c
#include "FsmLine.h"
// 初始化线
void FsmLine_Init(
FsmLine* this,
FsmNode* prevNode,
FsmNode* nextNode,
const char name[FsmName_MaxLen],
FsmCall Func
) {
this->prevNode = prevNode; // 设置线的前一个节点
this->nextNode = nextNode; // 设置线的后一个节点
strcpy(this->name, name); // 设置线的名称
this->Func = Func; // 设置发送状态转移时执行的函数
}
1.1.2 Fsm和Fsm_ops初始化 – Fsm.c
#include "FSM.h"
// 用于简化代码
#define size this->size
#define nodes this->nodes
#define currNode this->currNode
// 添加节点
static Bool AddNode(Fsm* this, FsmNode* node) {
// 如果node数据为空, 或添加节点数量大于等于设定值, 则返回False
if (node == 0 || size >= Fsm_MaxNodeNum) { return False; }
nodes[size++] = node; // 往状态机内添加节点
return True;
}
// 添加线
static Bool AddLine(Fsm* this, FsmLine* line) {
FsmSize i = 0;
// 如果line数据为空, 或line没有指定前一个节点, 则返回False
if (line == 0 || line->prevNode == 0) { return False; }
// 遍历状态机内所有节点
for (; i < size; ++i) {
// 根据名称寻找到对应节点
if (nodes[i] != 0 && strcmp(line->prevNode->name, nodes[i]->name) == 0) {
// 如果node超出最大容量, 则返回False
if (nodes[i]->lineSize >= FsmNode_MaxLineNum) { return False; }
// 往该节点添加线
nodes[i]->lines[nodes[i]->lineSize++] = line;
return True;
}
}
return False;
}
// 设置开始节点
static Bool Start(Fsm* this, const char name[FsmName_MaxLen]) {
FsmSize i = 0;
// 遍历状态机内所有节点
for (; i < size; ++i) {
// 根据名称寻找到对应节点
if (nodes[i] != 0 && strcmp(name, nodes[i]->name) == 0) {
currNode = nodes[i]; // 设置为当前节点
return True;
}
}
currNode = 0;
return False;
}
// 处理事件, name为触发的事件的名称
static Bool HandleEvent(Fsm* this, const char name[FsmName_MaxLen]) {
FsmSize i = 0;
// 如果当前节点为空, 或当前节点执行函数为空, 则返回False
if (currNode == 0 || currNode->Func == 0) { return False; }
currNode->Func(); // 执行当前节点函数
// 如果处理的事件名称为None, 直接返回False
if (strcmp(name, FsmNone) == 0) { return False; }
// 遍历当前节点内的所有线
for (; i < currNode->lineSize; ++i) {
// 如果触发的事件名称和寻找到的线名称相同, 则执行状态转移
if (currNode->lines[i] != 0
&& strcmp(currNode->lines[i]->name, name) == 0
) {
// 如果有状态转移时要执行的函数, 就执行它
if (currNode->lines[i]->Func != 0) {
currNode->lines[i]->Func();
}
// 当前节点设置为这条线指向的下一个节点
currNode = currNode->lines[i]->nextNode;
// 然后执行这个新节点的函数
currNode->Func();
return True;
}
}
return False;
}
// 获取有限状态机函数列表的实例(此处用了设计模式中单例模式的做法)
static Fsm_ops* FsmOps(void) {
static Fsm_ops ops;
static Bool init = False;
if (init == False) {
init = True; // 第二次调用FsmOps函数时不用进行初始化了
ops.AddNode = AddNode; // 设置添加节点函数指针
ops.AddLine = AddLine; // 设置添加线函数指针
ops.Start = Start; // 设置默认节点函数指针
ops.HandleEvent = HandleEvent; // 设置处理事件函数指针
}
return &ops; // 返回这个实例
}
// 初始化状态机
void Fsm_Init(Fsm* this) {
FsmSize i = 0;
currNode = 0; // 默认当前节点为空
for (; i < Fsm_MaxNodeNum; ++i) { nodes[i] = 0; } // 清空状态机内所有节点
size = 0; // 节点数量为0
this->ops = FsmOps(); // 获取函数列表
}
// 记得撤销宏
#undef size
#undef nodes
#undef currNode
此处代码较长,我们一部分一部分来解释
-
#define
和#undef
首先,是一开始的3个
#define
,这里这样做是为了简化代码,做成类似于C++的成员函数可以省略this->
一样的效果。由于这3个宏只在Fsm.c文件中使用,所以使用完之后要用#undef
撤销这3个宏 -
AddNode
添加节点函数用于往状态机内添加节点,每添加一个,状态机节点数量自加1
-
AddLine
添加线函数用于往状态机内添加线,添加线的时候,会寻找状态机内与要添加的线的前一个节点名称相同的节点,然后在这个节点内添加这条线完成线和节点的连接
-
Start
设置默认节点设置状态机默认节点为指定名称的节点
-
HandleEvent
处理事件根据传入的事件名称来确定是否需要执行状态转移,如果不用转移,则执行当前节点的函数;如果需要转移,则先执行当前节点的函数,然后再进行状态转移,执行转移时的函数,完成转移后,执行转移后的当前节点的函数
-
FsmOps
获取状态机函数列表这里用于获取状态机的函数列表,Fsm_ops这个结构体的存在意义是把状态机的所有操作函数都封装起来,方便使用
(值得注意的是,这里使用了类似于设计模式中的单例模式的写法,单例模式以后有机会再讲解吧:-P)
-
Fsm_Init
初始化状态机初始化状态机,把状态机内的节点全部设置为空
1.3 测试用例
终于写完了有限状态机了,下面我们测试一下这个状态机执行起来是什么样子的吧!我们继续用前面的“超级⚪里奥兄弟”的状态转移图写一个例子(这里给出的是状态转移图的英文版,方便查看变量名称):
#include "FsmExample.h"
#include <stdio.h>
#include <NfLib/Fsm/Fsm.h>
// 处于该节点时调用的函数
static void Call_n_stand(void) { printf(">> n_stand\n"); }
static void Call_n_run (void) { printf(">> n_run \n"); }
static void Call_n_jump (void) { printf(">> n_jump \n"); }
// 状态转移被触发时调用的函数
static void Call_l_press_move (void) { printf(">> l_press_move \n"); }
static void Call_l_release_move (void) { printf(">> l_release_move \n"); }
static void Call_l_press_jump_at_stand(void) { printf(">> l_press_jump_at_stand\n"); }
static void Call_l_press_jump_at_run (void) { printf(">> l_press_jump_at_run \n"); }
static void Call_l_on_ground (void) { printf(">> l_on_ground \n"); }
void Fsm_Example(void) {
char event[30];
// 状态机变量声明
FsmNode n_stand;
FsmNode n_run ;
FsmNode n_jump ;
FsmLine l_press_move ;
FsmLine l_release_move ;
FsmLine l_press_jump_at_stand;
FsmLine l_press_jump_at_run ;
FsmLine l_on_ground ;
Fsm fsm;
// 初始化节点(&节点变量, 节点名称, 处于该节点时执行的函数);
FsmNode_Init(&n_stand , "n_stand", Call_n_stand);
FsmNode_Init(&n_run , "n_run" , Call_n_run );
FsmNode_Init(&n_jump , "n_jump" , Call_n_jump );
// 初始化线(&线变量, &上一个节点, &下一个节点, 线名称, 转移时执行的函数);
FsmLine_Init(&l_press_move , &n_stand, &n_run , "l_press_move" , Call_l_press_move );
FsmLine_Init(&l_release_move , &n_run , &n_stand, "l_release_move", Call_l_release_move );
FsmLine_Init(&l_press_jump_at_stand, &n_stand, &n_jump , "l_press_jump" , Call_l_press_jump_at_stand);
FsmLine_Init(&l_press_jump_at_run , &n_run , &n_jump , "l_press_jump" , Call_l_press_jump_at_run );
FsmLine_Init(&l_on_ground , &n_jump , &n_stand, "l_on_ground" , Call_l_on_ground );
// 添加节点和线到状态机内
Fsm_Init(&fsm);
fsm.ops->AddNode(&fsm, &n_stand);
fsm.ops->AddNode(&fsm, &n_run );
fsm.ops->AddNode(&fsm, &n_jump );
fsm.ops->AddLine(&fsm, &l_press_move );
fsm.ops->AddLine(&fsm, &l_release_move );
fsm.ops->AddLine(&fsm, &l_press_jump_at_stand);
fsm.ops->AddLine(&fsm, &l_press_jump_at_run );
fsm.ops->AddLine(&fsm, &l_on_ground );
// 设置默认节点的名称
fsm.ops->Start(&fsm, "n_stand");
// 处理事件
do {
fsm.ops->HandleEvent(&fsm, event); // 处理事件
printf("\n输入要触发的事件名称:");
} while (scanf("%s", event) != EOF);
}
执行结果如下:
>> n_stand
输入要触发的事件名称:l_press_move
>> n_stand
>> l_press_move
>> n_run
输入要触发的事件名称:l_release_move
>> n_run
>> l_release_move
>> n_stand
输入要触发的事件名称:l_press_jump
>> n_stand
>> l_press_jump_at_stand
>> n_jump
输入要触发的事件名称:l_on_ground
>> n_jump
>> l_on_ground
>> n_stand
输入要触发的事件名称:l_press_move
>> n_stand
>> l_press_move
>> n_run
输入要触发的事件名称:l_press_jump
>> n_run
>> l_press_jump_at_run
>> n_jump
好的,状态转移一切正确!在n_stand节点下触发事件l_press_move,成功从n_stand通过l_press_move转移到了n_run
那么本文就到这里结束了,编写本文时因为时间仓促,难免会有一些错误的、讲的不太好的地方,若发现有错误或者有改进的建议,欢迎大家评论留言指出,谢谢大家阅读,我们下篇文章再见_(:з)∠)_
参考资料:Unity游戏框架搭建(四)建议有限状态机 – 凉鞋
个人项目(完善中):https://github.com/NumbFish-Luo/NfLib
C语言的奇妙技法探索讨论群:810994327