3、第三章 通用的按键代码(上)(嵌入式高级应用篇)

第三章 通用的按键代码(上)

本文对应的代码地址(https://gitee.com/holymiao/Essential-programming-skills-for-embedded-systems)

从学51单片机开始,关于按键的操作就是本基本的操作,但是,真正把按键玩的非常溜的,可以说没有几个,本节考虑把按键操作的问题进行系统的梳理,从小白到高手,从随意编写代码到生成按键库一站式梳理开来。

3.1 按键的基础

3.1.1 独立按键识别的基础

  1. 输入的GPIO设置为 输入上拉 ,为了保证按键没有按下的时候,一定是高电平。如果是浮空,则按键状态不定;如果是输入下拉,那也行,只要让按键的另一端接到VCC就行;如果单片机内部可以设置,则不需要外边另外加上拉(或下拉)电阻
  2. 按键没有按下的时候,读出来的数据是高电平,按下之后,因为GPIO直接接地,则会识别成低电平,这是GPIO识别按键的基础;
  3. 物理按键在按下的时候不可能是一个完美的矩形波,无论并联多少电容都不靠谱,最好的做法是在软件进行消抖操作,一般消抖时间(即图片上"前沿抖动"和"后沿抖动"的时常)10~20ms可以。

4.延时消抖:在51单片机教材中,一般使用delay函数阻塞延时来消抖,这种做法是致命的 ,典型的例子是,按键+数码管扫描时,按下按键,数码管显示会卡壳。

C 复制代码
//阻塞消抖例子
void readKey1(void)
{
    if(getGpio() == 0) {    //判断按键按下
        delay20ms();        //延时20ms跳出抖动区域
         if(getGpio() == 0) {   //再次判断是否按下
            //执行按键代码
         }
    }
}

因为在延时的时候,其他什么事情都干不了 ,且延时时间还很长,因此会影响整个系统的流畅运行,所以这种按键判断的处理方法,往后不要用了

  1. 定时器消抖:先分析为啥要消抖,这都是不停扫描按键的方式给闹的,如果按键不停扫描,则出现抖动的情况,就会出现 一按多识别 的情况;解决这个问题其实很简单,为啥要不停扫描?你20ms扫一次,判断一下上次没有按下但这次按下了,就识别成按下状态,不就一下子解决问题了吗!

代码如下:

C 复制代码
//定时消抖例子
void readKey2(void)
{
    //本函数20ms调用一次    
    static bool preKey = 1; //static修饰,可以保存上一次按键的状态
    bool currentKey = getGpio(); //获取当前按键状态

    //如果上次未按下,这次按下,则识别按下状态
    if((preKey == 1) && (currentKey == 0)) {
        //执行按键处理代码
    }
	preKey = currentKey; //本次按键状态赋值给preKey供下次判断使用
}

3.1.2 矩阵键盘基础

矩阵键盘,顾名思义,就是矩阵排列的键盘,可以用较少的IO口驱动大量的按键,按键越多,效率越高。只是驱动程序嘛......估计有人51驱动矩阵都没搞明白怎么回事儿(主要51单片机的IO口分为组合操作和独立操作,代码还都不一样,很多初学者看着看着就晕了)。

这里梳理一下矩阵按键的扫描流程:

  1. 矩阵分为行和列,分别用Rows和Columns表示,也可以简写成row和col;
  2. 一般来说,行和列对应的IO口必须一个是输出口,而另一组一定是上拉输入口,这里为了统一起见,规定所有行为输出口,所有列为上拉输入口
  3. 矩阵键盘如何扫描呢?简单来说:行IO口为低电平,确定y坐标;列IO读取为低电平确定x坐标。 行IO口会一个一个低电平扫描。如果此时某个按键按下,就先判断是哪一行IO口是程序输出的低电平,由此确定y坐标,然后再判断哪一列IO口读出来是低电平,由此确定x坐标。这一点大家一定要搞明白。代码结构如下:
C 复制代码
#define KEY_ROW 4   //按键行数
#define KEY_COL 4   //按键列数

//按键排序从左上角开始为0,然后横向依次排列,一行结束从第二行的最左边开始计算
int matrixkey(void)
{
    int x = 0;  //横坐标,对应列IO
    int y = 0;  //纵坐标,对应行IO
    int keyvalue = -1; //返回的按键值,-1表示未被按下 

    for(int i = 0; i < KEY_ROW; i++) { //行扫描
        //代码:1. 第i行IO设置成低电平,其他行设置成高电平
        //.....

        for(int j = 0; j < KEY_COL; j++) { //列IO扫描读取
            //代码2. 读取第j列IO是否为低电平
            //......

            if(此列为低电平) {
                //此时属于第i行,则表示前边几行已经有i * KEY_COL个按键了,
                //而扫描到第j列IO为零,则在前边的基础上加上j,就是当前按键的编号
                keyvalue = i * KEY_COL + j                
            }
        }
    }
    return keyvalue;
}
  1. 矩阵键盘的消抖操作,跟独立按键一样,20ms定时扫描。

3.1.3 AD模拟键盘

如图所示,没有按键按下时,电压是VCC,当按下某个按键时,总线电压就会产生相应变化,通过读取电压值来反推哪个按键按下。

这种模拟按键的好处就是占用IO极少,一般一个就够了。

缺点是:硬件电路必须稳定,否则会识别错误,而且不支持组合按键。

在编写代码的时候,需要提供每个按键按下时,电压的识别范围,这里把识别范围的信息封装成结构体,便于查看和分析,需要注意的是,为了代码简洁,这里不采用实际电压值,而是AD寄存器获取的数据。

比如,对于12位AD转换器来说,最高值VCC对应的寄存器是4096,VCC/2对应的寄存器数据就是2048,以此类推。

C 复制代码
typedef struct {
    uint16_t min;   //按键按下后的最小识别电压ad值,注意不是电压值
    uint16_t max;   //按键拿按下后最大识别电压ad值
} kv_range_t;

由此,这里编写4个按键的识别电压表:

C 复制代码
#define MAX_AD_KEY_COUNT  4
kv_range_t keyVoltageRange[MAX_AD_KEY_COUNT] = {{3000,3500},{2000,2500},{1000,1500},{0,500}};

根据上边的基础,咱们编写AD按键识别代码:

C 复制代码
int ADkey(void)
{
    int keyvalue = -1; //按键ID,-1表示未被按下
    uint16_t readADValue = readAD(); //获取单片机的AD值

    for(int i = 0; i < MAX_AD_KEY_COUNT; i++) {
        if(readADValue >= keyVoltageRange[i].min) && (readADValue <= keyVoltageRange[i].max)) {
            keyvalue = i;
            break;
        }
    }
    return keyvalue;
}

