第五讲key与etbn

第五讲key模块与easy_button库

本讲总览

这一讲主要学 3 件事:

  • 先搞清楚按键输入为什么要上拉、下拉,为什么不能浮空。

  • 再学会用 HAL_GPIO_ReadPin() 读取按键电平,写出最基础的按键扫描。

  • 最后结合当前工程,理解为什么项目里更适合用 easy_button 这种事件驱动方式。

复习时先抓住 3 个结论:

  • 先把硬件特性看清楚最重要,这个工程是上拉输入,所以按下时读到低电平。

  • 手写扫描更适合拿来理解按键本质,尤其是“读电平”和“判断边沿”这两个点。

  • 真正换项目时,不要急着搬代码,先看硬件,再读 .h,再看范例和文档,最后再接自己的业务。

上拉、下拉、浮空

上拉

上拉输入的默认状态是高电平。

下拉

下拉输入的默认状态是低电平。

浮空

浮空就是输入口没有明确默认电平,所以特别容易受干扰,状态也不稳定。

image-20260425202603550

电阻和电容在按键电路里的作用

电阻的位置决定是上拉还是下拉

这句话可以直接记成:

  • 电阻把引脚通过电阻接到 VCC,就是上拉。

  • 电阻把引脚通过电阻接到 GND,就是下拉。

它的作用就是给输入口一个默认电平,防止引脚悬空。

所以:

  • 上拉默认高电平

  • 下拉默认低电平

电容的作用主要是抗抖

机械按键在按下和松开的一瞬间,触点不会立刻稳定,而是会在很短时间内反复抖动。

如果不处理,常见现象就是:

  • 按 1 次却识别成很多次

  • 单击被误判成双击

  • LED 状态乱跳

电容可以让电平变化更平稳,相当于先做一层简单的硬件滤波。

image-20260425204146939

image-20260425211012584

这里直接记一句最实用的话:

  • 电阻负责给默认电平

  • 电容负责减轻抖动

但项目里一般还要配合软件消抖。easy_button 这种按键库,本质上就已经把软件消抖考虑进去了。

结合当前工程得出的结论

当前工程里的按键都配置成了 GPIO_MODE_INPUT + GPIO_PULLUP,也就是上拉输入。

这意味着:

  • 没按下时是高电平

  • 按下后是低电平

所以代码里判断按键按下时,常写成:

 HAL_GPIO_ReadPin(...) == GPIO_PIN_RESET

结合当前工程看引脚

当前工程里按键和 LED 的关系如下:

  • KEY1 ~ KEY5GPIOEPE11 ~ PE15

  • KEY6GPIOBPB10

  • LED1 ~ LED4GPIOBPB12 ~ PB15

  • LED5 ~ LED6GPIODPD8 ~ PD9

所以后面不管是手写扫描,还是接 easy_button,本质上都是围绕这些引脚去读状态、做处理。

HAL 库利器:HAL_GPIO_ReadPin() 解读

原型可以先记成:

 GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);

它的作用很直接:

  • GPIOx:读哪个端口

  • GPIO_Pin:读哪个引脚

  • 返回值:GPIO_PIN_SETGPIO_PIN_RESET

结合当前工程,最常见的判断方式就是:

 if(HAL_GPIO_ReadPin(GPIOE, KEY1_Pin) == GPIO_PIN_RESET)
 {
     // 说明 KEY1 被按下
 }

这里不要想反了。因为当前工程是上拉输入,所以:

  • SET 表示没按

  • RESET 表示按下

最基础的按键扫描

先看最基础的手写扫描。

 #include "key_app.h"
 ​
 uint8_t key_val,key_down,key_up,key_old;
 ​
 uint8_t key_read(){
     uint8_t temp=0;
     if(HAL_GPIO_ReadPin(GPIOE, KEY1_Pin) == GPIO_PIN_RESET) temp = 1;
     if(HAL_GPIO_ReadPin(GPIOE, KEY2_Pin) == GPIO_PIN_RESET) temp = 2;
     if(HAL_GPIO_ReadPin(GPIOE, KEY3_Pin) == GPIO_PIN_RESET) temp = 3;
     if(HAL_GPIO_ReadPin(GPIOE, KEY4_Pin) == GPIO_PIN_RESET) temp = 4;
     if(HAL_GPIO_ReadPin(GPIOE, KEY5_Pin) == GPIO_PIN_RESET) temp = 5;
     if(HAL_GPIO_ReadPin(KEY6_GPIO_Port, KEY6_Pin) == GPIO_PIN_RESET) temp = 6;
     return temp;
 }
 ​
 void key_task(){
     key_val=key_read();
     key_down=key_val&(key_val^key_old);
     key_up=~key_val&(key_val^key_old);
     key_old=key_val;
 }

