news 2026/4/15 8:36:55

5、第四章 流程管理的利器——状态机(上)(嵌入式高级应用篇)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
5、第四章 流程管理的利器——状态机(上)(嵌入式高级应用篇)

第四章 流程管理的利器——状态机(上)

前言

  • 很多学生会C语言,很好,你获得了学习嵌入式的入场券
  • 大部分同学会使用CPU外设,Good,你进入了嵌入式的大门
  • 有相当一部分学员会通过外设移植外围电路的驱动,Great,你可以尝试做项目了

但大部分人都卡死在这个位置,只能做一个可怜的码农,系统稍微复杂一点,程序就写的一团乱麻,自己都看不懂。
遇见流程极其复杂的项目,如何管理代码,才能让整个程序变得十分优雅又方便调试呢?
使用状态机!!!

4.1 项目引入

我在工作的时候接到这么一个技术支持,项目也不难,可以当个引入例子:

  1. 简单的电子时钟,支持时间显示、时间设置、闹铃设置、闹铃、支持显示温度。
  2. 硬件上:八个8段数码管、两个按键上下排布,配合一个RTC芯片和温度传感器;
  3. 默认上电先读取RTC后直接显示时钟,显示格式为“HH:MM:SS”,其中一个冒号占用一个数码管;时间显示5s之后切换成温度显示,格式为“TT.TC”;温度显示5s后切换成时间显示,如此循环;
  4. 在时钟显示界面,如果同时按下两个按键进入菜单页面,菜单用数字编码在数码管上闪烁显示;
  5. 在菜单页面,通过按上下按钮,用于切换菜单;
  6. 选中菜单之后,同时按下两个按键,进入当前菜单配置界面;
  7. 在菜单配置界面配置完成后,按下两个按键跳出到菜单页面;
  8. 在菜单界面或者菜单配置界面,如果超过30s未按下按键,则直接退回到时间显示界面;
  9. 时间配置菜单编码为1;进入此菜单后,按上键调节小时,按下键调节分钟;特别地,如果长按下键,属于快速调节分钟;
  10. 闹铃配置菜单编码为2;进入此菜单后,按上键调节小时,按下键调节分钟;特别地,如果长按下键,属于快速调节分钟;
  11. 还有其他菜单,这里砍了,简化一下功能

已经开始有人头疼了!
很多51单片机基础教程中,都把编写电子钟的项目放到最后,起到总结的效果。
但是对于很多学生来说,单独驱动按键、数码管、时钟芯片、温度传感器都没问题;就是和在一块问题非常严重,包括但不限于:

  • 逻辑混乱理不清,一千个初学者和中学者就有1001个程序架构
  • 功能代码和跳转的变量不得不放在同一个函数里边,但凡拆分称不同变量就要上全局变量;
  • 按键按下,数码管卡壳甚至直接歇菜
  • 数码管显示那么多不同内容,不知道咋处理最好;
  • 旧功能写好了,添加新功能时,发现程序架构不兼容,需要推倒重做
  • 按键按下顺序不一样,会出现各种不同的bug等。

以至于,很多学员跟着视频或者课本敲了一遍电子钟程序之后,既没有分析也没有调试,就直接结项,然后自信满满的说“51已经轻松拿捏”。
可真正去做项目的时候,还是一个头两个大:驱动都调通了,咋揉和一块呢?为何揉在一块老是出bug呢?这个垃圾新功能为啥不能兼容老程序呢?

4.2 状态机的引入

无论多复杂的项目,都可以用“状态机”来统一管理!
在编写代码之前,先把项目要求进行拆分,简单的拆分方法是分成“状态(State)”和“状态转移条件(Switch)”两部分。

4.2.1 状态区分

举个例子:
这个项目有那么多条件,真正能够区分的,其实就是如下几个状态:
初始(Init)、正常显示(NormalShow)、菜单(Menu)、时间设置(TimeSet)、闹铃设置(AlarmSet)五个状态(根据自己的习惯也可以进一步的粗分和细分状态数量);

4.2.2 状态功能

