驱动设计:
此案件驱动程序来源于安富莱,用于扫描独立按键,具有软件滤波机制,采用FIFO保存键值。可以检测如下事件:
按键按下
按键弹起
长按键
长按时自动连发
问题来了,什么是FIFO?
eg:FIFO机制和状态机一样,都是在裸机编程中非常重要的编程思想。
FIFO是先入先出的意思,即谁先进入队列,谁先出去。比如我们需要串口打印数据,当使用缓存将该数据保存的时候,在输出数据时必然是先进入的数据先出去,那么该如何实现这种机制呢?首先就是建立一个缓存空间,这里假设为10个字节空间进行说明。
从上图可知,使用FIFO就先要定义一个结构体,来储存他的三个成员。缓存区Buf[]数组,缓存区写指针Write,缓存区读指针Read。
初始状态下,Read=Write=0;
typedef struct
{
uint8_t Buf[10]; //缓存区
uint8_t Write; //缓存区写指针
uint8_t Read; //缓存区读指针
}KEY_FIFO_T;
我们依次按下k1,k2,FIFO中的数据变为:
如果Write不等于Read,我们就认为有新的按键事件发生。
读取一个按键进行处理后,Read变量变为1,Write变量保持不变
然后我们继续读取三个按键进行处理,Read变量变为4,此时Read=Write=4。两个变量已经相等,就表示没有新的按键进行处理。
注意: 当FIFO缓存区写满后,write会被重新赋值为0,也就是从第一个字节空间填数据进去,如果这个地址空间的数据还没有被都出去,就会被后来的数据覆盖调,这里开辟了十个字节的缓存区,一般够用。
设计FIFO按键的三个好处:
-
可靠地记录每一个按键事件,避免遗漏按键事件。特别是需要实现按键的按下、长按、自动连发、弹起等事件时。
-
读取按键的函数可以设计为非阻塞的,不需要等待按键抖动滤波处理完毕。
-
按键 FIFO 程序在嘀嗒定时器中定期的执行检测,不需要在主程序中一直做检测,这样可以有效地降低系统资源消耗。
按键FIFO的实现
一,定义结构体:
在我们的key.h文件中定义一个结构体类型为KEY_FIFO_T
的结构体。就是前面说的那个结构体。这只是类型声明,并没有分配变量空间。
typedef struct
{
uint8_t Buf[10]; //缓存区
uint8_t Write; //缓存区写指针
uint8_t Read; //缓存区读指针
}KEY_FIFO_T;
接着在key.c 中定义 s_tKey 结构变量, 此时编译器会分配一组变量空间。
static KEY_FIFO_T s_tkey; //按键FIFO结构体变量
二,将按键状态写入到FIFO缓存区:
当检测到按键事件发生后,就可以调用KEY_FIFO_Put函数将键值压入FIFO中。
void KEY_FIFO_Put(uint8_t Key_Code)
{
s_tkey.Buf[s_tkey.Write]=Key_Code;
if(++s_tkey.Write>=KEY_FIFO_SIZE)
{
s_tkey.Write=0;
}
}
每压一次,就是每调用一次KEY_FIFO_Put()
函数,写指针write就++
一次,也就是向后移动一个空间,如果FIFO空间写满了,也就是s_tKey.Write >= KEY_FIFO_SIZE
,Write会被重新赋值为 0。
三,将按键状态从FIFO中读出来:
//从缓存区数组中读取按键键值
uint8_t KEY_FIFO_Get(void)
{ uint8_t ret;
if(s_tkey.Read==s_tkey.Write) //表示缓存区为空
return KEY_NONE; //
else {
ret=s_tkey.Buf[s_tkey.Read];
if(++s_tkey.Read>=KEY_FIFO_SIZE)
{
s_tkey.Read=0;
}
return ret;
}
}
如果返回值KEY_NONE为0,表示写入的指针和读出的指针相等,那么,按键缓冲区为空,所有的按键时间已经处理完毕。如果不相等就说明FIFO的缓冲区不为空,将Buf中的数读出给ret
变量。同样,如果FIFO空间读完了,没有缓存了,也就是s_tKey.Read >= KEY_FIFO_SIZE
,Read也会被重新赋值为 0。按键的键值定义在key.h 文件,下面是具体内容:
//按次序定义按键的按下,弹起,长按事件
typedef enum
{
KEY_NONE=0,
KEY_1_DOWN,
KEY_1_UP,
KEY_1_LONG,
KEY_2_DOWN,
KEY_2_UP,
KEY_2_LONG,
KEY_3_DOWN,
KEY_3_UP,
KEY_3_LONG,
KEY_4_DOWN,
KEY_4_UP,
KEY_4_LONG,
KEY_5_DOWN,
KEY_5_UP,
KEY_5_LONG,
KEY_6_DOWN,
KEY_6_UP,
KEY_6_LONG,
}KEY_ENUM;
必须按次序定义每个键的按下、弹起和长按事件,即每个按键对象占用 3 个数值。推荐使用枚举enum, 不用#define的原因是便于新增键值,方便调整顺序。使用{ } 将一组相关的定义封装起来便于理解。编译器也可帮我们避免键值重复。
四,按键检测程序分析
上面说了如何将按键的键值存入和读出FIFO,但是既然是按键操作,就肯定涉及到按键消抖处理,还有按键的状态是按下还是弹起,是长按还是短按。所以为了以示区分,我们用还需要给每一个按键设置很多参数,就需要再定义一个结构体KEY_T
,让每个按键对应1个全局的结构体变量。
//检测按键是长按还是短按,为它设置一系列对应的参数
typedef struct
{
/*下面是一个函数指针,指向判断按键是否按下的函数*/
//1表示按下
uint8_t (*IsKeyDownFunc)(void);
uint8_t count; //滤波计数器
uint16_t LongCount; //长按计数器
uint16_t LongTime; //按键按下持续时间,0表示按键不检测长按
uint8_t State; //按键当前状态
uint8_t RepeatSpeed; //连续按键周期
uint8_t RepeatCount; //连续按键计数器
}KEY_T;
在key.c 中定义s_tBtn
结构体数组变量。
static KEY_FIFO_T s_tkey; //按键FIFO结构体变量
static KEY_T s_tBtn[HARD_KEY_NUM ]= {0}; //目前有四个按键,要为每一个按键分配结构体变量,所以定义一个KEY_T结构体类型的数组变量
每个按键对象都分配一个结构体变量,这些结构体变量以数组的形式存在将便于我们简化程序代码行数。因为我的硬件有6个按键,所以在key.h里有 #define HARD_KEY_NUM 6。使用函数指针IsKeyDownFunc
可以将每个按键的检测以及组合键的检测代码进行统一管理。
因为函数指针必须先赋值,才能被作为函数执行。因此在定时扫描按键之前,必须先执行一段初始化函数来设置每个按键的函数指针和参数。这个函数是void KEY_Init(void)
。
//初始化各个按键的指针参数
void KEY_FIFO_Init(void)
{
uint8_t i;
/* 对按键FIFO读写指针清零 */
s_tkey.Read = 0;
s_tkey.Write = 0;
/* 给每个按键结构体成员变量赋一组缺省值 */
for (i = 0; i < HARD_KEY_NUM; i++)
{
s_tBtn[i].LongTime = KEY_LONG_TIME;/* 长按时间 0 表示不检测长按键事件 */
s_tBtn[i].count = KEY_FILTER_TIME/ 2; /* 计数器设置为滤波时间的一半 */
s_tBtn[i].State = 0;/* 按键缺省状态,0为未按下 */
s_tBtn[i].RepeatSpeed = 6;/* 按键连发的速度,0表示不支持连发 */
s_tBtn[i].RepeatCount = 0;/* 连发计数器 */
}
/* 判断按键按下的函数 */
s_tBtn[0].IsKeyDownFunc = IsKey1Down;
s_tBtn[1].IsKeyDownFunc = IsKey2Down;
s_tBtn[2].IsKeyDownFunc = IsKey3Down;
s_tBtn[3].IsKeyDownFunc = IsKey4Down;
s_tBtn[4].IsKeyDownFunc = IsKey5Down;
s_tBtn[5].IsKeyDownFunc = IsKey6Down;
}
我们知道按键会有机械抖动,你以为按键按下就是低电平,其实在按下的一瞬间会存在机械抖动,如果不做延时处理,可能会出错,一般如果按键检测到按下后再延时50ms检测一次,如果还是检测低电平,才能说明按键真正的被按下了。反之按键弹起时也是一样的。所以我们程序设置按键滤波时间50ms, 因为代码每10ms扫描一次按键,所以按键的单位我们可以理解为10ms,滤波的次数就为5次。这样只有连续检测到50ms状态不变才认为有效,包括弹起和按下两种事件,即使按键电路不做硬件滤波(没有电容滤波),该滤波机制也可以保证可靠地检测到按键事件。
判断按键是否按下,用一个HAL_GPIO_ReadPin
就可以搞定。
//按键检测
uint8_t IsKey1Down(void)
{
if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin)==GPIO_PIN_RESET)
return 1;
else
return 0;
}
uint8_t IsKey2Down(void)
{
if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin)==GPIO_PIN_RESET)
return 1;
else
return 0;
}
uint8_t IsKey3Down(void)
{
if(HAL_GPIO_ReadPin(KEY3_GPIO_Port,KEY3_Pin)==GPIO_PIN_RESET)
return 1;
else
return 0;
}
uint8_t IsKey4Down(void)
{
if(HAL_GPIO_ReadPin(KEY4_GPIO_Port,KEY4_Pin)==GPIO_PIN_RESET)
return 1;
else
return 0;
}
uint8_t IsKey5Down(void)
{
if(HAL_GPIO_ReadPin(KEY4_GPIO_Port,KEY4_Pin)==GPIO_PIN_RESET)
return 1;
else
return 0;
}
uint8_t IsKey6Down(void)
{
if(HAL_GPIO_ReadPin(KEY4_GPIO_Port,KEY4_Pin)==GPIO_PIN_RESET)
return 1;
else
return 0;
}
五,按键扫描
按键扫描函数KEY_Scan()
每隔 10ms 被执行一次。RunPer10ms
函数在 systick
中断服务程序中执行。我这里因为用到了操作系统,所以直接创建一个10ms为周期的任务调用函数,当然你也可以使用定时器。
void Soft_Init(void)
{
KEY_FIFO_Init();
xTaskCreate( vDisplay_Task, //任务函数
"vDisplay_Task", //任务名
128, //STACK大小
NULL, //任务参数
2, //任务优先级
NULL); //任务句柄
while (1)
{
vTaskDelay(20);
//RunPer10ms();
KEY_Scan();
// printf("写入\r\n");
}
}
void KEY_Scan(void)
{
uint8_t i;
for (i = 0; i < HARD_KEY_NUM; i++)
{
KEY_Detect(i);
}
}
每隔10ms所有的按键GPIO均会被扫描检测一次。KEY_Detect
函数实现如下:
void KEY_Detect(uint8_t i)
{
KEY_T *pBtn;
pBtn =&s_tBtn[i]; //读取相应的结构体地址赋值给结构体变量pBtn
if(pBtn->IsKeyDownFunc()) { //这里执行的是按键按下的处理
//进行50ms滤波,滤除不正常的初始值状态,按键滤波前给count一个初值,前面说按键初始化的时候已经设置了 Count = KEY_FILTER_TIME/2
if(pBtn->count<KEY_FILTER_TIME)
{
pBtn->count=KEY_FILTER_TIME;
}
//实现KEY_FILTER_TIME的延迟
else if (pBtn->count<2*KEY_FILTER_TIME)
{
pBtn->count++;
}
else //判断按键是否长按,进行处理
{
/*
**********************************************************************************
这个 State 变量是有其实际意义的,如果按键按下了,这里就将其设置为 1,如果没有按下这个
变量的值就会一直是 0,这样设置的目的可以有效的防止一种情况的出现:比如按键 K1 在某个
时刻检测到了按键有按下,那么它就会做进一步的滤波处理,但是在滤波的过程中,这个按键
按下的状态消失了,这个时候就会进入到上面第二步 else 语句里面,然后再做按键松手检测滤波
,滤波结束后判断这个 State 变量,如果前面就没有检测到按下,这里就不会记录按键弹起。
**********************************************************************************
*/
if(pBtn->State==0) { //首次按下
pBtn->State=1;
KEY_FIFO_Put((uint8_t)(3*i+1));
}
if (pBtn->LongTime > 0) /*LongTime初始值是100。单位10ms,持续1秒,认为支持长按事件*/
{
if (pBtn->LongCount < pBtn->LongTime) /*LongCount长按计数器。单位10ms,持续1秒,认为长按事件*/
{
/* 发送按钮持续按下的消息 */
if (++pBtn->LongCount == pBtn->LongTime)/*LongCount等于LongTime(100),10ms进来一次,进来了100次也就是说按下时间为于1s*/
{
/* 键值放入按键FIFO */
KEY_FIFO_Put((uint8_t)(3 * i + 3));
}
}
else/*LongCount大于LongTime(100),也就是说按下时间大于1s,触发长按连发事件*/
{
if (pBtn->RepeatSpeed > 0)/* RepeatSpeed连续按键周期,控制发送长按按键长按的速度 */
{
if (++pBtn->RepeatCount >= pBtn->RepeatSpeed)
{
pBtn->RepeatCount = 0;
/* 长按键后,每隔60ms发送1个按键。因为长按也是要发送键值得,10ms进来一次,进来了6次,也就是说发送时间为60ms。*/
KEY_FIFO_Put((uint8_t)(3 * i + 1));
}
}
}
}
}
}
//这里面执行的是按键松手的处理或者按键没有按下的处理
else {
if(pBtn->count>KEY_FILTER_TIME)
{
pBtn->count=KEY_FILTER_TIME;
}
else if(pBtn->count!=0)
{
pBtn->count--;
}
else
{
if(pBtn->State==1)
{
pBtn->State=0;
KEY_FIFO_Put((uint8_t)(3*i+2));
}
}
pBtn->LongCount = 0;
pBtn->RepeatCount = 0;
}
}
这个函数还是比较难以理解的,主要是结构体的操作。所以好好学习结构体,不要见了结构体就跑。
分析:首先读取相应按键的结构体地址赋值给结构体指针变量pBtn ,因为程序里面每个按键都有自己的结构体,只有通过这个方式才能对具体的按键进行操作。(在前面我们使用软件定时器时也使用了这中操作,在滴答定时器的中断服务函数中)。
KEY_T *pBtn;
pBtn =&s_tBtn[i]; //读取相应的结构体地址赋值给结构体变量pBtn
然后接着就是给按键滤波前给Count设置一个初值,前面说按键初始化的时候已经设置了Count =5/2。然后判断是否按下的标志位,如果按键按下了,这里就将其设置为 1,如果没有按下这个变量的值就会一直是 0。这里可能不理解是就是按键按下发送的键值是3 * i + 1
。按键弹起发送的键值是3 * i + 2
,按键长按发送的键值是3 * i + 3
。也就是说按键按下发送的键值是1和4和7。按键弹起发送的键值是2和5和8,按键长按发送的键值是3和6和9。看下面这个枚举enum你就明白了。
//按次序定义按键的按下,弹起,长按事件
typedef enum
{
KEY_NONE=0,
KEY_1_DOWN,
KEY_1_UP,
KEY_1_LONG,
KEY_2_DOWN,
KEY_2_UP,
KEY_2_LONG,
KEY_3_DOWN,
KEY_3_UP,
KEY_3_LONG,
KEY_4_DOWN,
KEY_4_UP,
KEY_4_LONG,
KEY_5_DOWN,
KEY_5_UP,
KEY_5_LONG,
KEY_6_DOWN,
KEY_6_UP,
KEY_6_LONG,
}KEY_ENUM;
六,实验演示
while(1)
{
uint8_t key_code;
key_code=KEY_FIFO_Get();
//printf("key_code=%d\r\n",key_code);
if(key_code!=KEY_NONE)
{
switch(key_code)
{
case KEY_1_DOWN: /* K1键按下 */
printf("K1键按下\r\n");
break;
case KEY_1_UP: /* K1键弹起 */
printf("K1键弹起\r\n");
break;
case KEY_1_LONG: /* K1键弹起 */
printf("K1键弹起\r\n");
break;
case KEY_2_DOWN: /* K2键按下 */
printf("K2键按下\r\n");
break;
case KEY_2_UP: /* K2键弹起 */
printf("K2键弹起\r\n");
break;
case KEY_2_LONG: /* K2键弹起 */
printf("K2键长按\r\n");
break;
case KEY_3_DOWN: /* K3键按下 */
printf("K3键按下\r\n");
break;
case KEY_3_UP: /* K3键弹起 */
printf("K3键弹起\r\n");
break;
case KEY_3_LONG: /* K3键弹起 */
printf("K3键长按\r\n");
break;
default:
/* 其它的键值不处理 */
break;
}
}
//BSP_IO_T(LED1);
//BSP_IO_T(LED2);
}
检测按键有中断方式和GPIO查询方式两种。这里推荐用GPIO查询方式。
1.从裸机的角度分析
-
中断方式:中断方式可以快速地检测到按键按下,并执行相应的按键程序,但实际情况是由于按键的机械抖动特性,在程序进入中断后必须进行滤波处理才能判定是否有效的按键事件。如果每个按键都是独立的接一个 IO 引脚,需要我们给每个 IO 都设置一个中断,程序中过多的中断会影响系统的稳定性。中断方式跨平台移植困难。
-
查询方式:查询方式有一个最大的缺点就是需要程序定期的去执行查询,耗费一定的系统资源。实际上耗费不了多大的系统资源,因为这种查询方式也只是查询按键是否按下,按键事件的执行还是在主程序里面实现。
2.从OS的角度分析
-
中断方式:在 OS 中要尽可能少用中断方式,因为在RTOS中过多的使用中断会影响系统的稳定性和可预见性。只有比较重要的事件处理需要用中断的方式。
-
查询方式:对于用户按键推荐使用这种查询方式来实现,现在的OS基本都带有CPU利用率的功能,这个按键FIFO占用的还是很小的,基本都在1%以下。