第三章 通用的按键代码(上)
本文对应的代码地址(https://gitee.com/holymiao/Essential-programming-skills-for-embedded-systems)
从学51单片机开始,关于按键的操作就是本基本的操作,但是,真正把按键玩的非常溜的,可以说没有几个,本节考虑把按键操作的问题进行系统的梳理,从小白到高手,从随意编写代码到生成按键库一站式梳理开来。
3.1 按键的基础
3.1.1 独立按键识别的基础

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

4.延时消抖:在51单片机教材中,一般使用delay函数阻塞延时来消抖,这种做法是致命的 ,典型的例子是,按键+数码管扫描时,按下按键,数码管显示会卡壳。
C
//阻塞消抖例子
void readKey1(void)
{
if(getGpio() == 0) { //判断按键按下
delay20ms(); //延时20ms跳出抖动区域
if(getGpio() == 0) { //再次判断是否按下
//执行按键代码
}
}
}
因为在延时的时候,其他什么事情都干不了 ,且延时时间还很长,因此会影响整个系统的流畅运行,所以这种按键判断的处理方法,往后不要用了 。
- 定时器消抖:先分析为啥要消抖,这都是不停扫描按键的方式给闹的,如果按键不停扫描,则出现抖动的情况,就会出现 一按多识别 的情况;解决这个问题其实很简单,为啥要不停扫描?你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口分为组合操作和独立操作,代码还都不一样,很多初学者看着看着就晕了)。
这里梳理一下矩阵按键的扫描流程:
- 矩阵分为行和列,分别用Rows和Columns表示,也可以简写成row和col;
- 一般来说,行和列对应的IO口必须一个是输出口,而另一组一定是上拉输入口,这里为了统一起见,规定所有行为输出口,所有列为上拉输入口。
- 矩阵键盘如何扫描呢?简单来说:行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;
}
- 矩阵键盘的消抖操作,跟独立按键一样,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 功能设计
经过本项目经理组织多方面讨论分析后确定: 按键管理代码需要符合以下功能:
- 按键基本识别需要用户自己完成,那必须啊,不同MCU、连接不同的IO口,不同的扫描方式,获取的按键值方法肯定不一样。但为了满足大部分嵌入式按键的模式,本库需要实现独立按键、矩阵按键、AD模拟按键的实现,解决大部分代码痛点;
- 但架不住还有一些奇葩的按键,比如IC编码按键等,需要留出接口方便用户扩展;
- 可能一个项目中既有矩阵又有独立按键,或者有两个相互不牵扯的矩阵按键,都需要统一管理,底层驱动代码绝对不一样,为了统一管理,这里需要 把按键按照硬件+功能进行分组控制,类似于电脑上键盘、鼠标(都是输入且功能类似,但分两个设备)
- 无论什么类型的按键,最终都要 获取当前按键按下的值(keyValue),然后根据这个按键值进行消抖、识别按下、弹开、长按、双击的事件功能(组合按键暂时不支持,否则复杂爆了);
- 上边提到的按键事件,都能 通过回调函数,传递给用户,这叫"按键事件函数";
3.2.2 基类设计
- 为了实现上边的第1、2条,让不同的按键享用同样的操作,就需要用到结构体继承的概念,无论继承类如何变化,我的函数只操作基类,这样保证无论硬件如何变化,但是最后操作代码不会做任何改变 .
体现"里氏替换原则 ",即"子类对象应该能够替换其父类对象出现的任何地方,并且保证程序的行为不发生改变";
问题是,继承类已经变化了,只操作基类,能做区分吗? 当然可以,后续再讲。
C
//按键组基类
typedef struct key_base {
//定义按键相关的操作和函数
} key_base_t;
//矩阵按键的派生类
typedef struct {
key_base_t keybase; //继承基类,必须放到首位
//在这里定义矩阵按键相关的变量和函数
} MatrixKey_t;
- 为了满足上边的第3条,我们必须使用链表进行管理,因为鬼知道用户到底有多少组按键需要挂载,因此在基类中必须添加一个链表,这里我们还是使用之前已经编写的 LinkList库进行侵入式链表管理 (当然,大家也可以自行编写链表,只不过链表的添加和删除需要自行管理)。
关于LinkList库及侵入式链表的概念的内容及用法 ,参考此章节:《手搓算法(3):双向链表库的编写》
C
#include "LinkList.h" //双向链表库
LIST_MANAGE keyManager; //按键管理器,用于管理侵入式链表
//按键组基类
typedef struct key_base {
LIST_NODE list; //侵入式链表,尽量放到第一位
//定义按键相关的操作和函数
} key_base_t;
- 每一个按键组都有一个ID,用于区分是哪个组,我们约定,如果按键组ID为0,则表示无效按键组,所有实体按键组编号都必须大于1,小于等于255;
C
//按键组基类
typedef struct key_base {
LIST_NODE list; //侵入式链表,尽量放到第一位
uint8_t id; //按键组ID,0表示空,实体按键编号范围[0,255]
//...
} key_base_t;
- 不同按键甚至不同组按键键值的识别,这里提供两个思路:
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;
- 为了实现上一小节的第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;
- 为了实现上一小节的第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条功能,让用户提供按键的基本识别程序。此回调设计上有下边一些细节:
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;
- 为了方便用户阅读,这里把获取按键值的函数类型以及按键事件类型重新命名一下,并做了详细注释
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"用户已经实现了并注册到了基类里边,鉴于此,我们编写基类操作的相关函数。
- 首先按键组是双向链表,所以根据LinkList的使用方法,我们需要定义一个链表管理器keyManager,用于管理按键组的侵入式链表:
C
LIST_MANAGE keyManager; //按键管理器
- 我们的按键需要实现长按和双击功能,具体多长时间属于长按、双击,需要有个参数进行识别,故此添加俩变量,限定时间节点(注意的是,按键扫描20ms进行一次,所以这里1个数代表20ms):
C
static uint32_t LONG_TIME_LIMT = 0; //长按超时时间,1个tick代表20ms
static uint32_t DOUBLE_TIME_LIMT = 0; //双击限定时间,1个tick代表20ms
- 按键管理器需要初始化,超时时长对应也要初始化一下,故此添加管理器初始化函数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;
}
- 假如某个按键组基本功能已经设计好了,各种参数、各种底层获取按键值的回调函数也都注册了,那么就需要把这个 按键组注册到按键管理器中 ,顺带把按键 事件回调函数 和事件回调函数中的 用户数据指针 也传递进去,最后返回一个组别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)
;
函数接口不是问题,问题是,这个函数如何实现?
- 实现按键组注册函数需要如下思路:
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;
}
- 删除按键组,这个简单,根据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库函数
}
- 为了实现按键的消抖、实现按键的按下、弹开、长按、双击的功能,实现调用事件回调函数,这里需要设计一个用户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;
}
- 关于按键事件的分析代码比较难分析,这里不可能讲得很清楚,我个人也不怎么会画程序流程图,所以只能把实现细节一条一条的列出来,并附上代码供大家阅读。
- 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,就能完成按键的各种操作。
只是还有亿点点问题:
- 如何从基类扩展继承类,实现各种按键的操作;
- 既然扩展成继承类,如何在不改变上边管理器任何代码的基础上,继续完成按键操作;
- 这个按键管理器到底如何使用。
- 其他问题可以在评论区提出来。
上边这些问题,咱们下一篇文章详细讲解,绝对能够解决上边所有问题。