这段代码的思路是:

  • key_read() 负责读当前按下的是哪个键。

  • key_val 表示这次扫描到的键值。

  • key_old 表示上一次扫描到的键值。

  • key_down 表示新按下的那个瞬间。

  • key_up 表示刚松开的那个瞬间。

这类写法适合入门,因为它能帮自己把“读电平”和“按键事件”分开理解。

但也要知道它的局限:

  • 这里只返回一个键值,更适合单键识别。

  • 如果多个键同时按下,后判断的键会覆盖前面的键。

  • 按键逻辑一复杂,手写代码很快会乱。

所以这类写法更适合理解基础,不太适合直接扩展成复杂项目。

这套代码在工程里怎么跑

当前工程没有把按键判断直接堆在 while(1) 里,而是用了一个简单调度器。

主函数初始化流程可以概括成:

 HAL_Init();
 SystemClock_Config();
 MX_GPIO_Init();
 scheduler_init();
 app_ebtn_init();

主循环里一直执行:

 while (1)
 {
     scheduler_run();
 }

调度器当前挂了两个任务:

  • led_task:每 1ms 跑一次

  • btn_task:每 5ms 跑一次

也就是说,当前工程里的按键处理链路是:

main -> scheduler_run() -> btn_task() -> ebtn_process(HAL_GetTick())

这样做的好处是:

  • 结构更清楚

  • 扫描周期固定

  • 后面扩展别的任务更方便

easy_button

easy_button 的核心关键词是:

  • 事件驱动

  • 状态机

  • 回调函数

前面的手写扫描,是自己盯着每次电平变化去判断。

easy_button 的思路是:

  • 周期性读取按键当前状态

  • 库内部负责做消抖、按下、释放、单击、多击判断

  • 真正有事件时,再通过回调函数通知应用层

也就是说,它把“按键怎么判断”这件事封装掉了,应用层更多只关心“现在发生了什么事件”。

结合当前工程理解 easy_button

当前工程里的接法如下:

 #include "btn_app.h"
 #include "ebtn.h"
 ​
 typedef enum
 {
     USER_BUTTON_0 = 0,
     USER_BUTTON_1,
     USER_BUTTON_2,
     USER_BUTTON_3,
     USER_BUTTON_4,
     USER_BUTTON_5,
     USER_BUTTON_MAX,
 } user_button_t;
 ​
 static const ebtn_btn_param_t defaul_ebtn_param = EBTN_PARAMS_INIT(20, 0, 20, 300, 200, 500, 10);
 ​
 static ebtn_btn_t btns[] = {
         EBTN_BUTTON_INIT(USER_BUTTON_0, &defaul_ebtn_param),
         EBTN_BUTTON_INIT(USER_BUTTON_1, &defaul_ebtn_param),
         EBTN_BUTTON_INIT(USER_BUTTON_2, &defaul_ebtn_param),
         EBTN_BUTTON_INIT(USER_BUTTON_3, &defaul_ebtn_param),
         EBTN_BUTTON_INIT(USER_BUTTON_4, &defaul_ebtn_param),
         EBTN_BUTTON_INIT(USER_BUTTON_5, &defaul_ebtn_param),
 };
 ​
 uint8_t prv_btn_get_state(struct ebtn_btn *btn)
 {
     switch (btn->key_id)
     {
     case USER_BUTTON_0:
         return !HAL_GPIO_ReadPin(GPIOE, KEY1_Pin);
     case USER_BUTTON_1:
         return !HAL_GPIO_ReadPin(GPIOE, KEY2_Pin);
     case USER_BUTTON_2:
         return !HAL_GPIO_ReadPin(GPIOE, KEY3_Pin);
     case USER_BUTTON_3:
         return !HAL_GPIO_ReadPin(GPIOE, KEY4_Pin);
     case USER_BUTTON_4:
         return !HAL_GPIO_ReadPin(GPIOE, KEY5_Pin);
     case USER_BUTTON_5:
         return !HAL_GPIO_ReadPin(KEY6_GPIO_Port, KEY6_Pin);
     default:
         return 0;
     }
 }
 ​
 void prv_btn_event(struct ebtn_btn *btn, ebtn_evt_t evt)
 {
     if (evt == EBTN_EVT_ONCLICK)
     {
         uint16_t click_cnt = ebtn_click_get_count(btn);
 ​
         switch (btn->key_id)
         {
         case USER_BUTTON_0:
             if (click_cnt == 1)
             {
                 ucLed[0] = 1;
             }
             else if (click_cnt == 2)
             {
                 ucLed[0] = 0;
             }
             break;
         case USER_BUTTON_1:
             if (click_cnt == 1)
             {
                 ucLed[1] = 1;
             }
             else if (click_cnt == 2)
             {
                 ucLed[1] = 0;
             }
             break;
         case USER_BUTTON_2:
             if (click_cnt == 1)
             {
                 ucLed[2] = 1;
             }
             else if (click_cnt == 2)
             {
                 ucLed[2] = 0;
             }
             break;
         case USER_BUTTON_3:
             if (click_cnt == 1)
             {
                 ucLed[3] = 1;
             }
             else if (click_cnt == 2)
             {
                 ucLed[3] = 0;
             }
             break;
         case USER_BUTTON_4:
             if (click_cnt == 1)
             {
                 ucLed[4] = 1;
             }
             else if (click_cnt == 2)
             {
                 ucLed[4] = 0;
             }
             break;
         case USER_BUTTON_5:
             if (click_cnt == 1)
             {
                 ucLed[5] = 1;
             }
             else if (click_cnt == 2)
             {
                 ucLed[5] = 0;
             }
             break;
         default:
             break;
         }
     }
 }
 ​
 void app_ebtn_init(void){
     ebtn_init(btns,EBTN_ARRAY_SIZE(btns),NULL,0,prv_btn_get_state, prv_btn_event);
 }
 ​
 void btn_task(void)
 {
     ebtn_process(HAL_GetTick());
 }