3.1.4 总结

上边的代码我感觉 性价比最高 ,大家务必要掌握。

在处理三类按键时,所需代码最少,实现起来最方便。

3.2 用库的方式,把按键操作独立出来

对于个人应用来说,每次更换一款MCU都需要重新编写按键识别代码,也不是不行,但总感觉有点多余。

特别是需要处理长按、双击这些复杂功能的时候,如果每个都重新写一遍,弄不好就会出错。
能不能编写通用的按键处理程序,把最底层按键识别函数做个更改,就能方便的进行按键骚操作的识别呢

当然可以,可以编写 通用的按键管理库代码

提前叠个甲:本代码需要用到链表、结构体的继承、结构体内函数、结构体多态。我会一个一个讲解为啥要用到这些技术点。
具体实现方法可以看这篇文章《面相对象的C语言编程

3.2.1 功能设计

经过本项目经理组织多方面讨论分析后确定: 按键管理代码需要符合以下功能:

  1. 按键基本识别需要用户自己完成,那必须啊,不同MCU、连接不同的IO口,不同的扫描方式,获取的按键值方法肯定不一样。但为了满足大部分嵌入式按键的模式,本库需要实现独立按键、矩阵按键、AD模拟按键的实现,解决大部分代码痛点;
  2. 但架不住还有一些奇葩的按键,比如IC编码按键等,需要留出接口方便用户扩展
  3. 可能一个项目中既有矩阵又有独立按键,或者有两个相互不牵扯的矩阵按键,都需要统一管理,底层驱动代码绝对不一样,为了统一管理,这里需要 把按键按照硬件+功能进行分组控制,类似于电脑上键盘、鼠标(都是输入且功能类似,但分两个设备)
  4. 无论什么类型的按键,最终都要 获取当前按键按下的值(keyValue),然后根据这个按键值进行消抖、识别按下、弹开、长按、双击的事件功能(组合按键暂时不支持,否则复杂爆了);
  5. 上边提到的按键事件,都能 通过回调函数,传递给用户,这叫"按键事件函数"

3.2.2 基类设计

  1. 为了实现上边的第1、2条,让不同的按键享用同样的操作,就需要用到结构体继承的概念,无论继承类如何变化,我的函数只操作基类,这样保证无论硬件如何变化,但是最后操作代码不会做任何改变 .
    体现"里氏替换原则 ",即"子类对象应该能够替换其父类对象出现的任何地方,并且保证程序的行为不发生改变";
    问题是,继承类已经变化了,只操作基类,能做区分吗? 当然可以,后续再讲。
C 复制代码
//按键组基类
typedef struct key_base {
	//定义按键相关的操作和函数
} key_base_t;

//矩阵按键的派生类
typedef struct {
	key_base_t 		keybase;		//继承基类,必须放到首位
	//在这里定义矩阵按键相关的变量和函数
} MatrixKey_t;
  1. 为了满足上边的第3条,我们必须使用链表进行管理,因为鬼知道用户到底有多少组按键需要挂载,因此在基类中必须添加一个链表,这里我们还是使用之前已经编写的 LinkList库进行侵入式链表管理 (当然,大家也可以自行编写链表,只不过链表的添加和删除需要自行管理)。
    关于LinkList库及侵入式链表的概念的内容及用法 ,参考此章节:《手搓算法(3):双向链表库的编写
C 复制代码
#include "LinkList.h" //双向链表库

LIST_MANAGE keyManager; //按键管理器,用于管理侵入式链表

//按键组基类
typedef struct key_base {
	LIST_NODE 	list; //侵入式链表,尽量放到第一位
	//定义按键相关的操作和函数
} key_base_t;
  1. 每一个按键组都有一个ID,用于区分是哪个组,我们约定,如果按键组ID为0,则表示无效按键组,所有实体按键组编号都必须大于1,小于等于255;
C 复制代码
//按键组基类
typedef struct key_base {
	LIST_NODE 	list; 	//侵入式链表,尽量放到第一位
	uint8_t 	id;		//按键组ID,0表示空,实体按键编号范围[0,255]
	//...
} key_base_t;
  1. 不同按键甚至不同组按键键值的识别,这里提供两个思路:

a. 所有按键(无论同组或者不同组)之间的按键值编号都不一样,根据按键值,唯一确定是哪个按键按下

实现方法:每一组按键都设置一个 起始按键值(startKeyValue) ,后续所有的键值都在此基础上往后排,至于起始按键值设置多少,用户自行管理

b. 根据组别ID和按键值组合方式来定位按键,按键本身分组,只要保持组内部按键值不一样,就可以通过组合的方式定位唯一的按键。
小孩子才做选择,这里我全都要!!!
不仅定义一个按键识别码结构体keyValue_t,还要在按键组中添加startKeyValue量来设置本组的按键起始值。

