任务41 基于状态机的程序架构.docx
《任务41 基于状态机的程序架构.docx》由会员分享,可在线阅读,更多相关《任务41 基于状态机的程序架构.docx(15页珍藏版)》请在冰豆网上搜索。
任务41基于状态机的程序架构
项目4
简易数字钟的设计
(2)
计算机专业有门必修课程叫“软件工程”,这门课程告诉软件学习者们如何系统性的、规范化的、可定量的过程化方法去开发和维护软件。
我们在学习单片机编程的过程当中,也应该借鉴“软件工程”课程当中的讲述的方法和手段,去维护和规范我们的单片机程序。
在本单元当中,我们安排了4个任务。
任务1介绍了一种基于状态机的程序框架,通过状态机的学习,初学者可以写出思路清晰、多任务运行流畅的程序。
任务2介绍了程序的风格和可移植性,规范了变量和函数等的命名,并简单介绍了C51中提高程序可移植性的方法。
任务3介绍了程序模块化的实现方法,让初学者学会合理的管理程序。
任务4中运用本单元所讲的知识,结合前一单元,完成简易数字钟的设计。
【内容安排】
4.1基于状态机的程序框架
4.2程序的风格和可移植性
4.3程序的模块化
4.4简易数字钟的设计
任务4.1基于状态机的程序框架
4.1.1任务介绍
上一单元中已经多次提到多任务运行时,延时函数(DelayMs())对程序的危害性,堵塞CPU,系统任务的实时性得不到有效的保证。
在3.4节中,提到中断可以提高任务的实时性,但是单片机的中断数量是有限的,不可能每一个任务都有中断。
在3.5节中,通过定时器中断服务函数提供的时标信号,定时扫描LED和数码管,可以消除延时函数,时标信号给我们提供了一种新的思路来消除延时函数(本质上还是借助于中断)。
但是LED闪烁和动态数码管扫描都是属于状态时间分配均匀的(LED闪烁有两个状态,亮和灭分配时间相等;数码管每个位扫描的时间也相等),程序易于实现。
对于像按键检测这样的(时间分配不均匀的)任务,怎样来消除程序中的延时呢?
本节任务是:
利用本节所讲“状态机”,改写独立按键程序,并增加“长按”、“连击”等功能。
4.1.2知识准备
1、状态机的思想
网络上经常报道特级象棋大师车和多人一起下象棋,采用的方式是“车轮战”。
车轮战有两种方式:
(1)象棋大师先和甲开始下象棋,直到有了结果,然后才轮到乙和象棋大师对阵,下完了之后,然后是丙......,一直到和最后一个人下完。
(2)象棋大师先和A下一步棋,然后再和B下一步棋,然后再和C,和......,和所有人下完一遍后,再回头从A开始,一个人接一个人。
很显然“车轮战”的第1种方式效率不如第2种方式效率高,报道上的“车轮战”也是指的第2种方式。
原因在于象棋大师的水平远远高于其他人,如果采用第一种方式,象棋大师下一步棋很快,甲需要考虑很长时间才能落子,象棋大师在和甲下棋的过程中,其他人只能等待。
如果采用第二种方式,象棋大师和甲只下一步棋,然后再和乙也下一步棋,和所有人下完一步棋之后,再从甲开始,这样看起来是所有人都在下象棋,效率自然远高于第一种方式。
“车轮战”的第2种方式,实际上就是程序中状态机的基本原理。
程序中的多个任务可以看成是其他棋手,CPU是象棋大师,CPU在执行多个任务时,不再是先执行任务1,执行完任务1后,再执行任务2,而是把每个任务又划分出多个小任务(小任务中没有时间等待),CPU每次只执行每个任务中的小任务,执行完任务1中的一个小任务后,然后快速转向任务2中的小任务,按照这种模式轮询下去,由于CPU很快(象棋大师),整个程序中的任务都得到了实时的执行。
任务中的小任务是按照任务的状态来划分的,故称为“状态机”。
上一单元中,利用定时器的时标信号扫描动态数码管的程序实际上也是采用了状态机的原理。
时标信号到来后,CPU扫描1位数码管,然后去执行别的任务,下一个时标信号到来后,再扫描下一位数码管。
6位数码管的扫描被分成了6个子任务,CPU每一次只执行一个子任务。
2、任务的划分
数码管扫描的任务划分非常简单,因为每一个子任务执行的时间时均匀的,而且任务很相似。
但是程序中大多数任务划分出来的子任务时间分布不均匀,而且划分出来的子任务不相似。
举个例子,2个LED,第一个LED按照亮1秒,灭2秒的方式闪烁,第2个LED按照亮2秒,灭1秒的方式闪烁,要求不用延时函实现。
我们先给出程序,通过程序学习状态机的实现方法。
#include
#defineucharunsignedchar
#defineuintunsignedint
sbitLED1=P1^0;//第一个LED1接口定义
sbitLED2=P1^1;//第二个LED2接口定义
bitFlagSystem1Ms=0;//系统1ms时标信号
//定时器0初始化
voidTimer0Init()
{
TMOD=0x02;//GATE=0,C/T=0,M1M0=02;
TH0=56;//高8位RAM赋值
TL0=56;//低8位RAM赋值,200us定时
ET0=EA=1;//开定时器中断和总中断
TR0=1;//开启定时器
}
//第一个LED以亮1秒,灭2秒的方式闪烁
voidLed1Twinkle()
{
staticucharLed1State=0;//状态机变量1
staticucharLed1Cnt=0;//计数变量1
swithch(Led1State)
{
case0:
//LED1亮状态
{
LED1=0;
if(++Led1Cnt>=1000)//LED1亮1秒后,跳转到灭状态
{
if(++Led1Cnt>=2000)//LED1灭2秒后,返回到亮状态
{
Led1Cnt=0;
Led1State=0;
}
}break;
}
}
Led1Cnt=0;
Led1State=1;
}
}break;
case1:
//LED1灭状态
{
LED1=1;
if(++Led1Cnt>=2000)//LED1灭2秒后,返回到亮状态
{
Led1Cnt=0;
Led1State=0;
}
}break;
}
}
//第二个LED以亮2秒,灭1秒的方式闪烁
voidLed2Twinkle()
{
staticucharLed2State=0;//LED2状态机变量
staticucharLed2Cnt=0;//LED2计数变量
swithch(Led2State)
{
case0:
//LED2亮状态
{
LED2=0;
if(++Led2Cnt>=2000)//LED2亮2秒后,跳转到灭状态
{
Led2Cnt=0;
Led2State=1;
}
}break;
case1:
//LED2灭状态
{
LED2=1;
if(++Led2Cnt>=1000)//LED2灭1秒后,返回到亮状态
{
Led2Cnt=0;
Led2State=0;
Led2State=1;
}
}break;
case1:
//LED2灭状态
{
LED2=1;
if(++Led2Cnt>=1000)//LED2灭1秒后,返回到亮状态
{
Led2Cnt=0;
Led2State=0;
}
}break;
}
}
//主函数
voidmain()
{
TimeriInit();//定时器0初始化
while
(1)
{
if(FlagSystem1Ms==1)//间隔1ms轮询系统任务
{
FlagSystem1Ms=0;
Led1Twinkle();//任务1
Led2Twinkle();//任务2
}
}
}
//定时器0中断服务函数,提供2ms的时标信号
voidtimer0_intr(void)interrupt1
{
staticucharCnt200us=0;//200us计数变量
if(++Cnt200us>=5)//0.2ms*5=1ms
{
Cnt200us=0;//清空计数变量
FlagSystem1Ms=1;//1ms时标信号置位
}
}
整个程序的框架思路清晰,定时器0产生1ms时标信号供主函数使用,主函数每间隔1ms执行任务1和任务2。
任务1和任务2实现内容类似,下面我们分析任务1的内容。
任务1中的Led1Twinkle()函数是通过状态机完成的,状态机由switch-case语句构造。
在程序中先定义状态机变量(Led1State,静态局部变量),并赋初值0,以及时间计数变量(Led1Cnt)。
LED1的状态为亮1秒,灭2秒,分2个状态,所以switch-case语句中对应LED1的两个状态有两个case分支,状态机变量(Led1State有2个值,分别为0和1。
系统上电运行,1ms时标到来,第一次执行任务1,由于Led1State初值为0,则进入到第一个分支,在case语句中,CPU只执行两条语句:
点亮LED1;让计数变量加1(Led1Cnt=1),并判断是否等于1000,如果不是则直接跳出switch-case语句(break不可以忽略),任务1执行完毕,然后再执行任务2。
下一个1ms再到来,再进入第一个分支,计数变量再加1(Led1Cnt=2),再退出,时标信号到来,计数变量再加1,一直循环下去...。
直到计数变量满足了大于等于1000的条件,则计数变量置0,并把状态机变量置1(LedState=1),指向第二个分支,退出任务。
下一个1ms再次到来时,进入任务1后,根据状态机变量的值,进入到程序的第二个分支,熄灭LED1,重新对计数变量计数,接下来的程序运行模式和第一个分支是一样的。
总结:
“状态机”的思想实际上是把系统所有任务按照任务的状态划分出多个子任务,以时标信号为基准,主程序不停的轮询各任务中的子任务。
子任务之间的跳转依靠计数变量计数或者某些状态量的改变。
3、基于状态机的按键检测
上一单元中的独立按键的扫描程序如下:
if(KeyInput==0)//检测到按键按下
{
DelayMs(10);//延时消抖
if(KeyInput==0)//再次检测按键是否按下
{
DispBuffer[0]=DispCnt/10;
DispBuffer[1]=DispCnt%10;
if(++DispCnt>=100)//每按一次,计数器加1,超过
DispCnt=0;
while(!
KeyInput);//等待按键释放
}
}
}
在按键的扫描程序中,存在着DelayMs()延时堵塞主程序,比延时更堵塞系统的是等待按键释放语句:
while(!
KeyInput);按键从按下到弹起最小也得几百个ms,程序执行到这一步的话,程序指针长时间停留在等待释放语句上,严重阻碍了其它任务的执行。
总之,这种简单的按键检测方法作为基础学习和简单系统中可以用,但实际产品中,有很大的缺陷和不足。
在实际产品中,基于状态机的按键效率更高,功能强大。
图4.1.1是基于状态机的独立按键检测的状态转换图,根据按键检测三部曲,分为三个状态。
“状态0”为初次检测按键按下状态。
系统由定时器产生的时标信号来驱动每隔10ms检测按键有没有被按下,如果没有检测到按键按下时,状态机中的状态就会一直停留在“状态0”上,如果检测到按键按下,则将状态变量指向“状态1”。
“状态1”是按键消抖状态。
在“状态0”中首次检测到按键按下后,将状态机变量指向“状态1”,下一个10ms到来后,进入到“状态1”中(10ms消抖延时是通过定时器来完成的)。
在“状态1”中,再次确认按键按下输出键值,并将状态变量指向“状态2”。
“状态2”为按键等待释放状态。
在该状态中,每隔10ms查询一次按键有没有释放。
如果按键没有释放,则CPU一直停留在“状态2”,直到按键释放,将状态机变量指向“状态0”,按键的一次完整的检测过程完成。
图4.1.1独立按键状态机转换图
下面是基于状态机按键的一个应用实例:
用数码管的前2位显示一个十位数,变化范围为00-99,开始时显示00。
每按一次按键数码管上显示的数值加1,超过99数值重新归0。
程序如下:
#include
#defineucharunsignedchar
#defineuintunsignedint
sbitKeyInput1=P2^0;//“加1”键接口定义
sbitKeyInput2=P2^1;//“键1”键接口定义
//段码
ucharcodeSeg7Code[]={0xc0,0xf9,0xa4,0xb0,0x99,0x92,0x82,0xf8,0x80,0x90};ucharcodePosit[]={0xfe,0xfd};//位码
ucharDispBuffer[2];//缓冲区
bitFlagSystem1Ms=0;//1ms时标信号
//数码管显示
voidSeg7Display()
{
staticuchari=0;
P1=0xff;//消隐
P0=Seg7Code[DispBuffer[i]];//送段码
P1=Seg7Posit[i];//送位码
if(++i>=2)i=0;
}
//缓冲区刷新
voidNumToBuffer(ucharnum)
{
DispBuffer[0]=num/10;
DispBuffer[1]=num%10;
}
//按键扫描,没有按键按下,则返回0xff,
//有按键按下,则返回对应的键值
ucharKeyScan()
{
ucharTemp;
if(KeyInput1==0)Temp=0x01;//"加1"键按下
elseif(KeyInput2==0)Temp=0x02;//“减1”键按下
elseTemp=0xff;//没有按键按下,返回0xff
returnTemp;
}
//按键状态机检测
//根据按键"三部曲",分为三个状态
ucharGetKeyValue()
{
staticucharKeyState=0;//按键状态机变量
ucharKeyReturn=0;//返回键值
ucharKeyTemp=0;//暂存按键值
KeyTemp=KeyScan();//扫描按键
switch(KeyState)
{
case0:
//状态0,检测按键是否被按下
{
if(KeyTemp!
=0xff)//有按键按下,则跳转到状态1
KeyState=1;
}break;
case1:
//状态1,延时消抖后再次判断是否被按下
{
if(KeyTemp!
=0xff)//有按键按下
{
KeyState=2;//跳转到状态1
KeyReturn=KeyTemp;//返回键值
}
else
KeyState=0;
}break;
case2:
//状态2,等待按键弹起
{
if(KeyTemp==0xff)//按键弹起,则返回到状态0
KeyState=0;
}break;
}
returnKeyReturn;//返回键值
}
//主函数
voidmain()
{
ucharKeyValue=0;//键值
ucharCnt1Ms=0;//1ms计数变量
charDispNum=0;//数码管显示变量,变量类型为有符号
while
(1)
{
if(FlagSystem1Ms==1)//1ms时标信号到
{
FlagSystem1Ms=0;
NumToBuffer(DispNum);//缓冲区刷新
Seg7Display();//数码管显示
if(++Cnt1Ms>=10)//10ms时标信号到
{
KeyValue=GetKeyValue();//按键状态机检测
if(KeyValue==0x01)//"加1"键按下
{
if(++DispNum>=100)
DispNum=0;
}
if(KeyValue==0x02)//“减1”键按下
{
if(--DispNum<=0)
DispNum=99;
}
}
}
}
}
//定时器0中断服务函数,提供1ms的时标信号
voidTimer0Isr(void)interrupt1
{
staticucharCnt200us=0;//200us计数变量
if(++Cnt200us>=5)//0.2ms*5=1ms
{
Cnt200us=0;//清空计数变量
FlagSystem1Ms=1;//2ms标志位置1
}
}
程序解释:
(1)主函数中的任务由定时器产生的1ms时标信号驱动,每隔1ms,刷新一次缓冲区和扫描一位数码管,同时计数变量Cnt1Ms加1。
当计数变量Cnt1Ms满足>=10时,10ms时标信号到,执行一次按键扫描,取得键值,并根据键值完成对应功能。
(2)KeyScan()函数完成按键状态的扫描,并返回状态值(temp),temp取值为0x01、0x02分别对应2个按键的按下,如果没有按键按下,则temp为0xff。
GetKeyValue()根据按键检测三部曲的原理,通过状态机的方法获取键值。
通过定时器产生的10ms时标信号,每间隔10ms进入一次GetKeyValue()函数中。
(3)在GetKeyValue()函数中,状态机由switch-case语句构成,有3个状态,分别为:
初次检测到按键按下,10ms消抖延时再判断和等待按键弹起。
系统根据按键状态值(KeyTemp)实现状态之间的跳转。
每一个case语句后面的break不可省略,意味着每进入一次GetKeyValue()只执行当前状态中的任务,然后退出函数。
在状态0中,通过按键状态扫描函数(KeyScan())判断按键有没有按下,如果没有按键按下(KeyTemp==0xff),不做任务处理(状态机变量KeyState保持为0),退出函数后,下一个10ms,仍然进入状态0。
如果有按键按下(KeyTemp!
=0xff),将状态机变量指向状态1(即KeyState=1),退出函数后,下一个10ms到来,系统根据状态机变量的值,进入到状态1中。
从状态0中退出到进入到状态1中,经过了10ms,这10ms恰好是按键消抖的时间。
在状态1中,再次判断按键是否按下,如果有按键按下,则返回按键值,并将状态机变量指向状态2(KeyState=2)。
如果按键没有按下,则将状态机变量指向状态0。
退出函数后,下一个10ms到来,系统根据状态机变量的值,进入到状态2中。
在状态2中,每隔10ms检查一次按键是否弹起,如果按键没有弹起,则一直停留在状态2中,如果按键已经弹起,则将状态机变量指向状态0。
4.1.3任务实施
按键的长按与连击在实际中有着较多的应用,如有些电子产品的开机和关机按钮,为了避免误触发,需要长时间按住才能开关或者关机;在上面的例程中,我们在调整数码管显示的数值时,当前值离设定值较远,需要连续多次按下按键,如果按键有连击功能,按下按键不释放,数码管的值自动加或者减,给系统带来了很大的方便。
下面的程序在短按的基础之上增加了连击功能。
程序如下:
ucharGetKeyValue()
{
staticucharKeyState=0;//状态机变量,静态,初值为0
staticucharKeyCnt=0;//计时变量,静态
ucharKeyReturn=0;//按键键值
ucharKeyTemp=0;//按键状态值
KeyTemp=KeyScan();
switch(KeyState)
{
case0:
//状态0,判断有没有按键被按下
{
if(KeyTemp!
=0xff)//按键按下,进入到状态1
KeyState=1;
}break;
case1:
//状态1,10ms延时消
{
if(KeyTemp!
=0xff)//再次确认按键是否被按下
{
KeyState=2;//进入状态2,并返回“短按”键值
KeyReturn=KeyTemp|0x80;
}
else
KeyState=0;
}break;
case2:
//状态2,长按检测
{
if(KeyTemp!
=0xff)
{
if(++KeyCnt>=100)//按键被按下超过1S,返回“长按”键值
{
KeyCnt=0;
KeyState=3;//进入状态3,并返回“长按”键值
KeyReturn=temp|0x40;
}
}
else//按键在1S内没有弹起,返回到状态0
{
KeyCnt=0;
KeyState=0;
}
}break;
case3:
//状态3,连击状态
if(KeyTemp!
=0xff)//按键没有弹起,
{//每间隔500ms,返回一次“连击”按键值
if(++KeyCnt>=50)
{
KeyCnt=0;
KeyReturn=KeyTemp|0x20;
}
}
else//按键弹起,返回到初始状态
{
KeyCnt=0;
KeyState=0;
}
}break;
}
returnKeyReturn;
}
程序分析:
长按与连击的程序在短按的基础之上,多了2个状态。
有了短按的基础,长按与连击不难理解,请读者自己来分析。
需要注意的是,为了区分短按、长按和连接的返回值,KeyReturn的值做了处理,以第一个按键按下为例:
短按,KeyReturn=0x81;长按,KeyReturn=0x41,连击:
KeyReturn=0x21。