这段代码可以分成 4 步来看。

1. 先定义按键对象

btns[] 里定义了 6 个按键对象,每个按键共用同一组时间参数。

2. 用 prv_btn_get_state() 告诉库怎么读按键

这里最关键的是返回值语义:

  • 返回 1 表示按键按下

  • 返回 0 表示按键松开

当前工程是上拉输入,按下时读到低电平,所以这里用了 !HAL_GPIO_ReadPin(...) 做取反。

3. 用 prv_btn_event() 处理事件

当前工程这里只关心 EBTN_EVT_ONCLICK

然后再通过:

 uint16_t click_cnt = ebtn_click_get_count(btn);

判断是单击还是双击:

  • click_cnt == 1:单击

  • click_cnt == 2:双击

对应到当前工程,效果就是:

  • 单击点亮对应 LED

  • 双击熄灭对应 LED

4. 初始化并周期处理

初始化时调用:

 app_ebtn_init();

运行时周期调用:

 ebtn_process(HAL_GetTick());

HAL_GetTick() 提供当前毫秒时间,库内部会拿它去做消抖、点击间隔和按下时长判断。

easy_button 常用事件怎么理解

这个库对外最常用、也最重要的事件就 4 个:

  • EBTN_EVT_ONPRESS

  • EBTN_EVT_ONRELEASE

  • EBTN_EVT_ONCLICK

  • EBTN_EVT_KEEPALIVE

这里最重要的是两点:

1. 多击最后通常只上报一次 ONCLICK

双击、三击不是靠一堆新事件来区分,而是:

  • 最后统一报一次 ONCLICK

  • 再通过 click_cnt 告诉应用层这是几击

所以以后写业务逻辑时,重点是:

 evt == EBTN_EVT_ONCLICK

再结合:

 ebtn_click_get_count(btn)

来判断是单击、双击还是更多次点击。

2. 长按主要靠 KEEPALIVE

如果以后要做长按 1 秒、长按 3 秒、长按 5 秒这类功能,核心思路不是继续往代码里堆判断,而是:

  • 接收 EBTN_EVT_KEEPALIVE

  • 再结合 keepalive_cnt 判断已经按住多久

EBTN_PARAMS_INIT(...) 参数怎么理解

当前工程里写的是:

 EBTN_PARAMS_INIT(20, 0, 20, 300, 200, 500, 10)

可以先这样理解:

  • 20:按下消抖时间 20ms

  • 0:松开消抖时间这里没额外设置

  • 20:按下至少持续 20ms 才算有效点击

  • 300:按下超过 300ms 就不再按普通点击处理

  • 200:两次点击之间最多间隔 200ms,才算连续点击

  • 500:按住时每 500ms 触发一次 KEEPALIVE

  • 10:最多允许连续点击 10

现阶段最重要的是先记住一句话:

easy_button 是靠“时间参数 + 状态机”把按键动作翻译成事件。

以后换项目时,重点不是“移植文件”,而是复用这套按键思路

这部分是以后真正有用的地方。

做不同项目时,板子、芯片、按键数量、业务功能都可能不同,但按键处理通常还是围绕下面 4 层在变。

1. 硬件层会变