C 复制代码
/// @brief 按键识别码
typedef struct {
	uint8_t 	id;			//组别	
	uint16_t 	keyValue;	//按键ID,0表示当前组未被按下
} keyValue_t;

//按键组基类
typedef struct key_base {
	LIST_NODE 	list;	  
	uint8_t 	id;	 
	uint16_t 	startKeyValue;	//按键起始id	
} key_base_t;
  1. 为了实现上一小节的第4条功能,实现按下、弹开、长按、双击的功能,这里需要定义事件的枚举key_event_t,供程序使用,故此按键识别码在ID和keyValue的基础上还要添加按键事件的状态:
C 复制代码
/// @brief 按键事件
typedef enum {
	KEY_EVENT_NONE 		= 0X00,
	KEY_EVENT_DOWN 		= 0X01,		//按下事件
	KEY_EVENT_UP 		= 0X02,		//弹开事件
	KEY_EVENT_LONG 		= 0X04,		//长按事件
	KEY_EVENT_DOUBLE 	= 0X08		//双击事件
} key_event_t;

/// @brief 按键识别码
typedef struct {
	uint8_t 	state;		//状态(按键事件)
	uint8_t 	id;			//组别	
	uint16_t 	keyValue;	//按键ID,0表示当前组未被按下
} keyValue_t;
  1. 为了实现上一小节的第5条功能,需要在按键中设置按键事件的回调函数,这里有两个设计方向:

a. 所有按键组使用统一的事件回调,好处是只有一个回调函数;

b. 每一个按键组使用一个事件回调,程序编写麻烦点,好处是功能可以独立开;
这里选用b的思路。不同按键组可以把回调函数的指针指向同一个函数嘛,不影响用户(但影响库编写)

编写回调事件函数的时候,无返回值,其参数 一个是当前按键的识别码 (包含事件码、组别ID、组内按键值),另一个参数是用户自定义的数据指针 (关于此用户指针的意义和用法,在上一章节《通用的定时事件管理器》已经详细描述过,后续也会举例说明使用方法)

C 复制代码
/// @brief 定义按键基类,其他按键都基于此类
typedef struct key_base {
	LIST_NODE 	list;	                        
	uint8_t 	id;		
	uint16_t 	startKeyValue;
	void 		*userData;  //用户传入的数据指针,供事件回调使用
	void 		(*keyEvent)(keyValue_t,void *);	//本组对应的事件回调函数,一个参数代表按键值(包含按键状态),另一个参数是用户传入参数*userData	
} key_base_t; 
  1. 为了实现上一小节的第1条功能,让用户提供按键的基本识别程序。此回调设计上有下边一些细节:

a. 其返回值表示当前这组按键哪个按键被按下.

需要注意的是:如果返回0,表示没有按键按下 ;如果有按键按下,则返回不同的大于0的数据(注意,此按键数据需要跟startKeyValue变量叠加之后才能算是最终的按键值供用户使用,但不是在这里叠加,在后边出现的位置还会强调 ):

b. 参数里边包含按键基类的 this指针,(但是在基类里边省略参数名,这点注意),因为此函数实现的时候,需要获取本类中的一些参数(实在不理解,后边会用到,到时候还会强调这个this指针)

C 复制代码
/// @brief 定义按键基类,其他按键都基于此类
typedef struct key_base {
	LIST_NODE 	list;	                        //侵入式链表,用于链接各个组别
	uint8_t 	id;		                        //组别ID
	uint16_t 	startKeyValue;	                //按键起始id
	void 		*userData;                     	//用户传入的数据
	uint16_t 	(*getKeyValue)(struct key_base *);	//获取某个按键ID,并把ID反馈出去,就是后边的fun_getKeyValue_t类型;	参数为this指针
	void 		(*keyEvent)(keyValue_t,void *);		//本组对应的事件回调函数,一个参数代表按键值(包含按键状态),另一个参数是用户传入参数			
} key_base_t;
  1. 为了方便用户阅读,这里把获取按键值的函数类型以及按键事件类型重新命名一下,并做了详细注释
C 复制代码
struct key_base;	//主要是回调函数的参数类型需要用到,所以先声明一下后边定义的基类

/// @brief 获取按键值的回调函数类型
/// @param this 本函数对应的自身类的指针
/// @return 此按键组按下的按键代码
typedef uint16_t (*fun_getKeyValue_t)(struct key_base *this);

/// @brief 按键对应的事件函数指针类型
/// @param value 按键值,内含事件类型、组别、按键值
/// @param data 用户传入数据的地址,需要用户自行管理指针类型
typedef void (*fun_keyEvent_t)(keyValue_t value,void *data);

/// @brief 定义按键基类,其他按键都基于此类
typedef struct key_base {
	LIST_NODE 	list;	                        
	uint8_t 	id;		                        
	uint16_t 	startKeyValue;	               
	void 		*userData;                     
	fun_getKeyValue_t getKeyValue;	//获取按键值的函数指针改成这样		
	fun_keyEvent_t keyEvent;		//事件函数指针改成这样		
} key_base_t;

至此,基类的设计全部完成!!!

关于基类及定义的详细代码如下:

C 复制代码
//下边代码摘自KeyManager.h文件
#include <stdint.h>
#include <stdbool.h>
#include "LinkList.h"

/*********************************************************************************************************************
 * 下边代码属于按键管理器代码,奠定了管理器的基类,编写了按键操作的基本框架
 ********************************************************************************************************************/
/// @brief 按键事件
typedef enum {
	KEY_EVENT_NONE 		= 0X00,
	KEY_EVENT_DOWN 		= 0X01,		//按下事件
	KEY_EVENT_UP 		= 0X02,		//弹开事件
	KEY_EVENT_LONG 		= 0X04,		//长按事件
	KEY_EVENT_DOUBLE 	= 0X08		//双击事件
} key_event_t;

