第五章 多按键状态机
前言
之前有在博客园写过这篇文章,修改一下搬到这里来了
单片机开发经常会用到按键,而我们写按键驱动的时候有没有这样写过:
if(KEY1 == 0)
{
delay_ms(20);
if(KEY1 == 0)
{
while(KEY1 == 0);
// 按键按下处理代码
}
}
一看好像没什么问题,但是细心的同学就会发现,这里有个bug,而且效率不高。首先是这个while循环,如果按键一直不释放,那么那么程序就会卡死在这里。有人可能会说,我可以加超时呀。超时好像可以解决这一问题,但是如果我想做长按检测,难道我要在这里等它1秒?同时你会看到这里还有一个delay_ms(20)延时消斗程序,这些在程序运行过程中加延时函数,都会造成系统拖沓,效率低下。多个按键的时候甚至出现按键检测不到的问题,这对于实际项目来说,是很糟糕的事情。想要解决这个问题,就可以用到我们今天的主角——状态机。
一、状态机是什么?
- 状态机是有限状态自动机的简称,是现实事物运行规则抽象而成的一个数学模型。(解释的很专业,下次不用解释了)
- 我们只需要弄明白它的四要素就简单了,哪四要素?
现态、条件、动作、次态
,"现态"
和"条件"
是因,"动作"
和"次态"
是果。 - 现态:即当前所处的状态;
- 条件:即满足某个条件就会触发某一动作;
- 动作:即满足条件时要执行的动作;
- 次态:执行动作后的状态。
- “次态”是相对于“现态”而言的,“次态”被激活就会变成新的“现态”。举个简单栗子,比如你当前状态口渴,满足条件有水,你会执行喝水动作,喝完会变成次态不渴了,当你喝水前不渴是次态,喝完水不渴就是现态。
- 对于我们今天要写的程序来说,“现态”和“次态”就是按键可能所处的状态,有“未按”、“按下”、“长按”、“释放”等,“条件”就是按键“按下”、“长按”、“释放”等,“动作”就是满足条件后要执行的代码,比如点亮LED。
二、状态机编程
- 我们在上一章工程中进行修改,新建两个文件bsp_key.c和bsp_key.h,保存到我们的APPS目录下,并添加到工程中。
2.1 按键驱动
- 先来写一下GD32的按键初始化函数,我这里用到了3个按键,分别接单片机的PA5,PA6,PA7,初始化代码如下。
void KEY_Init(void)
{
//开启时钟
rcu_periph_clock_enable(RCU_GPIOA);
//配置IO上拉输入
gpio_mode_set(GPIOA,GPIO_MODE_INPUT,GPIO_PUPD_PULLUP,GPIO_PIN_5);//KEY1
gpio_output_options_set(GPIOA,GPIO_OTYPE_OD,GPIO_OSPEED_2MHZ,GPIO_PIN_5);
gpio_mode_set(GPIOA,GPIO_MODE_INPUT,GPIO_PUPD_PULLUP,GPIO_PIN_6);//KEY2
gpio_output_options_set(GPIOA,GPIO_OTYPE_OD,GPIO_OSPEED_2MHZ,GPIO_PIN_6);
gpio_mode_set(GPIOA,GPIO_MODE_INPUT,GPIO_PUPD_PULLUP,GPIO_PIN_7);//KEY3
gpio_output_options_set(GPIOA,GPIO_OTYPE_OD,GPIO_OSPEED_2MHZ,GPIO_PIN_7);
}
2.2 按键枚举和结构体
- 按键的状态我这里定义了四种:
未按、按下、长按、抬起
。将它们定义成枚举类型如下
typedef enum _KEY_STATUS_LIST
{
KEY_NULL = 0x00,
KEY_SURE = 0x01,
KEY_DOWN = 0x02,
KEY_LONG = 0x04,
KEY_UP = 0x08,
}KEY_STATUS_LIST;
- 这个枚举包含了五个元素,KEY_NULL表示未按下,KEY_SURE表示确认用了消除抖动的,KEY_DOWN表示按键按下,KEY_LONG表示按键长按,KEY_UP表示按键抬起。
- 下来是按键的枚举,相当于按键列表,列出所有按键,KEY_NUM刚好就是按键的数量 KEY_NUM = 3,你往里面增加按键,这个数字自动增加。这里数很重要,后面用到你就知道这里用枚举的巧妙之处了。
typedef enum _KEY_LIST
{
KEY1,
KEY2,
KEY3,
KEY_NUM,
}KEY_LIST;
- 再来就是按键结构体,因为C语言不是面向对象的,这里将按键所有变量和函数做成结构体,达到类似于面向对象编程的效果。
typedef struct _KEY_OBJECT
{
uint8_t KEY_SHIELD; //按键屏蔽 0:屏蔽 1:不屏蔽
uint8_t KEY_COUNT; //按键长按计数
uint8_t KEY_LEVEL; //虚拟当前IO电平 0:按下 1:抬起
uint8_t KEY_DOWN_LEVEL; //按下时IO实际的电平
uint8_t KEY_STATUS; //按键状态
uint8_t KEY_EVENT; //按键事件
uint8_t (*READ_PIN)(void);//获取IO状态的函数指针
}KEY_OBJECT;
extern KEY_OBJECT KEY_OBJ[KEY_NUM];
- KEY_SHIELD 用来做按键屏蔽的,0表示这个按键无效,1这表示有效;
- KEY_COUNT 长按计数器,统计长按的时间;
- KEY_LEVEL 虚拟一下按键按下的电平,加这个是为了后面函数封装统一,如果一个按键按下是高电平,一个按下是低电平就不好做代码统一了;
- KEY_DOWN_LEVEL 按键按下时,单片机IO上面的电平值;
- KEY_STATUS 按键状态,记录按键当前状态;
- KEY_EVENT 按键事件,记录事件是否要触发某个动作;
- (*READ_PIN) 这是一个函数指针变量,指向读IO状态的函数接口。
- 最后是按键对象的变量声明,如果你仔细看就会发现我这里用了KEY_NUM,发现一个用处就是即使按键增加,表里声明也自动增加。
2.3 按键状态获取
做好上述准备,开始步入正轨了。要想知道按键状态,那肯定是要去获取IO电平的,代码如下
static uint8_t KEY1_ReadPin(void)
{
return gpio_input_bit_get(GPIOA,GPIO_PIN_5);
}
static uint8_t KEY2_ReadPin(void)
{
return gpio_input_bit_get(GPIOA,GPIO_PIN_6);
}
static uint8_t KEY3_ReadPin(void)
{
return gpio_input_bit_get(GPIOA,GPIO_PIN_7);
}
- 我这里做了一层封装,虽然看起来有点多余,但是却能换来后期的可移植性,后面就会看到,即使换了一个单片机,也只是修改这里,就能跑起来。
- 有了几个函数,我们就可以来创建我们按键的结构体了
KEY_OBJECT KEY_OBJ[KEY_NUM] = {
{1,0,0,0,KEY_NULL,KEY_NULL,KEY1_ReadPin},
{1,0,0,0,KEY_NULL,KEY_NULL,KEY2_ReadPin},
{1,0,0,0,KEY_NULL,KEY_NULL,KEY3_ReadPin},
};
- 以上是按键结构体的初始化,具体为什么这样赋值,可以看结构体说明,就知道了。到这里我们还没有真正去调用,我们单独封装一个函数,对所有按键进行状态的获取。
static void Get_Key_Level(void)
{
uint8_t i;
for(i = 0;i < KEY_NUM;i++)
{
if(KEY_OBJ[i].KEY_SHIELD == 0)
continue;
if(KEY_OBJ[i].READ_PIN() == KEY_OBJ[i].KEY_DOWN_LEVEL)
KEY_OBJ[i].KEY_LEVEL = 0;
else
KEY_OBJ[i].KEY_LEVEL = 1;
}
}
- 这个函数分两步走,先判断按键是否有效,如果有效,则获取按键IO电平,来记录一下当前按键电平。这里你会发现,KEY_NUM又用上了,即使我增加了按键,这个函数也不用做任何改动。
2.4 按键状态机
- 主角来了,先上代码,再听我细细道来。
static void KEY_StatueMachine(void)
{
uint8_t i = 0;
Get_Key_Level();//获取按键状态
for(i = 0;i < KEY_NUM;i++)
{
switch(KEY_OBJ[i].KEY_STATUS)
{
//状态0:
case KEY_NULL:
if(KEY_OBJ[i].KEY_LEVEL == 0)
{
KEY_OBJ[i].KEY_STATUS = KEY_SURE;//转入状态1
KEY_OBJ[i].KEY_EVENT = KEY_NULL;
}
else
{
KEY_OBJ[i].KEY_EVENT = KEY_NULL;
}
break;
//状态1:
case KEY_SURE:
if(KEY_OBJ[i].KEY_LEVEL == 0)
{
KEY_OBJ[i].KEY_STATUS = KEY_DOWN;//转入状态2
KEY_OBJ[i].KEY_EVENT = KEY_DOWN;//按下事件
KEY_OBJ[i].KEY_COUNT = 0;
}
else
{
KEY_OBJ[i].KEY_STATUS = KEY_NULL;//转入状态0
KEY_OBJ[i].KEY_EVENT = KEY_NULL;
}
break;
//状态2:
case KEY_DOWN:
if(KEY_OBJ[i].KEY_LEVEL != 0)
{
KEY_OBJ[i].KEY_STATUS = KEY_NULL;//转入状态0
KEY_OBJ[i].KEY_EVENT = KEY_UP;//松开事件
}
else if((KEY_OBJ[i].KEY_LEVEL == 0)
&& (++KEY_OBJ[i].KEY_COUNT >= KEY_LONG_DOWN_DELAY))//计数
{
KEY_OBJ[i].KEY_STATUS = KEY_LONG;//转入状态3
KEY_OBJ[i].KEY_EVENT = KEY_LONG;//长按事件
KEY_OBJ[i].KEY_COUNT = 0;
}
else
{
KEY_OBJ[i].KEY_EVENT = KEY_NULL;
}
break;
//状态3:
case KEY_LONG:
if(KEY_OBJ[i].KEY_LEVEL != 0)
{
KEY_OBJ[i].KEY_STATUS = KEY_NULL;//转入状态0
KEY_OBJ[i].KEY_EVENT = KEY_UP;//松开事件
KEY_OBJ[i].KEY_EVENT = KEY_NULL;
}
else if((KEY_OBJ[i].KEY_LEVEL == 0)
&& (++KEY_OBJ[i].KEY_COUNT >= KEY_LONG_DOWN_DELAY))//计数
{
KEY_OBJ[i].KEY_EVENT = KEY_LONG;//长按事件
KEY_OBJ[i].KEY_COUNT = 0;
}
else
{
KEY_OBJ[i].KEY_EVENT = KEY_NULL;
}
break;
}
}
-
这里拿一个按键来做说明,首先我们先获取按键的状态,按键一开始状态都是KEY_NULL
(现态)
,此时有按键按下(满足按下的条件)
,转入KEY_SURE(次态)
,完成第一步退出,这里的KEY_SURE主要是做消除抖动用的;
-
第二次进来,
现态
就是KEY_SURE,再获取一下按键状态(条件
),如果没有检测到按键按下就返回,说明上次是干扰信号;如果检测到按键按下,那就进入真正的按下状态KEY_DOWN(次态
),同时我们给KEY_EVENT赋值,表示我们触发了按下事件,退出后,去判断是否要执行动作
。
-
第三次进来,这次
现态
从KEY_DOWN开始,再去获取按键状态(条件
),如果按键释放了,就转入状态KEY_NULL(次态
),并标记事件为KEY_UP,如果没用释放,进行计数,因为条件
没用满足我们不进行状态转移,退出下次再来。
-
当计数值到达长按指定时间,比如1秒,满足长按
条件
,转移到长按状态KEY_LONG(次态
),事件标记为长按,退出后可执行想要的动作
。
-
以上就是完整的状态机代码的实现了,同样看得出,就算按键增加了,这里的函数也不需要修改,这就是我们前面做那么多准备工作的原因。
2.4 按键处理函数
- 上面的状态机运行中,我们做了一堆事件的标记,却没用做任何事情,因为我们要把它分开处理,在外面来判断触发了哪些事件,这个事件要做哪些处理,具体就需要根据事件场景来写了。
void KEY_Scan(void)
{
KEY_StatueMachine();
if(KEY_OBJ[KEY1].KEY_EVENT == KEY_UP)
{
printf("KEY1 Down\n");
}
else if(KEY_OBJ[KEY1].KEY_EVENT == KEY_LONG)
{
printf("KEY1 Long Down\n");
}
if(KEY_OBJ[KEY2].KEY_EVENT == KEY_UP)
{
printf("KEY2 Down\n");
}
else if(KEY_OBJ[KEY2].KEY_EVENT == KEY_LONG)
{
printf("KEY2 Long Down\n");
}
if(KEY_OBJ[KEY3].KEY_EVENT == KEY_UP)
{
printf("KEY3 Down\n");
}
else if(KEY_OBJ[KEY3].KEY_EVENT == KEY_LONG)
{
printf("KEY3 Long Down\n");
}
}
- 这个函数,包含了按键状态机和按键处理函数,把它放到主函数中去调用即可,注意这个函数要做延迟调用,一般设置20ms循环调用即可。
- 有没有发现这里有体现了我们结构体的妙用,我们可以很清楚的知道是哪个按键标记的时间。如果你再回头去看看,发现如果你想要移植这部分代码,你要实现的地方就只有一处,而那里正是和你底层硬件有关的地方。这正是程序封装的高级用法,关注我以后带你学习更多的单片机程序封装的方法。
三、总结
本章主要学习了按键状态机的使用,同时也教大家如何对单片机程序进行封装,体验一下单片机更高级的编程方式,也方便日后的代码移植。写作不易,如果觉得对你有用,帮忙点个赞,你的支持会让我有更多动力写下去!
多按键状态机