不同项目里最先变的通常是:

  • 按键是上拉还是下拉

  • 按下是高电平有效还是低电平有效

  • 按键数量是多少

  • 是独立按键还是矩阵键盘

所以换项目时,第一件事不是急着写逻辑,而是先确认硬件输入特性。

2. 调用方式会变

不同项目里,按键处理可能挂在不同地方:

  • 裸机 while(1) 轮询

  • 简单任务调度器

  • 定时器节拍任务

  • RTOS 周期任务

但核心思路不变:

  • 都要有固定周期去处理按键

  • 都要有稳定的毫秒时基

3. 业务层会变

不同项目里,按键功能不一样:

  • 有的项目只要单击

  • 有的项目要单击 + 双击

  • 有的项目要长按进入设置模式

  • 有的项目要几个按键做菜单控制

所以项目变化时,真正常改的是“事件对应什么功能”,而不是底层判断方法。

4. 参数层会变

不同项目的按键手感、响应要求也不一样,所以这些参数常常需要跟着项目调整:

  • 消抖时间

  • 多击间隔

  • 长按周期

  • 普通点击允许的最长按下时间

也就是说,同一套按键处理框架,换项目时最常改的通常是:

  • 引脚映射

  • 电平有效方向

  • 调用位置

  • 事件逻辑

  • 时间参数

而不是整个推倒重来。

换项目时可以按这条思路复用

我现在更认可的一套顺序是:

第一步:先看硬件特性

这一步最重要,先确认:

  • 按键是上拉还是下拉

  • 按下是高电平有效还是低电平有效

  • 有几个按键

  • 是独立按键还是矩阵键盘

因为这些会直接决定后面“怎么读按键”。

第二步:再读 .h 文件

这一步不是为了逐行硬背,而是为了先看清楚:

  • 这个库最核心的结构体是什么

  • 最核心的接口函数是什么

  • 需要我提供哪些回调

  • 它能上报哪些事件

也就是说,要先通过头文件搞清楚“这个库希望我给它什么,它又能给我什么”。

第三步:再看作者的范例和文档

这一步主要是看作者推荐的使用方式,重点看:

  • 初始化顺序

  • 回调一般怎么写

  • 扫描周期怎么安排

  • 单击、双击、长按这些逻辑一般怎么接

只看 .h 文件,能知道接口长什么样;再看范例和文档,才更容易知道它在真实项目里通常怎么用。

第四步:最后再落到自己的项目

前面三步理顺以后,再去做项目适配就会清楚很多:

  • 根据硬件特性写状态读取逻辑

  • 根据接口接初始化和周期处理

  • 根据项目需求写事件逻辑

  • 根据手感和响应要求调时间参数

这样做的好处是,不会一上来就对着例程硬抄,结果代码虽然能编译,但实际行为全是错的。

以后做新项目时,可以直接按下面这个顺序想:

  1. 先确认硬件输入特性:上拉还是下拉,按下是高有效还是低有效。

  2. 再通过 .h 文件弄清楚核心接口、事件和回调。

  3. 再看作者的范例和文档,确认推荐接法。

  4. 再确认按键处理放在哪里跑:主循环、调度器还是 RTOS 任务。

  5. 再确认业务需要什么:单击、双击、长按,还是组合功能。

  6. 最后再根据项目手感和响应要求去调时间参数。

如果这套顺序理顺了,不管项目怎么换,按键部分都不会太乱。

做不同项目时最容易踩的坑

1. 电平方向判断错

这是最常见的问题。

表现通常是:

  • 没按键却一直触发

  • 按了键却没反应

所以每次换项目,第一件事就是先确认“按下时到底应该返回 1 还是 0”。

2. 没有固定处理周期

按键处理如果没有固定节奏,双击和长按这类逻辑会很容易不稳定。

3. 毫秒时基不统一

参数按毫秒配,处理时间最好也按毫秒算,不然点击和长按窗口都会乱。

4. 业务逻辑和底层耦合太死

如果把“怎么读按键”和“按键触发什么功能”全写死在一起,项目一换就会很难改。

更好的思路是分开:

  • 底层负责给事件

  • 上层负责决定事件对应什么动作

本讲先记住这几个结论

  • 当前工程里的按键是上拉输入,所以按下时读到低电平。

  • HAL_GPIO_ReadPin() 负责把引脚电平读出来。

  • 手写 key_task() 适合帮助自己理解扫描和边沿。

  • 当前工程里更适合把按键放进调度器,再交给 easy_button 处理。

  • 以后换项目时,重点不是重新手写一套按键,而是复用“硬件确认、固定周期、事件处理、参数调整”这套思路。