/// @brief 按键识别码
typedef struct {
	uint8_t 	state;		//状态(按键事件)
	uint8_t 	id;			//组别	
	uint16_t 	keyValue;	//按键ID,0表示当前组未被按下
} keyValue_t;


struct key_base;	//主要是回调函数的参数类型需要用到,所以先声明一下后边定义的基类

/// @brief 获取按键值的回调函数类型
/// @param this 本函数对应的自身类的指针
/// @return 此按键组按下的按键代码
typedef uint16_t (*fun_getKeyValue_t)(struct key_base *this);

/// @brief 按键对应的事件函数指针类型
/// @param value 按键值,内含事件类型、组别、按键值
/// @param data 用户传入数据的地址,需要用户自行管理指针类型
typedef void (*fun_keyEvent_t)(keyValue_t value,void *data);

/// @brief 定义按键基类,其他按键都基于此类
typedef struct key_base {
	LIST_NODE 	list;	                        //侵入式链表,用于链接各个组别
	uint8_t 	id;		                        //组别ID
	uint16_t 	startKeyValue;	                //按键起始id
	void 		*userData;                     	//用户传入的数据
	fun_getKeyValue_t getKeyValue;				//获取某个按键ID,并把ID反馈出去的函数指针
	fun_keyEvent_t keyEvent;					//本组对应的事件回调函数指针,一个参数代表按键值(包含按键状态),另一个参数是用户传入参数			
} key_base_t;

3.2.3 操作基类相关函数

基类编写已经结束,可是怎么用呢?

首先我们假定,基类中获取按键值的底层代码函数"getKeyValue"用户已经实现了并注册到了基类里边,鉴于此,我们编写基类操作的相关函数。

  1. 首先按键组是双向链表,所以根据LinkList的使用方法,我们需要定义一个链表管理器keyManager,用于管理按键组的侵入式链表:
C 复制代码
LIST_MANAGE keyManager; //按键管理器
  1. 我们的按键需要实现长按和双击功能,具体多长时间属于长按、双击,需要有个参数进行识别,故此添加俩变量,限定时间节点(注意的是,按键扫描20ms进行一次,所以这里1个数代表20ms):
C 复制代码
static uint32_t LONG_TIME_LIMT = 0;		//长按超时时间,1个tick代表20ms
static uint32_t DOUBLE_TIME_LIMT = 0;	//双击限定时间,1个tick代表20ms
  1. 按键管理器需要初始化,超时时长对应也要初始化一下,故此添加管理器初始化函数keyManagerInit()
C 复制代码
/// @brief 初始化按键管理器
/// @param longTime 长按超时时间,单位ms,为0时表示禁用长按功能
/// @param doubleTime 双击限定时间,单位ms,为0时表示禁用双击功能
void keyManagerInit(uint32_t longTime_ms, uint32_t doubleTime_ms)
{
    //初始化链表管理器
    ListManageInit(&keyManager);
	LONG_TIME_LIMT = longTime_ms / 20;  	//按键20ms扫描一次,所以这里要÷20获取时间戳
	DOUBLE_TIME_LIMT = doubleTime_ms / 20;
}
  1. 假如某个按键组基本功能已经设计好了,各种参数、各种底层获取按键值的回调函数也都注册了,那么就需要把这个 按键组注册到按键管理器中 ,顺带把按键 事件回调函数 和事件回调函数中的 用户数据指针 也传递进去,最后返回一个组别ID即可:
C 复制代码
/// @brief 添加按键组
/// @param keyGroup 按键组指针
/// @param startKeyValue 初始按键值,必须大于等于1,后续所有的值都数据都要加上此变量,方便让不同组按键ID唯一(这个值需要用户自行管理)
/// @param keyEvent 回调函数事件
/// @param userData 用户传入的参数指针
/// @return 分配的按键组ID,为0表示分配失败,有效值1~255
uint8_t keyManagerAddGroup(key_base_t* keyGroup, uint16_t startKeyValue, fun_keyEvent_t keyEvent,  void * userData)
;

函数接口不是问题,问题是,这个函数如何实现?

  1. 实现按键组注册函数需要如下思路:

a. 把按键组指针keyGroup插入到按键管理器中,这个是基本功能;

b. 把初始按键值startKeyValue、事件回调函数keyEvent、事件回调函数的用户参数userData注册到按键组指针keyGroup指针中;这个也很基本,毕竟是按键组的基本属性;

c. 给此按键组分配组别ID!这个麻烦了 ,代码编写需要下边的思路:

i. 管理器中的按键组通过ID从小到大排列,这是前提;

ii. 如果管理器中最后一个按键组的ID跟管理器的按键组数量相同,那么不用说,ID就是管理器数量+1,然后把新组别插入到管理器最后即可;

iii. 如果发现管理器中最后一个按键组的ID跟管理器的按键组数量不一样,说明之前有删除过按键组的操作,其中必定有不连续的ID。这时候从头搜索,找出这个不连续的ID号,抢过来使用,并把新按键组超如到这个位置,保证管理器还是通过ID从小到大排列。

