一、状态机思想
1.1 状态机的简介
状态机,或称有限状态机FSM(Finite State Machine),表示有限个状态及在这些状态之间的转移和动作等行为的数学模型,FSM是一种逻辑单元内部的一种高效编程方法,在服务器的编程中,服务器可以根据不同状态或者消息类型进行相应的处理逻辑,使得程序逻辑更加清晰易懂,是一种重要的编程思想。
状态:一个系统在某一时刻所存在的稳定的工作情况,系统在整个工作周期中可能有多个状态。例如一部电动机共有正转、反转、停转这 3 种状态。
一个状态机需要在状态集合中选取一个状态作为初始状态。
迁移:系统从一个状态转移到另一个状态的过程称作迁移,迁移不是自动发生的,需要外界对系统施加影响。停转的电动机自己不会转起来,让它转起来必须上电。
事件:某一时刻发生的对系统有意义的事情,状态机之所以发生状态迁移,就是因为出现了事件。对电动机来讲,加正电压、加负电压、断电就是事件。
动作:在状态机的迁移过程中,状态机会做出一些其它的行为,这些行为就是动作,动作是状态机对事件的响应。给停转的电动机加正电压,电动机由停转状态迁移到正转状态,同时会启动电机,这个启动过程可以看做是动作,也就是对上电事件的响应。
条件:状态机对事件并不是有求必应的,有了事件,状态机还要满足一定的条件才能发生状态迁移。还是以停转状态的电动机为例,虽然合闸上电了,但是如果供电线路有问题的话,电动机还是不能转起来。
1.2状态机的优点
(1)提高CPU使用效率
话说我只要见到满篇都是delay_ms()的程序就会蛋疼,动辄十几个ms几十个ms的软件延时是对CPU资源的巨大浪费,宝贵的CPU机时都浪费在了NOP指令上。那种为了等待一个管脚电平跳变或者一个串口数据而岿然不动的程序也让我非常纠结,如果事件一直不发生,你要等到世界末日么?
把程序状态机化,这种情况就会明显改观,程序只需要用全局变量记录下工作状态,就可以转头去干别的工作了,当然忙完那些活儿之后要再看看工作状态有没有变化。只要目标事件(定时未到、电平没跳变、串口数据没收完)还没发生,工作状态就不会改变,程序就一直重复着“查询—干别的—查询—干别的”这样的循环,这样CPU就闲不下来了。在程序清单 List3 中,if{}else{}语句里else下的内容(代码中没有添加,只是加了一条/*idle code*/的注释示意)就是上文所说的“别的工作” 。
这种处理方法的实质就是在程序等待事件的过程中间隔性地插入一些有意义的工作,好让CPU不是一直无谓地等待。
(2) 逻辑完备性
我觉得逻辑完备性是状态机编程最大的优点。
不知道大家有没有用C语言写过计算器的小程序,我很早以前写过,写出来一测试,那个惨不忍睹啊!当我规规矩矩的输入算式的时候,程序可以得到正确的计算结果,但要是故意输入数字和运算符号的随意组合,程序总是得出莫名其妙的结果。
后来我试着思维模拟一下程序的工作过程,正确的算式思路清晰,流程顺畅,可要碰上了不规矩的式子,走着走着我就晕菜了,那么多的标志位,那么多的变量,变来变去,最后直接分析不下去了。
很久之后我认识了状态机,才恍然明白,当时的程序是有逻辑漏洞的。如果把这个计算器程序当做是一个反应式系统,那么一个数字或者运算符就可以看做一个事件,一个算式就是一组事件组合。对于一个逻辑完备的反应式系统,不管什么样的事件组合,系统都能正确处理事件,而且系统自身的工作状态也一直处在可知可控的状态中。反过来,如果一个系统的逻辑功能不完备,在某些特定事件组合的驱动下,系统就会进入一个不可知不可控的状态,与设计者的意图相悖。
状态机就能解决逻辑完备性的问题。
状态机是一种以系统状态为中心,以事件为变量的设计方法,它专注于各个状态的特点以及状态之间相互转换的关系。状态的转换恰恰是事件引起的,那么在研究某个具体状态的时候,我们自然而然地会考虑任何一个事件对这个状态有什么样的影响。这样,每一个状态中发生的每一个事件都会在我们的考虑之中,也就不会留下逻辑漏洞。
这样说也许大家会觉得太空洞,实践出真知,某天如果你真的要设计一个逻辑复杂的程序,
我保证你会说:哇!状态机真的很好用哎!
(3)程序结构清晰
用状态机写出来的程序的结构是非常清晰的。
程序员最痛苦的事儿莫过于读别人写的代码。如果代码不是很规范,而且手里还没有流程图,读代码会让人晕了又晕,只有顺着程序一遍又一遍的看,很多遍之后才能隐约地明白程序大体的工作过程。有流程图会好一点,但是如果程序比较大,流程图也不会画得多详细,很多细节上的过程还是要从代码中理解。
相比之下,用状态机写的程序要好很多,拿一张标准的UML状态转换图,再配上一些简明的文字说明,程序中的各个要素一览无余。程序中有哪些状态,会发生哪些事件,状态机如何响应,响应之后跳转到哪个状态,这些都十分明朗,甚至许多动作细节都能从状态转换图中找到。可以毫不夸张的说,有了UML状态转换图,程序流程图写都不用写。
二、状态机实例
在状态1时,遇到一个事件,此时发生状态变化,一般在状态转换前,先要进行事件的处理,然后改变状态位,然后进入状态2,以此类推....
状态机的实现无非就是 3 个要素:状态、事件、响应。转换成具体的行为就 3 句话。
- 发生了什么事?
- 现在系统处在什么状态?
- 在这样的状态下发生了这样的事,系统要干什么?
下面以按键消抖功能,来介绍switch-case 法的状态机编程思路。
2.1 按钮消抖状态转换图
状态机机编程前,首先要明确的对应功能的状态机需要几个状态,本例的按键功能,只检测最基础的按下与松开状态(暂不实现长按、双击等状态),并增加对应的按钮去抖功能,因此,需要用到4个状态:
稳定松开状态
按下抖动状态
稳定按下状态
松开抖动状态
对应的状态转换图如下:
由于按键通常处于松开状态,这里让状态机的初始化状态为松开状态,然后在这4个状态中来回切换。
图中的VT代表按键检测到电平,VT=0即检测到低电平,可能是按键按下,由初始的“稳定松开”状态转为“按下抖动”状态
当持续检测到低电平(VT=0)一段时间后,认为消抖完成,由“按下抖动”状态转为“稳定按下”状态
在“按下抖动”状态时,在指定的一段时间内,再次检测到高电平(VT=1),说明确实是按钮抖动(比如按键被快速拨动了一下又弹起,或强烈震动导致的按键抖动),则由“按下抖动”状态转为“稳定松开”状态。
void timeKeySan1()
{
static int state=0;
static int tick=0;
switch(state)
{
case 0:
if(KeyTime1==0)
{
tick=0;
state=1;
}
break;
case 1:
tick++;
if(KeyTime1==1)
{
state=0;
}
if(tick>10)
{
KeyTimeHandle1();
state=2;
}
break;
case 2:
if(KeyTime1==1)
{
state=0;
}
else
{
tick++;
if(tick>10)
{
state=3;
}
}
break;
case 3:
if(KeyTime1==1)
{
state=0;
}
break;
default:
state=0;
break;
}
}
2.2 编程实现
2.2.1 状态定义
对应上面的按钮状态图,可以知道需要用到4个状态:
稳定松开状态(KS_RELEASE)
按下抖动状态(KS_PRESS_SHAKE)
稳定按下状态(KS_PRESS)
松开抖动状态(KS_RELEASE_SHAKE)
这里使用枚举来定义这4个状态。为了在调试时,能够把对应状态名称以字符串的形式打印出来,这里使用宏定义的一个小技巧:
#符号+自定义的枚举名称
即可自动转变为字符串形式,再将这些字符串放到const char* key_status_name[]数组中,便可通过数组的形式访问这些状态的字符串名称形式。
此外,为了不重复书写枚举名称与对应的枚举字符串(#+枚举名称),进一步使用宏定义的方式,只定义一次状态,然后通过下面两条宏定义,实现对枚举项和枚举项对应的字符串的分别获取;
即使在使用 FreeRTOS 的情况下,如果系统的逻辑需要描述为多个状态和状态转换,那么状态机仍然是一种很有用的设计工具。状态机迁移表示例:
当前状态 | 输入 | 下一个状态 | 输出 |
---|---|---|---|
S1 | A | S2 | O1 |
S1 | B | S1 | O2 |
S2 | A | S1 | O3 |
S2 | B | S2 | O4 |
在上面的例子中,有两个状态 S1 和 S2,输入有两个选项 A 和 B,输出有四个选项 O1、O2、O3 和 O4。根据当前状态和输入,状态机会根据表格的规则进行状态转移,并输出相应的结果。
#include <stdio.h>
// 定义状态枚举
typedef enum
{
S1,
S2
} State;
// 定义输入和输出枚举
typedef enum
{
A,
B
} Input;
typedef enum
{
O1,
O2,
O3,
O4
} Output;
// 定义状态机结构体
typedef struct
{
State current_state;
} KeyboardStateMachine;
// 处理输入并获取输出
Output process_input(KeyboardStateMachine *sm, Input input)
{
State next_state;
Output output;
// 根据当前状态和输入进行状态转移和输出操作
switch (sm->current_state)
{
case S1:
if (input == A)
{
next_state = S2;
output = O1;
}
else if (input == B)
{
next_state = S1;
output = O2;
}
break;
case S2:
if (input == A)
{
next_state = S1;
output = O3;
}
else if (input == B)
{
next_state = S2;
output = O4;
}
break;
}
// 更新当前状态
sm->current_state = next_state;
return output;
}
int main()
{
// 创建状态机对象
KeyboardStateMachine keyboard_sm;
keyboard_sm.current_state = S1;
// 处理输入并获取输出
Input input1 = A;
Output output1 = process_input(&keyboard_sm, input1);
printf("Input: %d, Output: %d\n", input1, output1);
Input input2 = B;
Output output2 = process_input(&keyboard_sm, input2);
printf("Input: %d, Output: %d\n", input2, output2);
return 0;
}
实际情况下,键盘事件状态机的输出(Output)可以根据你的具体需求进行定义。在示例状态转移表中,我们定义了四种输出选项 O1、O2、O3 和 O4。这只是一个简单的示例,你可以根据实际应用场景来定义更多的输出选项。
以下是一些实际情况下可能的输出选项示例:
根据按键的不同,执行不同的操作:例如,输出可以指示执行某个特定的操作,比如打开菜单、修改文本等。
返回状态信息:输出可以提供当前状态的信息,用于界面显示或其他处理。例如,输出可以表示正在编辑状态、播放状态、暂停状态等。
触发事件:输出可以作为触发条件,用于通知其他组件或模块执行相应的操作。例如,输出可以触发报警、发送通知等。
控制状态切换:输出可以用于控制状态切换,对状态机进行进一步的控制或导航。例如,输出可以指示状态机切换到另一个状态,启动另一个子状态机等。
2.3在 FreeRTOS 中,任务(task)的方式来实现状态机
#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
// 定义状态枚举
typedef enum
{
S1,
S2
} State;
// 定义输入和输出枚举
typedef enum
{
A,
B
} Input;
typedef enum
{
O1,
O2,
O3,
O4
} Output;
// 定义任务句柄
TaskHandle_t task_S1_handle;
TaskHandle_t task_S2_handle;
// 任务 S1,表示状态 S1
void task_S1(void *pvParameters)
{
Input input;
Output output;
while (1)
{
// 等待输入
xQueueReceive(input_queue, &input, portMAX_DELAY);
// 根据输入进行状态转移和输出操作
if (input == A)
{
vTaskSuspend(task_S1_handle); // 暂停当前任务
vTaskResume(task_S2_handle); // 恢复任务 S2
output = O1;
}
else if (input == B)
{
output = O2;
}
// 执行输出操作
printf("S1: Input %d, Output %d\n", input, output);
}
}
// 任务 S2,表示状态 S2
void task_S2(void *pvParameters)
{
Input input;
Output output;
while (1)
{
// 等待输入
xQueueReceive(input_queue, &input, portMAX_DELAY);
// 根据输入进行状态转移和输出操作
if (input == A)
{
output = O3;
}
else if (input == B)
{
output = O4;
}
// 执行输出操作
printf("S2: Input %d, Output %d\n", input, output);
}
}
int main()
{
// 创建输入消息队列
input_queue = xQueueCreate(10, sizeof(Input));
// 创建任务 S1
xTaskCreate(task_S1, "S1", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY + 1, &task_S1_handle);
// 创建任务 S2
xTaskCreate(task_S2, "S2", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY + 1, &task_S2_handle);
// 启动调度器
vTaskStartScheduler();
while (1)
{
Input event = A;
xQueueSend(input_queue, &event, 0);
}
return 0;
}
在这个示例中,输入可以是按钮按下、定时器到期或传感器触发等,输出可以是状态变化、动作完成或错误发生等。通过消息队列将输入事件发送给状态机任务,并从状态机任务获取输出结果。
2.4实战运用
只谈概念太空洞了,举一个小例子:一单片机、一按键、俩 LED 灯(记为L1和L2)、一人, 足矣!
规则描述:
1、L1L2
状态转换顺序OFF/OFF--->ON/OFF--->ON/ON--->OFF/ON--->OFF/OFF
2、通过按键控制L1L2
的状态,每次状态转换需连续按键5
次
3、L1L2
的初始状态OFF/OFF
下面这段程序是根据功能要求写成的代码。
程序清单List1:
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(统一建模语言)的语法元素
画出来的,语法不是很标准,但拿来解释问题足够了。
圆角矩形代表状态机的各个状态,里面标注着状态的名称。
带箭头的直线或弧线代表状态迁移,起于初态,止于次态。
图中的文字内容是对迁移的说明,格式是:事件[条件]/动作列表(后两项可选)。
“事件[条件]/动作列表”要说明的意思是:如果在某个状态下发生了“事件”,并且状态机
满足“[条件]”,那么就要执行此次状态转移,同时要产生一系列“动作”,以响应事件。在这个例子里,我用“KEY”表示击键事件。
图中有一个黑色实心圆点,表示状态机在工作之前所处的一种不可知的状态,在运行之前状态机必须强制地由这个状态迁移到初始状态,这个迁移可以有动作列表(如图1所示),但不需要事件触发。
图中还有一个包含黑色实心圆点的圆圈,表示状态机生命周期的结束,这个例子中的状态机生生不息,所以没有状态指向该圆圈。
关于这个状态转换图就不多说了,相信大家结合着上面的代码能很容易看明白。现在我们再聊一聊程序清单List1。
先看一下fsm_active()
这个函数,g_stFSM.u8KeyCnt = 0;
这个语句在switch—case
里共出现了 5 次,前 4 次是作为各个状态迁移的动作出现的。从代码简化提高效率的角度来看,我们完全可以把这 5 次合并为 1 次放在 switch—case 语句之前,两者的效果是完全一样的,代码里之所以这样啰嗦,是为了清晰地表明每次状态迁移中所有的动作细节,这种方式和图2的状态转换图所要表达的意图是完全一致的。
再看一下g_stFSM
这个状态机结构体变量,它有两个成员:u8LedStat
和 u8KeyCnt
。用这个结构体来做状态机好像有点儿啰嗦,我们能不能只用一个像 u8LedStat 这样的整型变量来做状态机呢?
当然可以!我们把图 2中的这 4 个状态各自拆分成 5 个小状态,这样用 20 个状态同样能实现这个状态机,而且只需要一个 unsigned char 型的变量就足够了,每次击键都会引发状态迁移, 每迁移 5 次就能改变一次 LED 灯的状态,从外面看两种方法的效果完全一样。
假设我把功能要求改一下,把连续击键5次改变L1L2
的状态改为连续击键100次才能改变L1L2
的状态。这样的话第二种方法需要4X100=400
个状态!而且函数fsm_active()
中的switch—case语句里要有400个case
,这样的程序还有法儿写么?!
同样的功能改动,如果用g_stFSM
这个结构体来实现状态机的话,函数fsm_active()
只需要将if(g_stFSM.u8KeyCnt>3)
改为if(g_stFSM.u8KeyCnt > 98)
就可以了!
g_stFSM
结构体的两个成员中,u8LedStat
可以看作是质变因子,相当于主变量;u8KeyCnt
可以看作是量变因子,相当于辅助变量。量变因子的逐步积累会引发质变因子的变化。
像g_stFSM
这样的状态机被称作Extended State Machine
,我不知道业内正规的中文术语怎么讲,只好把英文词组搬过来了。
大佬开源连接:【stm32单片机基础】按键状态机实现长按和短按_stm32_Net_Walke-GitCode 开源社区