在每个状态下,代码功能都是简单的、独立的,可以独立编写和调试。

  1. 初始(Init)状态下,初始化RTC、温度传感器,这都能在demo中找到源码,直接复制过来就能用;
  2. 正常显示(NormalShow)状态下,定时读取RTC,数码管显示时间;定时读取温度传感器,数码管显示温度;实现时间显示5s,温度显示5s(如果嫌麻烦的话,可以把此状态拆分为俩,然后5s切换一下状态也是可以的);
  3. 菜单(Menu)模式下,上下按键切换菜单内容,需要按键和数码管配合,也不难;
  4. 时间设置(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)状态
时间设置TimeSetKey_Up小时++、Key_Down分钟++,Key_Down长按则分钟快调1、同时按下Key_Up和Key_Down进入菜单(Menu)状态
2、30s无动作则进入正常显示(NormalShow)
闹铃设置AlarmSetKey_Up小时++、Key_Down分钟++,Key_Down长按则分钟快调1、同时按下Key_Up和Key_Down进入菜单(Menu)状态
2、30s无动作则进入正常显示(NormalShow)

4.3 简单状态机的代码编写

根据4.2章节的分析,各个状态功能基本都是独立的,可以很容易编写和调试,现在只要管理好各个状态的切换即可。这样,代码编写就会被拆分成若干个子过程,而不是把所有的状态分析混成一锅粥来统一编写;

4.3.1 状态枚举

阅读其他大型代码,无一例外,状态的设置都是用的枚举,没有一个用define的。
用枚举有啥好处?

  1. 枚举的值默认从0开始依次+1,数据绝对不会重复(强行重复也会提示错误);而用define,所有状态的编号需要用户自己管理;
  2. 枚举名字自定义,不仅方便阅读,也能当连续的数字索引用;define当数字索引管理极其麻烦;
  3. 最重要的是,枚举想要添加一个状态,只需要在其中任意位置插入新状态就行,其他状态的值会自动发生变化,程序员完全不用参与,旧代码完全兼容,不做任何修改就可以兼容;而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()。
如果这个架构是自己编写的还好维护,如果让其他人接手,依然是一个头两个大,不利于程序的维护(如果故意想写屎山代码,当我没说)。
能不能编写一个通用的状态机代码,用户只需像"填充表格"一样,集中管理状态代码、状态转移条件,就能适用各种流程的状态机?甚至是状态机里边套子状态机?
关于通用的状态机代码编写,咱们放大下一篇文章继续研究。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/12 23:57:02

为什么你的容器调度总失败?Docker Offload任务分配原理全剖析

第一章:为什么你的容器调度总失败?Docker Offload任务分配原理全剖析在复杂的微服务架构中,Docker容器调度失败是常见却难以根治的问题。其根源往往不在于镜像本身,而在于任务分配机制的底层逻辑未被充分理解。Docker的“offload”…

作者头像 李华
网站建设 2026/4/14 0:13:21

大语言模型微调中的学习动力学:从挤压效应到智能进化

大语言模型微调中的学习动力学:从挤压效应到智能进化 【免费下载链接】Qwen3-4B-Base 探索语言极限,Qwen3-4B-Base引领大模型新篇章。集成多元训练数据与前沿技术,实现更高质的预训练与扩展的语言理解能力,助您开启智能文本处理新…

作者头像 李华
网站建设 2026/4/12 17:30:35

solidwordks练习题1

(事先声明,作者只是sw学习爱好者,发本篇作品是巩固个人学习掌握度,只是借用机械学霸老师的图进行更好地叙述,并无任何侵权目的)观察全图,结构有一个底座,四个孔,两根筋&a…

作者头像 李华
网站建设 2026/4/13 12:17:21

Unity依赖注入革命:用Zenject构建模块化游戏架构

还在为Unity项目中混乱的对象引用关系而烦恼吗?是否曾经在深夜调试时被"NullReferenceException"折磨到怀疑人生?今天,让我们一同探索Zenject这个能够彻底改变你Unity开发体验的依赖注入框架。 【免费下载链接】Zenject 项目地址…

作者头像 李华
网站建设 2026/4/14 15:33:53

Docker + Agent持续交付实践:从测试到上线的4步闭环

第一章:企业 Agent 的 Docker 更新流程在企业级应用部署中,Agent 通常以 Docker 容器形式运行,负责监控、日志采集或服务注册等关键任务。为确保系统稳定性与安全性,定期更新 Agent 镜像并平滑重启容器至关重要。更新前的准备 确认…

作者头像 李华