C 复制代码
uint8_t keyManagerAddGroup(key_base_t* keyGroup, uint16_t startKeyValue, fun_keyEvent_t keyEvent,  void * userData)
{
    uint8_t retValue = 1;		//保存按键组ID的变量
    key_base_t *item = (key_base_t *)(keyManager.tail);  //用于遍历按键组链表的指针,这里先指向尾指针

    //判断传入参数是否为空
    if(keyGroup == NULL) {
        return 0;
    }
	
	keyGroup->keyEvent = keyEvent;		//注册回调函数
	keyGroup->userData = userData;		//注册用户数据
	keyGroup->startKeyValue = startKeyValue; //设置当前组按键的起始数据

    //最多255个组,如果超过,则添加失败
    if(keyManager.count == 255) {
        return 0;
    } else {  
		//下边代码主要是为了查找空余ID,
		//如果是连续分配,则直接向后依次分配
		//如果之前有删除代码组的操作导致ID不连续
		//把新组插入进不连续的ID的位置,
		//整个链表根据ID从小到大排序

	if(keyManager.count == 0) {
		//如果之前链表中没有任何按键组
		//则直接分配ID
		retValue = 1;
		item = NULL;	//防止出错,这里重新赋值为NULL,方便后续判断位置使用
	} else if(keyManager.count == item->id) { 
		//如果最后一组的id和链表成员个数相等,则直接count+1
		retValue = keyManager.count + 1;
		item = NULL;	//这里赋值为NULL,方便后续判断位置使用
	} else {    
		//如果不相等,说明之前有被删除的组别,导致ID不连续    
		item = (key_base_t *)(keyManager.head);	//从头开始    
		while(item != NULL){                	//遍历链表
			if(retValue == item->id) {       	//如果编号相同说明ID被用
				retValue++;
				continue;
			} 
			if(retValue < item->id) {        	//若编号小于其中某一组的编号,则说明本编号可以用
				keyGroup->id = retValue;
				break;
			}
			item = vListPeekNext((LIST_NODE *)item); //获取下一个按键组
		}  
		}    
	}

    if(item == NULL) { 
        //如果扫描后item为空,则直接把新组追加到最后
        keyGroup->id = retValue;
        if(ListPutTail(&keyManager, (LIST_NODE *)keyGroup) != L_OK) {
            return 0;
        }        
    } else {
        //插入到item前边,保持id从小到大排列
        if(ListInsertBeforItem(&keyManager, (LIST_NODE *)item, (LIST_NODE *)keyGroup) != L_OK) {
            return 0;
        }
    }    
    return item->id;
} 
  1. 删除按键组,这个简单,根据ID删除管理器就行,至于为啥不直接用按键组的指针?有了指针可以直接获取ID,所以这里只用一个删除模式!
    为了方便函数调用,这里需要提前编写一个根据ID获取按键组指针的函数,不仅删除函数会用,后续分析按键事件的函数也会用得到。
    注意:删除按键组之后,用户引用的指针要手动改成NULL
C 复制代码
/// @brief 私有函数,根据ID查找按键组的地址
/// @param id 按键组ID
/// @return 按键组地址
static key_base_t *getKeyGroupItem(uint8_t id)
{
	key_base_t * keyGroupItem = (key_base_t *)keyManager.head;
	while(keyGroupItem != NULL) {
		if(keyGroupItem->id == id) {
			break;
		}		
		keyGroupItem = vListPeekNext((LIST_NODE *)keyGroupItem); //获取下一个按键组指针
	}
	return keyGroupItem;
}

/// @brief 根据ID删除对应的按键组
/// @param id 按键组ID
void delKeyGroup(uint8_t id) 
{
	LIST_NODE *keyGroupItem = (LIST_NODE *)getKeyGroupItem(id);	//搜索按键组指针
	ListPopItem(&keyManager, keyGroupItem);						//从链表中删除,此处调用前边编写的LinkList库函数
}
  1. 为了实现按键的消抖、实现按键的按下、弹开、长按、双击的功能,实现调用事件回调函数,这里需要设计一个用户20ms周期定时调用的函数keyManagerScankeyEvent()。
    只是这个函数过于复杂,这里分段进行分析讲解:
    首先编写一个私有子函数,主要用于扫描所有按键组到底哪个按键被按下,这里有几个细节需要说明:

i. 不支持组合键,故只要扫描到一个按键,就直接放弃后边按键组的扫描 。对于整个按键组链表来说,ID越小的按键组优先级越高;

ii. 如果没有任何按键按下,则按键ID为0 ,方便后续判断;同样的,某一按键组中获取的按键值keyValue为0,表示此按键组没有任何按键被按下

iii. 因为基类有startKeyValue变量,故此通过回调函数 getKeyValue()获取的按键值需要叠加这个初始值 。比如startKeyValue设置为10,当回调getKeyValue()获取的数据为1时,那么此按键最终键值就是1+10-1=10,、如果获取数据位5,则最终键值就是5+10-1=15,以此类推;

iv. 此函数获取的返回值只包括按键组ID和按键值keyValue,不包括事件。

v. 此函数中出现的链表操作(如keyManager.head、vListPeekNext()),都是调用LinkList库,参考上边提到的链接:《手搓算法(3):双向链表库的编写》。当然大家也可以自己手搓链表,保持本按键库的独立性。

