第四章 流程管理的利器——状态机(上)
前言
- 很多学生会C语言,很好,你获得了学习嵌入式的入场券;
- 大部分同学会使用CPU外设,Good,你进入了嵌入式的大门;
- 有相当一部分学员会通过外设移植外围电路的驱动,Great,你可以尝试做项目了;
但大部分人都卡死在这个位置,只能做一个可怜的码农,系统稍微复杂一点,程序就写的一团乱麻,自己都看不懂。
遇见流程极其复杂的项目,如何管理代码,才能让整个程序变得十分优雅又方便调试呢?
使用状态机!!!
4.1 项目引入
我在工作的时候接到这么一个技术支持,项目也不难,可以当个引入例子:
- 简单的电子时钟,支持时间显示、时间设置、闹铃设置、闹铃、支持显示温度。
- 硬件上:八个8段数码管、两个按键上下排布,配合一个RTC芯片和温度传感器;
- 默认上电先读取RTC后直接显示时钟,显示格式为“HH:MM:SS”,其中一个冒号占用一个数码管;时间显示5s之后切换成温度显示,格式为“TT.TC”;温度显示5s后切换成时间显示,如此循环;
- 在时钟显示界面,如果同时按下两个按键进入菜单页面,菜单用数字编码在数码管上闪烁显示;
- 在菜单页面,通过按上下按钮,用于切换菜单;
- 选中菜单之后,同时按下两个按键,进入当前菜单配置界面;
- 在菜单配置界面配置完成后,按下两个按键跳出到菜单页面;
- 在菜单界面或者菜单配置界面,如果超过30s未按下按键,则直接退回到时间显示界面;
- 时间配置菜单编码为1;进入此菜单后,按上键调节小时,按下键调节分钟;特别地,如果长按下键,属于快速调节分钟;
- 闹铃配置菜单编码为2;进入此菜单后,按上键调节小时,按下键调节分钟;特别地,如果长按下键,属于快速调节分钟;
- 还有其他菜单,这里砍了,简化一下功能;
已经开始有人头疼了!
很多51单片机基础教程中,都把编写电子钟的项目放到最后,起到总结的效果。
但是对于很多学生来说,单独驱动按键、数码管、时钟芯片、温度传感器都没问题;就是和在一块问题非常严重,包括但不限于:
- 逻辑混乱理不清,一千个初学者和中学者就有1001个程序架构;
- 功能代码和跳转的变量不得不放在同一个函数里边,但凡拆分称不同变量就要上全局变量;
- 按键按下,数码管卡壳甚至直接歇菜;
- 数码管显示那么多不同内容,不知道咋处理最好;
- 旧功能写好了,添加新功能时,发现程序架构不兼容,需要推倒重做;
- 按键按下顺序不一样,会出现各种不同的bug等。
以至于,很多学员跟着视频或者课本敲了一遍电子钟程序之后,既没有分析也没有调试,就直接结项,然后自信满满的说“51已经轻松拿捏”。
可真正去做项目的时候,还是一个头两个大:驱动都调通了,咋揉和一块呢?为何揉在一块老是出bug呢?这个垃圾新功能为啥不能兼容老程序呢?
4.2 状态机的引入
无论多复杂的项目,都可以用“状态机”来统一管理!
在编写代码之前,先把项目要求进行拆分,简单的拆分方法是分成“状态(State)”和“状态转移条件(Switch)”两部分。
4.2.1 状态区分
举个例子:
这个项目有那么多条件,真正能够区分的,其实就是如下几个状态:
初始(Init)、正常显示(NormalShow)、菜单(Menu)、时间设置(TimeSet)、闹铃设置(AlarmSet)五个状态(根据自己的习惯也可以进一步的粗分和细分状态数量);
4.2.2 状态功能
在每个状态下,代码功能都是简单的、独立的,可以独立编写和调试。
- 初始(Init)状态下,初始化RTC、温度传感器,这都能在demo中找到源码,直接复制过来就能用;
- 正常显示(NormalShow)状态下,定时读取RTC,数码管显示时间;定时读取温度传感器,数码管显示温度;实现时间显示5s,温度显示5s(如果嫌麻烦的话,可以把此状态拆分为俩,然后5s切换一下状态也是可以的);
- 菜单(Menu)模式下,上下按键切换菜单内容,需要按键和数码管配合,也不难;
- 时间设置(TimeSet)、闹铃设置(AlarmSet)状态,上下按键切换时间,也是一个简单的功能
4.2.3 状态切换
在每个状态下如何切换到下一个状态也都是有条件的,比如:
- 初始(Init)状态结束后直接进入正常显示(NormalShow)状态
- 正常显示(NormalShow)下,同时按下两个按键,进入菜单(Menu)状态
- 在时间设置(TimeSet)界面如果同时按下两个按键,就回到菜单(Menu)状态;如果超过30s未动作,就回到正常显示(NormalShow)状态
这些就是状态转移条件。
4.2.4 状态机整理表格
一般教材喜欢画状态转移图,我不会,这里就用列表来总结上边的操作。
| 名字 | 标签 | 功能 | 转出条件 |
|---|---|---|---|
| 初始 | Init | 初始化硬件 | 初始完成切换到正常显示(NormalShow)状态 |
| 正常显示 | NormalShow | 读取RTC和温度传感器,显示时间和温度,5s切一次;到闹铃时间提示闹铃 | 同时按下Key_Up和Key_Down进入菜单(Menu)状态 |
| 菜单 | Menu | 显示功能菜单,两个按键切换菜单 | 同时按下Key_Up和Key_Down,根据菜单编号,分别进入时间设置(TimeSet)、闹铃设置(AlarmSet)状态 |
| 时间设置 | TimeSet | Key_Up小时++、Key_Down分钟++,Key_Down长按则分钟快调 | 1、同时按下Key_Up和Key_Down进入菜单(Menu)状态 2、30s无动作则进入正常显示(NormalShow); |
| 闹铃设置 | AlarmSet | Key_Up小时++、Key_Down分钟++,Key_Down长按则分钟快调 | 1、同时按下Key_Up和Key_Down进入菜单(Menu)状态 2、30s无动作则进入正常显示(NormalShow); |
4.3 简单状态机的代码编写
根据4.2章节的分析,各个状态功能基本都是独立的,可以很容易编写和调试,现在只要管理好各个状态的切换即可。这样,代码编写就会被拆分成若干个子过程,而不是把所有的状态分析混成一锅粥来统一编写;
4.3.1 状态枚举
阅读其他大型代码,无一例外,状态的设置都是用的枚举,没有一个用define的。
用枚举有啥好处?
- 枚举的值默认从0开始依次+1,数据绝对不会重复(强行重复也会提示错误);而用define,所有状态的编号需要用户自己管理;
- 枚举名字自定义,不仅方便阅读,也能当连续的数字索引用;define当数字索引管理极其麻烦;
- 最重要的是,枚举想要添加一个状态,只需要在其中任意位置插入新状态就行,其他状态的值会自动发生变化,程序员完全不用参与,旧代码完全兼容,不做任何修改就可以兼容;而define添加新状态后,需要认真进行管理,一个不小心可能就跑飞了;
鉴于此,我们这里依然用枚举进行状态定义:
typedef enum { State_Init, //初始状态,索引默认从0开始 State_NormalShow, State_Menu, State_TimeSet, State_AlarmSet, State_Max } State_Def;4.3.2 编写各个状态下的程序代码
这里假如各种状态下,运行代码运行的驱动都搞定了,只着眼于状态机的编写。后续用到哪个函数,就在程序中进行注释。
这里每一个状态下的程序功能都可以单独编写和和调试。
4.3.3 状态运行函数(重点之一)
编写stateMachineRun()函数,用于根据状态的不同运行不同的函数,其实就是switch语句进行拆分;
//全局变量,用于保存当前状态机状态 static State_Def g_CurState = State_Init; //保存按键状态的变量,通过调用getKey()函数来获取 //1表示按下,0表示未按下 static char key_up, key_down; void stateMachineRun(void) { switch(g_CurState) { case State_Init: //初始化状态执行硬件初始化函数 BSPInit(); break; case State_NormalShow: //正常显示状态执行显示时间和温度的函数 //这里更新显示码, //具体数码管扫描推荐使用定时器,下同 normalShow(); break; case State_Menu: //菜单状态,显示菜单函数, //进入函数判断一次按键,决定是否上下翻,然后立马跳出,不允许阻塞或者延时 showMenu(); break; case State_TimeSet: //根据上下按键决定数字是否发生变化, //然后立马退出,不允许阻塞或延时 setTime(); break; case State_AlarmSet: //同上 setAlarm(); break; default: break; } }再次强调:上边出现的函数,除了BSPInit()之外,其他函数都不允许阻塞或者延时,每次调用就执行一个循环流程,只要状态不改,这些函数就会不停地刷新。这是状态机的优势所在!!!
4.3.4 状态切换函数(重点之二)
状态切换函数非常重要,根据条件,进行状态的转移;
//记录超时时间的时间戳 //要求定时器中1s自加一次 static int timeTicket = 0; //1s定时器中断,让timeTicket自加 void timerEvent() { timeTicket++; } void stateMachineSwitch(void) { if((key_up == 1) || (key_down == 1)) { //有按键按下,则超时戳清零 timeTicket = 0; } switch(g_CurState) { case State_Init: //初始化函数执行完成之后 //直接切换到正常显示模式 g_CurState = State_NormalShow; break; case State_NormalShow: //双键按下,切换到菜单状态 if((key_up == 1) && (key_down == 1)) { g_CurState = State_Menu; timeTicket = 0; //超时戳清零 } break; case State_Menu: if(timeTicket == 30) { //超时,则退回正常显示模式 g_CurState = State_NormalShow; } //双键按下,切换到子菜单状态 if((key_up == 1) && (key_down == 1)) { //获取当前选择的菜单码 int menu = getMenu(); //根据菜单码来确定进入哪个子菜单 if(menu == 1) g_CurState = State_TimeSet; if(menu == 2) g_CurState = State_AlarmSet; } break; case State_TimeSet: if(timeTicket == 30) { //超时,则退回正常显示模式 g_CurState = State_NormalShow; } //双键按下,切换到菜单状态 if((key_up == 1) && (key_down == 1)) { g_CurState = State_Menu; } break; case State_AlarmSet: if(timeTicket == 30) { //超时,则退回正常显示模式 g_CurState = State_NormalShow; } //双键按下,切换到菜单状态 if((key_up == 1) && (key_down == 1)) { g_CurState = State_Menu; } break; default: break; } }4.3.5 状态机函数整合
上边关于状态运行和状态切换的函数都已经编写完成,后续在添加一些辅助函数就可以打包成一个程序:
void stateMachine(void) { getKey(&key_up,&key_down); //获取按键状态 stateMachineRun(); //按键状态运行 stateMachineSwitch(); //按键状态切换 }这样的函数在主程序的主循环里边就可以不停地调用,这也就是为啥状态机里边的函数不允许阻塞的原因,因为外边有个大循环框着呢。
至此,简单状态机代码架构已经编写完成;
4.4 总结
个人认为,这是性价比比较高的状态机编写方法,大家一定要掌握编写使用技巧。
上边咱们已经编写了最简单的状态机编码,只是不同的状态机流程,都需要大改上边提到的每一个函数,特别是状态转移函数stateMachineSwitch()。
如果这个架构是自己编写的还好维护,如果让其他人接手,依然是一个头两个大,不利于程序的维护(如果故意想写屎山代码,当我没说)。
能不能编写一个通用的状态机代码,用户只需像"填充表格"一样,集中管理状态代码、状态转移条件,就能适用各种流程的状态机?甚至是状态机里边套子状态机?
关于通用的状态机代码编写,咱们放大下一篇文章继续研究。