一、前言
1.1 文章的背景和目的
相信玩嵌入式的朋友大家有写过不少按键驱动,可能很多时候都是驱动层跟应用层耦合严重,内容一多起来逻辑就变得非常复杂,应用层功能稍有改变或按键的增减、类型发送变化,程序都得大改。为了以后在新板子上使用按键不需要过多考虑驱动层,我特意写了个按键驱动。
1.2 下载地址
驱动下载地址:CherryKey
视频讲解:https://www.bilibili.com/video/BV1is4y1n7rR/?share_source=copy_web&vd_source=50bde786a88edb8a73bf68836c62e1a7
1.3 文章的主要内容和结构
该帖子主要介绍了CherryKey的特点、设计思路、使用方法。
二、驱动介绍
2.1驱动特点
- 面向对象
- 静态链表管理按键
- 参考了键盘、鼠标的驱动,给出了适合单片机的基础事件:按下、弹起、单击、双击、长按
- 每个按键的每个事件有独立的多个回调函数、已注册的回调函数使用列表存储。回调函数分同步/异步处理,异步处理的回调函数使用队列管理,让你的程序更有主次
- 驱动兼容GPIO按键、ADC按键,支持连按,组合按键。
2.2驱动讲解
1.为什么使用链表。因为使用链表我们就可以像下面的程序一样,一份代码适用多个对象。
void key_detect_scan() {
uint16_t i, length;
length = slink_length(&key_link);
for (i=0; i<length; i++) {
key_detect((key_t*)slink_getData(&key_link, i+1));
}
}
2.为什么给出五个基础事件:按下、弹起、单击、双击、长按。在平时使用的单片机按键驱动中,常常只有按下、双击、长按,而单击(click)跟按下(press)是不一样的,有些时候我们需求用户有一个按下弹起才触发的情况,这就是这份代码的考虑。在给出的五个基础事件中,用户可以根据需求在回调函数设计三击、四击等动作。
另外也支持组合按键例如下面是ADC按键跟GPIO按键的组合:
if ((key_get_event(&key2) == LONG_PRESS) && (key_get_event(&key3) == LONG_PRESS)) {
HAL_GPIO_WritePin(rgb_g_GPIO_Port, rgb_g_Pin, GPIO_PIN_RESET); // rgb灯绿色
HAL_GPIO_WritePin(rgb_g_GPIO_Port, rgb_r_Pin, GPIO_PIN_RESET); // rgb灯红色
}
使用key_get_event可以获取按键当前的事件
3.每个按键的每个事件有自己独立的多个回调函数,回调函数使用虚函数表存储
typedef void (*key_cb)(void);
key_cb_t press_cb[KEY_CB_NUM]; // 按下回调
key_cb_t bounce_cb[KEY_CB_NUM]; // 弹起
key_cb_t click_cb[KEY_CB_NUM]; // 按下弹起(单击)
key_cb_t dbclick_cb[KEY_CB_NUM]; // 双击
key_cb_t lp_cb[KEY_CB_NUM]; // 长按
在回调函数注册时可根据需求设置同步/异步处理
void key_onpress_register(key_t *key_num, key_cb callback, uint8_t index, uint8_t async) {
if((index > KEY_CB_NUM-1) || (index < 0) || (callback == NULL))
return;
key_num->press_cb[index].callback = callback;
key_num->press_cb[index].async = async;
}
比如对于hello world这种重要的事情(手动狗头)我们采用同步处理,而点亮LED这种可以选择异步处理。
4.异步处理的函数使用队列管理
static void key_press_callback(key_t *key_num) {
uint8_t i;
for (i=0; i<KEY_CB_NUM; i++) {
if (key_num->press_cb[i].async == SYNC) { // 同步,立即执行
key_num->press_cb[i].callback();
}
else if(key_num->press_cb[i].async == ASYNC) { // 异步,加入队列
queue_insert(&key_cb_queue, key_num->press_cb[i].callback);
}
}
}
void key_callback_handle(void) {
uint8_t i, len;
key_cb callback = NULL;
len = queue_length(key_cb_queue);
for (i=0; i<len; i++) {
queue_extract(&key_cb_queue, (QElemType*)&callback);
callback();
}
}
这样我们就只需要让key_callback_handle定期执行就可以了,如10ms。
5.兼容ADC按键跟GPIO按键
#define key_port void //第几组引脚
#define key_pin uint16_t //第几个引脚
// 对应GPIO_TypeDef
typedef struct {
key_pin pin[KEY_ADC_NUM];
} key_adc_port_t;
把一个ADC引脚抽象为一个key_adc_port_t,ADC引脚控制的每个按键抽象为pin[i],这样就可以类似GPIO一样使用ADC引脚了。ADC按键读键值函数如下:
static key_level_e key_adc_read(key_port *port, key_pin pin) {
key_adc_port_t *adc_port = (key_adc_port_t *)port;
return adc_port->pin[pin] ? KEY_HIGH : KEY_LOW;
}
而pin[i]的值由以下函数计算:
void key_adc_update(key_adc_port_t *KeyADCPort, uint32_t adc_value) {
uint8_t i;
uint32_t voltage = adc_value;
//单个按键按下
for (i = 0; i < KEY_ADC_NUM; i++) {
KeyADCPort->pin[i] = (voltage < key_adcRange[i][0] && voltage > key_adcRange[i][1]) ? KEY_HIGH : KEY_LOW;
}
//2个按键按下
if (voltage < key_adcRange[4][0] && voltage > key_adcRange[4][1]) {
KeyADCPort->pin[0] = KEY_HIGH;
KeyADCPort->pin[1] = KEY_HIGH;
}
......
在我的板子里,ADC按键是支持多个按键同时按下的,所以四个按键有15种情况,后面就是用 if 把各种情况列出来。如果只支持单个按键按下,那么只要开始的for循环就行。
三、程序的使用
1.首先在key_config.h文件配置我们的参数,例如:
#define key_port void //第几组引脚
#define key_pin uint16_t //第几个引脚
/* 定期执行时间 */
#define KEY_TIME_BASE 1 //ms
#define KEY_NUMBER 6 // 按键数量
#define KEY_ADC_NUM 4 // ADC按键数量
#define KEY_CB_NUM 6 // 每个按键可注册的回调函数数量
#define KEY_QUEUE_LEN 50 // 队列长度
2.然后是初始化,每个按键就是一个对象,初始化时需要传入对象、读键值函数、引脚端口、ID,同时根据实际情况使用key_set_level设置按键的有效电平。
根据需要创建按键
extern key_t key1;
extern key_t key2;
extern key_t key3;
extern key_t key4;
extern key_t key5;
extern key_t key6;
void key_init(void) {
/* 链表、队列初始化 */
slink_init(&key_link, key_sll, KEY_NUMBER+2);
queue_init(&key_cb_queue, key_cb_array, KEY_QUEUE_LEN);
/* 创建按键 */
/* gpio按键 */
key_create(&key1, &key_level_read, (key_port*)keya_GPIO_Port, keya_Pin, 1);
key_create(&key2, &key_level_read, (key_port*)keyb_GPIO_Port, keyb_Pin, 2);
/* adc按键 */
key_create(&key3, &key_adc_read, (key_port*)&KeyADCPort, 0, 3);
key_create(&key4, &key_adc_read, (key_port*)&KeyADCPort, 1, 4);
key_create(&key5, &key_adc_read, (key_port*)&KeyADCPort, 2, 5);
key_create(&key6, &key_adc_read, (key_port*)&KeyADCPort, 3, 6);
/* 设置adc按键高有效 */
key_set_level(&key3, KEY_HIGH);
key_set_level(&key4, KEY_HIGH);
key_set_level(&key5, KEY_HIGH);
key_set_level(&key6, KEY_HIGH);
}
3.注册回调函数
void key_demo_init(void) {
/* 注册回调函数 */
key_onpress_register(&key1, key1_press, 0, SYNC);
key_onbounce_register(&key1, key1_bounce, 0, SYNC);
key_onclick_register(&key1, key1_click, 0, SYNC);
key_ondbclick_register(&key1, key1_dbclick, 0, SYNC);
key_onlp_register(&key1, key1_lp, 0, SYNC);
key_onclick_register(&key3, key3_click, 0, ASYNC);
key_onclick_register(&key4, key4_click, 0, ASYNC);
key_onclick_register(&key5, key5_click, 0, ASYNC);
key_onclick_register(&key6, key6_click, 0, ASYNC);
}
当然,回调函数可以在任何时候注册/注销,比如
void key1_click(void)
{
key_onclick_unregister(&key3, 0);
printf("按键1单击\n");
}
void key1_dbclick(void)
{
key_onclick_register(&key3, key3_click, 0, SYNC);
printf("按键1双击\n");
}
这在我们写屏幕菜单的时候很有用,比如在第一页的按键是功能1,进入第二页则是功能2,这时我们可以把功能1回调函数注销后注册功能2。
4.主要的代码在key.c、key.h、key_config.h其余依赖文件有:
public.c/public.h
staticLinkList.c/staticLinkList.h
my_queue.c/my_queue.h
四、按键驱动的测试结果和效果展示
图里的效果是根据demo里的回调函数产生的,按顺序分别是单击、双击、连按、长按、连按、单击
五、总结与展望
这个按键驱动应该能满足大部分单片机的应用场景,而且使用简单,里面还有多种数据结构,方便入门学习。目前使用笔记多的是GPIO按键跟ADC按键,以后可能会根据需求添加别的按键类型。
还有个题外话,就是在触发双击事件的同时会触发两次单击,可能有的同学会问为什么不在驱动层解决这个问题。其实一般的解决办法是检测到单击后先不触发事件,等过了双击时间还没双击事件再触发单击事件,这样一来就使单击的灵敏度变低,就不适合一些实时应用场景了。所以驱动层只给出基础的功能比较好,让用户根据自己需要进行设计。
这里我抛砖引玉给出一个办法,就是使用软件定时器。在不想被双击触发的单击回调函数里开启一个定时器x(如500ms),在这段时间里如果没有双击触发,则会触发定时器回调,在定时器回调函数执行单击事件。如果有双击事件触发(双击回调带有停止定时器x语句),则不会进入定时器回调,这样就解决了上面的问题。
因个人能力有限,如果有写得不对的地方欢迎大家提出/补充。