C 复制代码
/// @brief 私有函数,扫描按键组哪个按键按下
/// @param 无
/// @return 当前按下的按键值(不支持组合按键)
static keyValue_t scanKeyGroup(void)
{
	uint16_t readKey = 0; 	//读取按键值的临时变量
	key_base_t *keyGroupItem = (key_base_t *)keyManager.head; //遍历按键链表的变量
	keyValue_t currentKey ={0};	//发送给事件的按键值

	//若按键管理器中没有数据,则直接退出
	if(keyGroupItem == NULL) {
		return currentKey;
	}
	
	//扫描所有按键组,获取当前按下的按键
	while(keyGroupItem != NULL) {
		if(keyGroupItem->getKeyValue != NULL) {				//判断函数指针有效性
			
			readKey= keyGroupItem->getKeyValue(keyGroupItem);	//通过回调函数获取当前这一组的按键值
			if(readKey == 0) {
				//如果读取数据为0,表示当前按键组没有按键被按下
				currentKey.id = 0;
				currentKey.keyValue = 0;
			} else {
				//如果识别按键被按下
				//则当前按键值在原有获取的按键值基础上加上本组的startKeyValue-1,
				//之所以要-1,是因为每个按键组获取的原始按键值不能是0,最少也是从1开始
				//在一定程度上区分不同组的按键数据(非必要)
				currentKey.keyValue = readKey + keyGroupItem->startKeyValue - 1;
				currentKey.id = keyGroupItem->id;				//组别ID赋值
				
				//如果当前这一组识别有按键按下,
				//则直接跳出循环,后边的按键组都不再识别
				//(故不支持组合按键,且组ID越小,优先级越高)
				break;
			}			 
		} else {
			//如果getKeyValue()函数指针为空,则直接识别下一组
			continue;
		}
		keyGroupItem = vListPeekNext((LIST_NODE *)keyGroupItem); //获取下一个按键组
	}

	return currentKey;
}
  1. 关于按键事件的分析代码比较难分析,这里不可能讲得很清楚,我个人也不怎么会画程序流程图,所以只能把实现细节一条一条的列出来,并附上代码供大家阅读。
  • a. 函数整体流程:分析参数有效性------>调用scanKeyGroup()函数获取当前哪个按键按下(currentKey)------>根据currentKey和prevKey分析按键状态(按下、弹开、连续按下、连续未按下)------>分析各种事件状态(最核心代码) ------>把当前按键保存下来(prevKey)------>根据分析出来的按键ID重新获取按键组指针------>调用按键组指针中的事件回调函数,把按键值所有信息反馈出去。
    下边粘贴的是 删除"根据currentKey和prevKey分析按键状态"、"分析各种事件状态"后其他代码,供大家分析,这些不难。
C 复制代码
/// @brief 按键扫描和事件识别代码,用户20ms调用一次,会自动触发事件回调函数
/// @param  
void keyManagerScankeyEvent(void)
{	
	keyValue_t eventKeyValue = {0};		//发送给事件的按键值
	
	keyValue_t currentKey = {0}; 		//当前按键值
	static keyValue_t prevKey = {0}; 	//上一次按键值
	

	//1.若按键管理器中没有数据,则直接退出
	if(keyManager.count == 0) {
		return;
	}
	
	//2.扫描按键组,获取按下的按键值
	currentKey = scanKeyGroup();	
	
	
	//3.下边这些代码用于分析当前的按键处于哪种状态,并清零or计时器+1
	//此处代码暂时省略............

	//4.下边代码根据按键状态和计时器标志分析到底标记哪种按键事件
	//此处代码暂时省略............


	//5.把当前的按键值保存到prevKey中供下次使用
	prevKey = currentKey;  

	//6.如果识别按键无操作,则直接退出
	if(eventKeyValue.id == 0) {
		return;
	}
	
	//7.根据识别的按键ID,重新找到对应的组别指针
	key_base_t *keyGroupItem = getKeyGroupItem(eventKeyValue.id);
	
	//8.如果eventKeyValue有数据(组ID不是0),且对应组的事件函数(keyEvent)非空
	if((eventKeyValue.id != 0) && (keyGroupItem->keyEvent != NULL)) {	
		//执行事件函数,按键值和状态传出去,用户自定义的参数也传出去
		keyGroupItem->keyEvent(eventKeyValue, keyGroupItem->userData);
	}
	
}
  • b. 分析按键状态,把按键状态分析和按键事件识别分开来编写,既方便分析,也能让代码编写更加清晰。状态分析有如下细节:

i. 本次按键按下、上次按键未按下,表示按下状态;本次按键未按下、上次按键按下,表示弹开状态;本次和上次都按下,表示连续按下状态;本次和上次按键都未按下,表示连续未按下状态。变量keyState分别用0,1,2,3表示;

ii. 按键弹开状态时,双击计时器doubleClickTicket清零;按键按下时,长按计时器longClickTicket清零;

iii. 连续按下时,长按计时器longClickTicket++;连续未按下时,双击计时器doubleClickTicket++;

iv. 按键弹开时,需要把上一次的按键信息保存到doublePrevKey中,供后续双击事件时判断。

C 复制代码
	keyValue_t currentKey = {0}; //当前按键值
	static keyValue_t prevKey = {0}; //上一次按键值
	static uint32_t longClickTicket = 0;  //长按计时器
	static uint32_t doubleClickTicket = 0; //双击计时器
	static keyValue_t  doublePrevKey = {0}; //保存上一次按键弹开之前,按键的值
	int keyState = 0;					//当前按键的状态,0表示按下,1表示弹开,2表示连续按下,3表示连续不按

	//2.扫描按键组,获取按下的按键值
	currentKey = scanKeyGroup();	

	//3.下边这些代码用于分析当前的按键处于哪种状态,并清零or计时器+1
	//指导思想:把按键状态和按键事件分析分成两段代码拆成两段,让代码调理清晰,不至于一团浆糊
	if((currentKey.id != 0) && (prevKey.id == 0)) { //按键按下状态
		keyState = 0;
		longClickTicket = 0;			//长按计时器重新计时
	} else if((currentKey.id == 0) && (prevKey.id != 0)) { //按键弹开状态
		keyState = 1;
		doublePrevKey = prevKey;		//记录弹开前按键值,UP和double事件要用到		
		doubleClickTicket = 0;			//双击计时器重新计时
	} else if((currentKey.id != 0) && (prevKey.id != 0)) { //连续按下状态
		keyState = 2;
		longClickTicket++;				//长按计时器每次+1
		
	} else if((currentKey.id == 0) && (prevKey.id == 0)) {	//连续未按下状态
		keyState = 3;
		doubleClickTicket++;			//双击计时器每次+1			
	}

	//4.下边代码根据按键状态和计时器标志分析到底标记哪种按键事件
	//暂时省略

	//5.把当前的按键值保存到prevKey中供下次使用
	prevKey = currentKey;  
  • c. 事件分析,整个库中最核心的算法,其主要内容如下:

