用C语言写一个有限状态机(finite-state machine, FSM) -- 无动态内存管理版本

用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 测试用例

终于写完了有限状态机了,下面我们测试一下这个状态机执行起来是什么样子的吧!我们继续用前面的“超级⚪里奥兄弟”的状态转移图写一个例子(这里给出的是状态转移图的英文版,方便查看变量名称):

l_press_move
l_release_move
l_press_jump_at_stand
l_press_jump_at_run
l_on_ground
n_stand
n_run
n_jump
#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

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
#include int main() { int state_key = 1; //钥匙状态 为1时钥匙区有钥匙,为时钥匙区 无钥匙 int state_hold = 0; // 钥匙持有状态 为1时持有钥匙,为时 未持有钥匙 int state_door = 0; //门状态 0:关闭 1:打开 int state_lock = 1; //上锁状态 1:上锁 0:解锁 int order; // 用于存放用户输入指令 printf("this is a game.\n"); printf ("if you want to OPEN THE DOOR ,input 1\n"); printf ("if you want to CLOSE THE DOOR ,input 2\n"); printf ("if you want to LOCK THE DOOR ,input 3\n"); printf ("if you want to UNLOCK THE DOOR,input 4\n"); // printf ("if you want to LOCK THE DOOR ,input 5\n"); printf("please input the order\n"); while(1) { scanf("%d",&order); // if(order!=(1||2||3||4)) // { // printf("worng input ,please input again.\n"); // continue; // } switch(order) { case 1 : if(state_door==1) { printf("the door has been opened before\n"); break; } if(state_lock==1) { printf("the door has been lock\n"); break; } state_door=1; break; case 2: if(state_door==0) { printf("the door has been closed before\n"); break; } if(state_lock==1) { printf("the door has been lock\n"); break; } state_door=0; break; case 3: if(state_door==1) { printf("the door has been opened before\n"); break; } if(state_lock==1) { printf("the door has been lock\n"); break; } state_lock=1; break ; case 4: if(state_door==1) { printf("the door has been opened before\n"); break; } if(state_lock==0) { printf("the door has not been lock\n"); break; } state_lock=0; break ; } } }
提供的源码资源涵盖了Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 适合毕业设计、课程设计作业。这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。 所有源码均经过严格测试,可以直接运行,可以放心下载使用。有任何使用问题欢迎随时与博主沟通,第一时间进行解答!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值