第五讲key模块与easy_button库
本讲总览
这一讲主要学 3 件事:
-
先搞清楚按键输入为什么要上拉、下拉,为什么不能浮空。
-
再学会用
HAL_GPIO_ReadPin()读取按键电平,写出最基础的按键扫描。 -
最后结合当前工程,理解为什么项目里更适合用
easy_button这种事件驱动方式。
复习时先抓住 3 个结论:
-
先把硬件特性看清楚最重要,这个工程是上拉输入,所以按下时读到低电平。
-
手写扫描更适合拿来理解按键本质,尤其是“读电平”和“判断边沿”这两个点。
-
真正换项目时,不要急着搬代码,先看硬件,再读
.h,再看范例和文档,最后再接自己的业务。
上拉、下拉、浮空
上拉
上拉输入的默认状态是高电平。
下拉
下拉输入的默认状态是低电平。
浮空
浮空就是输入口没有明确默认电平,所以特别容易受干扰,状态也不稳定。

电阻和电容在按键电路里的作用
电阻的位置决定是上拉还是下拉
这句话可以直接记成:
-
电阻把引脚通过电阻接到
VCC,就是上拉。 -
电阻把引脚通过电阻接到
GND,就是下拉。
它的作用就是给输入口一个默认电平,防止引脚悬空。
所以:
-
上拉默认高电平
-
下拉默认低电平
电容的作用主要是抗抖
机械按键在按下和松开的一瞬间,触点不会立刻稳定,而是会在很短时间内反复抖动。
如果不处理,常见现象就是:
-
按 1 次却识别成很多次
-
单击被误判成双击
-
LED 状态乱跳
电容可以让电平变化更平稳,相当于先做一层简单的硬件滤波。


这里直接记一句最实用的话:
-
电阻负责给默认电平
-
电容负责减轻抖动
但项目里一般还要配合软件消抖。easy_button 这种按键库,本质上就已经把软件消抖考虑进去了。
结合当前工程得出的结论
当前工程里的按键都配置成了 GPIO_MODE_INPUT + GPIO_PULLUP,也就是上拉输入。
这意味着:
-
没按下时是高电平
-
按下后是低电平
所以代码里判断按键按下时,常写成:
HAL_GPIO_ReadPin(...) == GPIO_PIN_RESET
结合当前工程看引脚
当前工程里按键和 LED 的关系如下:
-
KEY1 ~ KEY5在GPIOE的PE11 ~ PE15 -
KEY6在GPIOB的PB10 -
LED1 ~ LED4在GPIOB的PB12 ~ PB15 -
LED5 ~ LED6在GPIOD的PD8 ~ PD9
所以后面不管是手写扫描,还是接 easy_button,本质上都是围绕这些引脚去读状态、做处理。
HAL 库利器:HAL_GPIO_ReadPin() 解读
原型可以先记成:
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
它的作用很直接:
-
GPIOx:读哪个端口 -
GPIO_Pin:读哪个引脚 -
返回值:
GPIO_PIN_SET或GPIO_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 文件,能知道接口长什么样;再看范例和文档,才更容易知道它在真实项目里通常怎么用。
第四步:最后再落到自己的项目
前面三步理顺以后,再去做项目适配就会清楚很多:
-
根据硬件特性写状态读取逻辑
-
根据接口接初始化和周期处理
-
根据项目需求写事件逻辑
-
根据手感和响应要求调时间参数
这样做的好处是,不会一上来就对着例程硬抄,结果代码虽然能编译,但实际行为全是错的。
以后做新项目时,可以直接按下面这个顺序想:
-
先确认硬件输入特性:上拉还是下拉,按下是高有效还是低有效。
-
再通过
.h文件弄清楚核心接口、事件和回调。 -
再看作者的范例和文档,确认推荐接法。
-
再确认按键处理放在哪里跑:主循环、调度器还是 RTOS 任务。
-
再确认业务需要什么:单击、双击、长按,还是组合功能。
-
最后再根据项目手感和响应要求去调时间参数。
如果这套顺序理顺了,不管项目怎么换,按键部分都不会太乱。
做不同项目时最容易踩的坑
1. 电平方向判断错
这是最常见的问题。
表现通常是:
-
没按键却一直触发
-
按了键却没反应
所以每次换项目,第一件事就是先确认“按下时到底应该返回 1 还是 0”。
2. 没有固定处理周期
按键处理如果没有固定节奏,双击和长按这类逻辑会很容易不稳定。
3. 毫秒时基不统一
参数按毫秒配,处理时间最好也按毫秒算,不然点击和长按窗口都会乱。
4. 业务逻辑和底层耦合太死
如果把“怎么读按键”和“按键触发什么功能”全写死在一起,项目一换就会很难改。
更好的思路是分开:
-
底层负责给事件
-
上层负责决定事件对应什么动作
本讲先记住这几个结论
-
当前工程里的按键是上拉输入,所以按下时读到低电平。
-
HAL_GPIO_ReadPin()负责把引脚电平读出来。 -
手写
key_task()适合帮助自己理解扫描和边沿。 -
当前工程里更适合把按键放进调度器,再交给
easy_button处理。 -
以后换项目时,重点不是重新手写一套按键,而是复用“硬件确认、固定周期、事件处理、参数调整”这套思路。