i. 如果禁用双击识别(DOUBLE_TIME_LIMT == 0),则按键弹开后直接识别成弹开事件,否则不要处理;

ii. 如果处于按键连续按下状态,如果长按计时器longClickTicket累加时长正好等于LONG_TIME_LIMT,则识别成长按事件,并把长按标志位isLongClick置城True,供后续判断;注意:longClickTicket大于或者小于LONG_TIME_LIMT时都不要执行长按,前者的原因是时间不够,后者的原因是已经执行过了不需要重复识别

iii. 如果按键处于按下状态,则需要判断doubleClickTicket是否小于DOUBLE_TIME_LIMT(表明上次按键弹开后很快的又按了一次按键,可能是双击),然后再判断一下当前按键跟上次按下的按键(doublePrevKey)是否是一个按键,如果全都满足,则双击实锤;否则就识别成按下事件;

iv. 如果按键处于连续未按下状态,且未禁用双击识别(DOUBLE_TIME_LIMT != 0),则双击计时器doubleClickTicket计时等于DOUBLE_TIME_LIMT的时候,且之前没有发生过双击或者长按事件时(如果发生过上述两种事件,则禁用本次的按键弹开事件) ,才能识别成按下事件;大于或者小于DOUBLE_TIME_LIMT都不行,前者因为时间不够,还有双击的可能,后者是因为已经识别成双击了,不需要重复识别

C 复制代码
	int keyState = 0;					//当前按键的状态,0表示按下,1表示弹开,2表示连续按下,3表示连续不按
	static bool isDouble = false;		//表示是否有双击的事件发生
	static bool isLongClick = false;	//表示是否有长按的事件发生

	//4.下边代码根据按键状态和计时器标志分析到底标记哪种按键事件
	if(keyState == 0) {											//如果按键按下状态		
		if((DOUBLE_TIME_LIMT != 0)
			&& (currentKey.id == doublePrevKey.id)				//这次按键值跟上一次松开前按键值一模一样
			&& (currentKey.keyValue == doublePrevKey.keyValue)
			&& (doubleClickTicket < DOUBLE_TIME_LIMT)) {		//且松开时间小于DOUBLE_TIME_LIMT,
				eventKeyValue = currentKey;
				eventKeyValue.state = KEY_EVENT_DOUBLE;			//双击事件实锤
				isDouble = true;								//双击标志位置位
			} else {											//否则,是按键按下事件
				eventKeyValue = currentKey;
				eventKeyValue.state = KEY_EVENT_DOWN;
				isDouble = false;
			}
			isLongClick = false;
	} else if(keyState == 1) {									//如果按键弹开状态
		if(DOUBLE_TIME_LIMT == 0) {								//如果双击事件禁用
			eventKeyValue = doublePrevKey;
			eventKeyValue.state = KEY_EVENT_UP;					//则直接识别按键弹开事件
		}
	} else if(keyState == 2) {									//如果处于连续按下状态
		if((LONG_TIME_LIMT != 0)
			&& (longClickTicket == LONG_TIME_LIMT)) {			//按下时间"等于"LONG_TIME_LIMT
			eventKeyValue = currentKey;
			eventKeyValue.state = KEY_EVENT_LONG;				//长按事件
			isLongClick = true;									//长按标志位置位
		} 
	} else if(keyState == 3) {									//如果处于连续未按下状态
		if((DOUBLE_TIME_LIMT != 0)
			&& (!isLongClick) && (!isDouble)					//如果之前没有发生过长按或双击事件
			&& (doubleClickTicket == DOUBLE_TIME_LIMT)) {		//双击计时器"等于"DOUBLE_TIME_LIMT
			eventKeyValue = doublePrevKey;
			eventKeyValue.state = KEY_EVENT_UP;					//才能实锤按键弹开事件
		}	
	}

下边贴出函数的完整代码:

C 复制代码
/// @brief 按键扫描和事件识别代码,用户20ms调用一次,会自动触发事件回调函数
/// @param  
void keyManagerScankeyEvent(void)
{	
	keyValue_t eventKeyValue = {0};	//发送给事件的按键值
	
	keyValue_t currentKey = {0}; //当前按键值
	static keyValue_t prevKey = {0}; //上一次按键值
	
	static uint32_t longClickTicket = 0;  //长按计时器
	static uint32_t doubleClickTicket = 0; //双击计时器
	static keyValue_t  doublePrevKey = {0}; //保存上一次按键弹开之前,按键的值

	int keyState = 0;					//当前按键的状态,0表示按下,1表示弹开,2表示连续按下,3表示连续不按
	static bool isDouble = false;		//表示是否有双击的事件发生
	static bool isLongClick = false;	//表示是否有长按的事件发生
	
	
	//1.若按键管理器中没有数据,则直接退出
	if(keyManager.count == 0) {
		return;
	}
	
	//2.扫描按键组,获取按下的按键值
	currentKey = scanKeyGroup();	
	
	
	//3.下边这些代码用于分析当前的按键处于哪种状态,并清零or计时器+1
	//指导思想:把按键状态和按键事件分析分成两段代码拆成两段,让代码调理清晰,不至于一团浆糊
	if((currentKey.id != 0) && (prevKey.id == 0)) { //按键按下状态
		keyState = 0;
		longClickTicket = 0;			//长按计时器重新计时
	} else if((currentKey.id == 0) && (prevKey.id != 0)) { //按键弹开状态
		keyState = 1;
		doublePrevKey = prevKey;		//记录弹开前按键值,UP和double事件要用到		
		doubleClickTicket = 0;			//双击计时器重新计时
	} else if((currentKey.id != 0) && (prevKey.id != 0)) { //连续按下状态
		keyState = 2;
		longClickTicket++;				//长按计时器每次+1
		
	} else if((currentKey.id == 0) && (prevKey.id == 0)) {	//连续未按下状态
		keyState = 3;
		doubleClickTicket++;			//双击计时器每次+1
			
	}

	//4.下边代码根据按键状态和计时器标志分析到底标记哪种按键事件
	//需要注意点:
	//a.按键按下时需要分析是否是双击事件;
	//b.按键弹开后,不能直接执行事件,要等到弹开的时间等于DOUBLE_TIME_LIMT时再分析(之前之后都不行,必须等于时分析),
	//  因为在这个时间内可能会有按键第二次按下,直接就执行双击事件,抹去了弹开事件
	//  (根据下边的代码,如果上一个按键弹开后迅速按下另一个按键,会直接抹掉了上一个按键的弹开事件,请知悉)
	//c.如果禁用弹开事件,则在按键弹开后直接执行弹开事件
	if(keyState == 0) {											//如果按键按下状态		
		if((DOUBLE_TIME_LIMT != 0)
			&& (currentKey.id == doublePrevKey.id)				//这次按键值跟上一次松开前按键值一模一样
			&& (currentKey.keyValue == doublePrevKey.keyValue)
			&& (doubleClickTicket < DOUBLE_TIME_LIMT)) {		//且松开时间小于DOUBLE_TIME_LIMT,
				eventKeyValue = currentKey;
				eventKeyValue.state = KEY_EVENT_DOUBLE;			//双击事件实锤
				isDouble = true;								//双击标志位置位
			} else {											//否则,是按键按下事件
				eventKeyValue = currentKey;
				eventKeyValue.state = KEY_EVENT_DOWN;
				isDouble = false;
			}
			isLongClick = false;
	} else if(keyState == 1) {
		if(DOUBLE_TIME_LIMT == 0) {								//如果双击事件禁用
			eventKeyValue = doublePrevKey;
			eventKeyValue.state = KEY_EVENT_UP;					//则直接识别按键弹开事件
		}
	} else if(keyState == 2) {									//如果处于连续按下状态
		if((LONG_TIME_LIMT != 0)
			&& (longClickTicket == LONG_TIME_LIMT)) {			//按下时间"等于"LONG_TIME_LIMT
			eventKeyValue = currentKey;
			eventKeyValue.state = KEY_EVENT_LONG;				//长按事件
			isLongClick = true;									//长按标志位置位
		} 
	} else if(keyState == 3) {									//如果处于连续未按下状态
		if((DOUBLE_TIME_LIMT != 0)
			&& (!isLongClick) && (!isDouble)					//如果之前没有发生过长按或双击事件
			&& (doubleClickTicket == DOUBLE_TIME_LIMT)) {		//双击计时器等于DOUBLE_TIME_LIMT之后
			eventKeyValue = doublePrevKey;
			eventKeyValue.state = KEY_EVENT_UP;					//才能实锤按键弹开事件
		}	
	}


	//5.把当前的按键值保存到prevKey中供下次使用
	prevKey = currentKey;  

	//6.如果识别按键无操作,则直接退出
	if(eventKeyValue.id == 0) {
		return;
	}
	
	//7.根据识别的按键ID,重新找到对应的组别指针
	key_base_t *keyGroupItem = getKeyGroupItem(eventKeyValue.id);
	
	//8.如果eventKeyValue有数据(组ID不是0),且对应组的事件函数(keyEvent)非空
	if((eventKeyValue.id != 0) && (keyGroupItem->keyEvent != NULL)) {	
		//执行事件函数,按键值和状态传出去,用户自定义的参数也传出去
		keyGroupItem->keyEvent(eventKeyValue, keyGroupItem->userData);
	}
	
}

3.3 按键管理器总结

3.2章节讲解了按键管理器的全部代码,基本函数和基本按键事件识别代码都已经编写完成,理论上只要注册了基类中的按键值获取函数指针getKeyValue,就能完成按键的各种操作。

只是还有亿点点问题:

  1. 如何从基类扩展继承类,实现各种按键的操作;
  2. 既然扩展成继承类,如何在不改变上边管理器任何代码的基础上,继续完成按键操作;
  3. 这个按键管理器到底如何使用。
  4. 其他问题可以在评论区提出来。

上边这些问题,咱们下一篇文章详细讲解,绝对能够解决上边所有问题。

相关推荐
Cuit小唐1 小时前
指针函数和函数指针
c语言
大聪明-PLUS2 小时前
FFmpeg 组件 - 用途、输入/输出数据、配置
linux·嵌入式·arm·smarc
缘三水2 小时前
【C语言】10.操作符详解(下)
c语言·开发语言·c++·语法·基础定义
矜辰所致3 小时前
CH58x 主机扫描事件相关应用(扫描到广播包)
c语言·蓝牙主机·ble 广播包·广播包过滤·广播名称过滤
高级盘丝洞3 小时前
openPOWERLINK c读取数据并送到mqtt
c语言·开发语言
EXtreme353 小时前
链表进化论:C语言实现带哨兵位的双向循环链表,解锁O(1)删除的奥秘
c语言·数据结构·性能优化·双向链表·编程进阶·链表教程
量子炒饭大师3 小时前
David自习刷题室——【蓝桥杯刷题备战】乘法表
c语言·c++·git·职场和发展·蓝桥杯·github·visual studio
pu_taoc3 小时前
ffmpeg实战2-从MP4文件提取 音频和视频
c语言·c++·ffmpeg·音视频
2301_789015623 小时前
C++:list(带头双向链表)增删查改模拟实现
c语言·开发语言·c++·list