easy_button 按键处理
说明:这篇先按你最终原稿的顺序走。原稿里已经有的思路、图片、代码顺序尽量保留;课件和 README 只在你原稿明显缺解释、写了“课件”或留了补充提示的地方补进去。课件示例主要是
STM32 + CubeMX + HAL,当前备赛主控是GD32F470VET6,所以思路能复用,但引脚、参数、工程写法不能照抄。
先问自己:我这次回来到底要补哪块?
- 我现在能不能先看原理图,再判断按键该配上拉还是下拉?
- 我会不会还是一看到例程就照抄引脚,没有先改成自己的接线?
CubeMX里按键这块,我到底最该盯哪两个配置?MX_GPIO_Init()放在我面前时,我能不能看出来按键脚是怎么初始化的?- 最简单的 4 行代码,我现在是“会抄”,还是已经知道它是在抓按下和松开的瞬间?
extern、调度器 、最小验证这几步,我会不会总漏掉一步?- 我现在是继续把最简单扫描版练熟,还是已经该上
ebtn这种按键库? ebtn里面的key_id和key_idx,我会不会还在混?- 我现在缺的是“会移植库”,还是缺“理解事件驱动 / 状态机 / 回调”这几个概念?
带着问题回来查时,可以这样找:
- 想补输入模式和原理图判断:看“GPIO输入模式”
- 想补
CubeMX和初始化:看“cubemax配置”和“代码” - 想补最简单按键任务怎么搭:看“最简单的4行代码实现”
- 想补
HAL_GPIO_ReadPin()到底干了什么:直接看那一节 - 想补为什么后面要上框架、框架怎么接:看“ebtn库”
GPIO输入模式
输入需要读,默认电平很重要。
上拉
是什么,有什么用,什么时候用。
先记一句话:上拉,就是这个输入脚平时没人碰它的时候,默认是高电平。
按键这里最常见的就是上拉。因为很多按键电路都是“按下接地”。这样平时高电平,按下变低电平,代码判断也顺手。
下拉
和上拉相反。
下拉就是让输入脚默认保持低电平,按下后再变成高电平。
一般使用上拉电阻。
这句不是说下拉不能用,而是按键这块大多数时候上拉更常见。你后面看到很多例程默认“低电平按下”,本质上就是因为它用的是上拉。
为什么可以通过电阻上下拉?爱上半导体 up 主有视频可以看一下。
这里先别钻太深。当前先吃透结论:上下拉电阻就是给输入脚一个稳定默认值,不然引脚容易悬空乱跳。
浮空
引脚高阻态,简单的数字输入极少用浮空模式,浮空很容易“受惊”。
这句很关键。浮空不是“高级”,而是没人管它,所以很容易受干扰。做普通按键时,一般不直接这样用。
小结
总结上面的三种模式:
| 模式 | 默认状态 | 按键里常见吗 | 你现在先怎么记 |
|---|---|---|---|
| 上拉 | 默认高电平 | 最常见 | 按下后常变低 |
| 下拉 | 默认低电平 | 也能用,但相对少 | 按下后常变高 |
| 浮空 | 默认不确定 | 一般不直接用 | 容易受干扰 |
如何通过看原理图判断使用上下拉电阻?
下方原理图是上拉电阻。
这里真正要记的不是“上拉电阻”四个字,而是顺序别反了。
不要先看例程怎么写,再去套自己的板子。
先看原理图。
先看三件事:
- 按键一端接的是
GND还是VCC - 输入脚空闲时是被拉高还是拉低
- 按下后电平怎么变化
如果按键按下后接地,空闲时又被电阻拉到高电平,那就是典型上拉输入,代码里通常就会写成“读到低电平表示按下”。
为什么还要接电容?
多问 AI:“在设计按键的时候为什么要设计一个电容”。
硬件消抖。
你这里先记住这几个点:
- 电容不是装饰,是为了减小按键抖动
- 有 RC 电路时,可以先做一层硬件消抖
- 就算有硬件消抖,复杂按键功能后面通常还是要软件判断
为什么不去类比一下蓝桥杯?
蓝桥杯原理图就没有电容,所以要软件消抖。
这张图不是拿来“看个热闹”的。
它在说明按键外围怎么接,也是在提醒你:先看原理图,再决定软件里怎么判断,后面要不要消抖。

尽量不要和例程的引脚保持一致,连最简单的原理图都不会看,是失败的。
这句说得重,但意思是对的。引脚一定跟自己的板子走,不要跟例程走。
cubemax配置
选取对应引脚配置 input。
先把对应引脚配成输入模式。

这张图在说明:第一步先别急着管别的,先把按键对应引脚改成输入。
配置 GPIO 上拉电阻。
再按原理图去配上拉。

这张图在说明:输入模式定完以后,还要继续把内部上下拉选对,这里是上拉。
这里先别想复杂,先记住两件事:
- 引脚模式要对
- 上下拉要对
你后面换标准库或者自己写寄存器,变的是写法,不变的是这两个动作。
代码
配置完依旧是在 main.c 里看 MX_GPIO_Init();
CubeMX 点完不是结束,还是得回到代码里看它到底生成了什么。

这张图在说明:最后还是要落回 MX_GPIO_Init(),确认生成代码确实按你的预期在初始化按键脚。
标准库就是自己改,不能用可视化软件。
这句放到你现在的节奏里,应该这样理解:
- 前期可以借 CubeMX 看思路
- 但后面不能离了它就不会改
- 标准库、寄存器版本,迟早要自己能写
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOE_CLK_ENABLE();
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOH_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(GPIOB, LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin, GPIO_PIN_RESET);
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(GPIOD, LED5_Pin|LED6_Pin, GPIO_PIN_RESET);
/*Configure GPIO pins : KEY1_Pin KEY2_Pin KEY3_Pin KEY4_Pin
KEY5_Pin */
GPIO_InitStruct.Pin = KEY1_Pin|KEY2_Pin|KEY3_Pin|KEY4_Pin
|KEY5_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOE, &GPIO_InitStruct);
/*Configure GPIO pin : KEY6_Pin */
GPIO_InitStruct.Pin = KEY6_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(KEY6_GPIO_Port, &GPIO_InitStruct);
/*Configure GPIO pins : LED1_Pin LED2_Pin LED3_Pin LED4_Pin */
GPIO_InitStruct.Pin = LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
/*Configure GPIO pins : LED5_Pin LED6_Pin */
GPIO_InitStruct.Pin = LED5_Pin|LED6_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOD, &GPIO_InitStruct);
}
这里先盯最关键的初始化信息:
- 这段最关键的是
Mode = GPIO_MODE_INPUT - 以及
Pull = GPIO_PULLUP - 如果你后面换成标准库或寄存器,还是先做这两件事
key_app.c
#include "key_app.h"
key_app.h
复制 led_app.h 的头文件,稍作修改。
#ifndef KEY_APP_H
#define KEY_APP_H
#include "mydefine.h"
#endif
这里其实差一步:key_task() 后面要记得在 .h 里声明,不然这个头文件只是空架子。
最简单的4行代码实现
key_app.c
#include "key_app.h"
uint8_t key_val,key_old,key_down,key_up;
//底层
uint8_t key_read()
{
uint8_t temp = 0;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_3) == GPIO_PIN_RESET) temp = 1;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_2) == GPIO_PIN_RESET) temp = 2;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_5) == GPIO_PIN_RESET) temp = 3;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET) temp = 4;
if(HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_RESET) temp = 5;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_6) == GPIO_PIN_RESET) temp = 6;
return temp;
}
//应用层,放到.h申明
void key_task()
{
key_val = key_read();
key_down = key_val & (key_old ^ key_val);
key_up = ~key_val & (key_old ^ key_val);
key_old = key_val;
if(key_down == 1)
{
//需要extern
ucLed[0] ^= 1;
}
}
这 4 行你先整体记住,先别拆太碎。
key_val = key_read();
key_down = key_val & (key_old ^ key_val);
key_up = ~key_val & (key_old ^ key_val);
key_old = key_val;
先记作用:
- 每次调度时先读当前按键值
- 再和上一次结果比较
- 抓“刚按下”和“刚松开”这两个瞬间
然后顺手指出一下这版的边界:
- 这里的
key_read()返回的是按键编号,不是位图 - 所以它更适合做当前这种“先跑通一个简单按键任务”
- 真到多键并发、双击、长按、组合键,就不该停在这版
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_3) == GPIO_PIN_RESET) temp = 1;
//GPIOE对应PE,GPIO_PIN_3对应3,GPIO_PIN_RESET对应0,temp对应键位值
//按照我的接线应该写成
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_2) == GPIO_PIN_RESET) temp = 1;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_3) == GPIO_PIN_RESET) temp = 2;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET) temp = 3;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_5) == GPIO_PIN_RESET) temp = 4;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_6) == GPIO_PIN_RESET) temp = 5;
if(HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_RESET) temp = 6;
这段一定要留着。
因为这里体现的不是“改了几个引脚号”,而是你的习惯:
- 看自己的接线
- 按自己的板子改
- 不照着例程抄
mydefine.h
#include "main.h"
#include "scheduler.h"
#include "led_app.h"
#include "key_app.h"
//外部引用:别的文件里定义,这里只声明
extern uint8_t ucLed[6];
这里把这块补清楚一点。
先记最够用的一句:extern 的意思就是“这个变量不是在我这里定义的,但我要在这里用它”。
放到你这段代码里,就是:
ucLed[6]真正定义的位置,不在key_app.c- 但
key_task()里又要改它 - 所以这里先用
extern uint8_t ucLed[6];告诉编译器:这个数组别的地方已经有了,我这里只是引用
你现在可以先把变量作用域粗分成这三种来记:
- 写在函数里面的变量:一般只在这个函数里用
- 写在某个
.c文件全局位置的变量:这个文件里都能用 - 写在别的
.c文件里的全局变量:如果当前文件也要用,就要先extern
所以这里如果不写 extern,key_app.c 里直接用 ucLed[0] ^= 1;,编译器就不知道这个 ucLed 是哪来的。
key_app.h
#ifndef __KEY_APP_H__
#define __KEY_APP_H__
#include "mydefine.h"
void key_task(void);
#endif
记得把 key_task 放到 scheduler.c 里,惯例 10ms。
static task_t scheduler_task[] =
{
{led_task, 1, 0},
{key_task, 10, 0},
};
然后烧录验证一下,模块测试的习惯。
if(key_down == 1)
{
//需要extern
ucLed[0] ^= 1;
}
别一上来就想着双击、长按、菜单。
先把这个最小验证跑通:按一下键,灯翻转一次。
HAL_GPIO_ReadPin()` 到底帮你做了什么
GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
GPIO_PinState bitstatus;
/* Check the parameters */
assert_param(IS_GPIO_PIN(GPIO_Pin));
if((GPIOx->IDR & GPIO_Pin) != (uint32_t)GPIO_PIN_RESET)
{
bitstatus = GPIO_PIN_SET;
}
else
{
bitstatus = GPIO_PIN_RESET;
}
return bitstatus;
}
HAL 不需要考虑不一样的寄存器,简单知道一下。
真正要记的是:
- 它就是去读输入数据寄存器
IDR - 最后返回
GPIO_PIN_SET或GPIO_PIN_RESET - 所以你写
== GPIO_PIN_RESET,本质上就是在判断当前是不是低电平
ebtn库
日常开发,不可能所有东西是自己写,也不是靠 AI 写,框架用现成的。
而且靠网上资源。
比如,问 AI:“请你在 GitHub 上帮我搜索几个嵌入式开发中比较完善的按键管理框架”。
ai 进行第一遍过滤。
readme 不是中文的一般不移植。
找到好用的框架,或者工作室推荐。
你现在这个阶段,优先用自己看得懂、例子完整、能改得动的库。
1 下载(梯子)
2 看 readme
3 新建文件夹,Components 组件库

这张图在说明:库文件别乱塞,先在工程里给第三方组件单独留位置,后面才不容易越用越乱。
4 按照 APP 的思路添加以及配置
这个容易忘记,忘记了引用不了 ebtn.h。

这张图在说明:除了把源码加进工程,还得把头文件路径配进去,不然编译器根本找不到库。
5 好的框架是可以直接编译成功的
6 APP 里,创建组件库对应的应用层,比如 btn_app.c

这张图在说明:库本体和你自己的应用层要分开,业务代码别直接糊进库里。
要改的代码,在 app 里注册。
和之前一样的代码实现思路。
先理解:这个库到底强在哪
README 里明确写
它自己强调的优势主要是这几个:
- 支持组合按键
- 支持批量扫描
- 单个按键 RAM 占用比较小
- 支持静态注册,也支持动态注册
README 里还把它和 FlexibleButton、MultiButton、lwbtn 做了对比,结论就是:功能很全,尤其适合按键多的场景,比如键盘、矩阵键盘这类。
放到你现在的理解里,可以先记成一句话:
这个库不是为了“帮你读一个键”,而是为了“把一整套按键事件管理起来”。
移植之前的概念
ebtn,按键处理的瑞士军刀?
这个说法可以这么理解:你不是只拿它来判断“有没有按下”,而是拿它来统一管:
- 单击
- 双击
- 多击
- 长按
- 组合键
也就是说,前面你手写的 4 行代码,更像入门版;ebtn 更像真正的工程版。
事件驱动
不会一直检查状态。
比轮询的效率高很多,也很符合人类直觉。
课件这里用的是“门铃”那个比喻,这个比喻是对的。
你不用每次都问:“这个键现在按了没?”
而是:
- 当事件发生了
- 框架通知你
- 你再做对应动作
这就叫事件驱动。
状态机
状态切换的固定规则和顺序。
状态机思想,难,代码不一定难。
比如用这个思想优化实现之前的代码。
后面使用这个思想。
先枚举状态,再看每个状态切换的规则和顺序。
这里补成你当前最该懂的版本:
状态机,不是为了显得高级,而是为了把“按键现在处于哪个阶段”管清楚。
比如一个键,不是只有“按下 / 没按”这么简单,它可能会经历:
- 空闲
- 刚检测到按下,等消抖
- 已经稳定按下
- 等待释放
- 释放后判断是不是单击、多击、长按
如果不用状态机,这些逻辑就会散在很多 if 里面,越写越乱。
如果用了状态机,你就是先把状态列出来,再规定:
- 什么条件下从 A 切到 B
- 什么条件下从 B 切到 C
- 切换时要不要触发事件
你可以先把状态机理解成:把混乱的按键判断,变成“按顺序走格子”。
回调函数
回调函数这里先抓住两件事:一个负责取状态,一个负责接收事件。
现阶段核心是思想和概念。
这里也直接补清楚:
这个库真正需要你提供的,核心就两类函数:
- 获取状态函数:告诉库“这个键现在是不是有效按下”
- 事件通知函数:库判断出事件后,反过来通知你
所以回调函数这套东西,你先别想得太玄。
放到这里就是:
- 库负责判断
- 你负责响应
这就是解耦。
如何使用ebtn库(5步讲解)
课件和 README 这块是能对上的,所以这里就按你原稿问题来补。
如果你现在对“移植组合库”没感觉,先别急着上板。先把 README 里的接入顺序理顺,再照着一点点搬。
这里不要一上来陷进源码,先看 README 把调用链理顺,再去看示例代码确认每一步怎么落到工程里。
1 包含头文件
这一步最简单,就是让你的工程先能看到 ebtn.h。
如果只是把文件拷进去了,但 Keil 里没把头文件路径加进 Include Paths,后面一样会报找不到头文件。
2 定义参数与按键列表
EBTN_PARAMS_INIT//定义宏
这一步别只记一个宏名,真正落地时就是下面这三部曲。
这三步其实就是 README 的 Step1,课件里也在讲这个思路:
/* 1. 定义按键参数实例 */
// 参数宏: EBTN_PARAMS_INIT(
// 按下消抖时间ms, 释放消抖时间ms,
// 单击有效最短按下时间ms, 单击有效最长按下时间ms,
// 多次单击最大间隔时间ms,
// 长按(KeepAlive)事件周期ms (0禁用),
// 最大连续有效点击次数 (e.g., 1=单击, 2=双击, ...)
// )
const ebtn_btn_param_t key_param_normal = EBTN_PARAMS_INIT(
20, // time_debounce: 按下稳定 20ms
20, // time_debounce_release: 释放稳定 20ms
50, // time_click_pressed_min: 最短单击按下 50ms
500, // time_click_pressed_max: 最长单击按下 500ms (超过则不算单击)
300, // time_click_multi_max: 多次单击最大间隔 300ms (两次点击间隔超过则重新计数)
500, // time_keepalive_period: 长按事件周期 500ms (按下超过 500ms 后,每 500ms 触发一次)
5 // max_consecutive: 最多支持 5 连击
);
/* 2. 定义静态按键列表 */
// 宏: EBTN_BUTTON_INIT(按键ID, 参数指针)
ebtn_btn_t static_buttons[] = {
EBTN_BUTTON_INIT(1, &key_param_normal), // KEY1, ID=1, 使用 'key_param_normal' 参数
EBTN_BUTTON_INIT(2, &key_param_normal), // KEY2, ID=2, 也使用 'key_param_normal' 参数
};
/* 3. 定义静态组合按键列表 (可选) */
// 宏: EBTN_BUTTON_COMBO_INIT(按键ID, 参数指针)
ebtn_btn_combo_t static_combos[] = {
// 假设 KEY1+KEY2 组合键
EBTN_BUTTON_COMBO_INIT(101, &key_param_normal), // 组合键, ID=101 (必须与普通按键ID不同)
};
这三步你先这样理解:
- 先定义“这批按键的行为规则”
- 再定义“有哪些普通按键”
- 最后如果要组合键,再单独定义组合键对象
这里最容易混的是参数宏那一坨。你现在先不用硬背顺序,先记每项大概在管什么:
- 消抖
- 单击时间范围
- 多击间隔
- 长按周期
- 最大连击数
枚举,这里是区分度。
typedef enum
{
USER_BUTTON_0 = 0, // 这里定义 0,后面会自动递增
USER_BUTTON_1,
USER_BUTTON_2,
USER_BUTTON_3,
USER_BUTTON_4,
USER_BUTTON_5,
USER_BUTTON_MAX,
} user_button_t;
// 绑定按键 ID 和默认参数
static ebtn_btn_t btns[] =
{
EBTN_BUTTON_INIT(USER_BUTTON_0, &defaul_ebtn_param), // 默认参数
EBTN_BUTTON_INIT(USER_BUTTON_1, &defaul_ebtn_param), // 枚举名比直接写 1 更直观
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),
};
这里一起补掉你原稿里的问号:
USER_BUTTON_0 = 0后面不写值,C 会自动往下加 1- 所以下面依次就是
1 2 3 4 5 - 用枚举的好处不是“更高级”,而是比直接写数字更直观
EBTN_BUTTON_INIT(USER_BUTTON_0, &defaul_ebtn_param)的意思就是:给这个按键一个业务编号,再把默认参数绑定过去defaul_ebtn_param和前面的key_param_normal本质上是一个东西,都是“这组按键默认用的参数”
3 编写回调函数
4 初始化按键驱动
ebtn_init(btns, EBTN_ARRAY_SIZE(btns), btns_combo, EBTN_ARRAY_SIZE(btns_combo),
prv_btn_get_state, prv_btn_event);
// 核心接线点就是状态读取函数和事件回调函数
这块正好是整个框架最核心的地方。
为什么除了这两个,其他都“像是已经好了”?
因为前面定义的数组和参数,本质上都只是“配置数据”。
真正让库和你的硬件连起来的,就是这两个函数:
prv_btn_get_state:告诉库,某个键现在到底是不是按下prv_btn_event:告诉库,发生事件后你要做什么
也就是说:
- 前面的数组,是“描述”
- 这两个函数,才是“接线”
如果不考虑组合按键,btns_combo 这里完全可以先传 NULL, 0,后面你原稿也已经这样写了。
uint8_t prv_btn_get_state(struct ebtn_btn *btn)
{
switch (btn->key_id)
{
case USER_BUTTON_0:
return !HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_3);//绑定具体的按键
case USER_BUTTON_1:
return !HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_2);//换成你自己的 GPIO 和引脚
case USER_BUTTON_2:
return !HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_5);
case USER_BUTTON_3:
return !HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4);
case USER_BUTTON_4:
return !HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13);
case USER_BUTTON_5:
return !HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_6);
default:
// 对于组合键或其他未明确处理的 ID,返回 0
return 0;
}
}
这里你原稿里的问号也一起补掉:
- 是的,核心就是把它换成你自己的
!HAL_GPIO_ReadPin(GPIOx, GPIO_PIN_x); - 前面的
switch(btn->key_id)是在做“编号 → 具体硬件引脚”的映射 !HAL_GPIO_ReadPin(...)的意思是:因为当前按键是低电平按下,所以把RESET(0)取反成1- 这个函数返回给
ebtn的,不是引脚原始电平,而是“这个键现在算不算有效按下”
// EBTN_EVT_ONPRESS = 0x00, /*!< 按下事件 - 检测到有效按下时发送 */
// EBTN_EVT_ONRELEASE, /*!< 释放事件 - 检测到有效释放事件时发送 (从活动到非活动) */
// EBTN_EVT_ONCLICK, /*!< 单击事件 - 发生有效的按下和释放事件序列时发送 */
// EBTN_EVT_KEEPALIVE, /*!< 保持活动事件 - 按钮处于活动状态时定期发送 */
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;
}
}
}
这个回调函数就是“事件到了以后,你自己的业务层要干嘛”。
这里也顺手把 README 的事件思想补进来:
- 库只给你 4 类事件
- 但通过
click_cnt和keepalive_cnt,你其实可以扩展出很多玩法 - 这也是 README 里说“事件类型少,但很灵活”的原因
void app_ebtn_init(void)
{
ebtn_init(btns, EBTN_ARRAY_SIZE(btns), NULL, 0, prv_btn_get_state, prv_btn_event);//传入空指针,0
}
这一句就对应 README 里的初始化步骤。这里传 NULL, 0,就是告诉库:当前这版先不启用组合按键。
5 周期性调用处理函数
btn_app.c
void btn_task(void)
{
ebtn_process(HAL_GetTick());
}
btn_app.h
#ifndef __BTN_APP_H__
#define __BTN_APP_H__
#include "mydefine.h"
void btn_task(void);
void app_ebtn_init(void);
#endif
mydefine.h
#include "main.h"
#include "scheduler.h"
#include "led_app.h"
#include "key_app.h"
#include "btn_app.h"
extern uint8_t ucLed[6];
scheduler.c
static task_t scheduler_task[] =
{
{led_task, 1, 0},
{btn_task, 5, 0},
};
main.c
MX_GPIO_Init();
/* USER CODE BEGIN 2 */
app_ebtn_init();
scheduler_init();
/* USER CODE END 2 */
这一串代码你现在可以直接记成移植最小闭环:
- 库文件放进工程
- 头文件路径配好
- 定义参数和按键数组
- 写状态读取函数和事件回调函数
- 初始化
- 周期调
ebtn_process()
到底怎么移植
作者没写很详细怎么办?问 ai。
“请你阅读这个框架,把我当成小白一步步教我怎么移植使用在 STM32 HAL 库的应用场景中”。
这里把这段话落成真正的做法:
- 先看 README,把调用链理出来
- 再看
example_user.c和example_test.c,确认作者是怎么调用的 - 然后只抄“接入顺序”,不要一上来全抄示例代码
- 最后再换成你自己的 GPIO 和业务逻辑
进阶:组合按键
这块你原稿明确写了要结合单键思路讲,那这里就直接接着单键讲。
单键时,你做的是:
- 给每个物理按键一个
key_id - 写
prv_btn_get_state()把编号映射到具体引脚 - 在事件回调里按
key_id做动作
组合键时,多出来的核心不是“再写一套扫描逻辑”,而是:
- 先定义组合键对象
- 再告诉这个组合键由哪些单键组成
README 里专门强调了这一点:它支持组合键,是因为内部用 BitArray 管理状态,不用你自己重复写一套组合键扫描逻辑。
最基本的绑定方式就是:
ebtn_combo_btn_add_btn(&btns_combo[0], USER_BUTTON_0);
ebtn_combo_btn_add_btn(&btns_combo[0], USER_BUTTON_1);
这里再补你后面一定会遇到的坑:
key_id 和 key_idx
README 里专门单独讲了这个。
你先这么记:
key_id:你自己定义的按键编号,给业务层看的key_idx:驱动内部按键位置,给组合键和批量扫描这些内部机制用的
为什么会多出一个 key_idx?
因为这个库为了支持:
- 组合按键
- 批量扫描
内部要把很多按键状态统一放进一个位数组里管理,所以它必须知道“第几个位置是哪个键”。
这就是 key_idx。
所以你后面如果组合键绑定不对,优先怀疑这两个概念是不是混了。
README 里还提了一点风险:
- 动态按键目前不支持删除
原因也很直接:组合键和内部索引一旦绑定好了,中途删掉会把索引关系搞乱。
ebtn的小总结
这里直接收成结论。
- 这个库最值钱的不是“能单击双击”,而是把按键管理变成统一框架
- 它靠 4 种事件 + 计数,覆盖了大多数按键场景
- 它对多键、组合键、批量扫描这类场景更有优势
- 你现在先把单键版本移植通,再去碰组合键
- 真正难的不是 API 数量,而是把“状态 → 事件 → 业务动作”这条链理顺
作业
按键0+按键1 复制 ucLed
按键0+按键2 粘贴 ucLed
按键0+按键3 剪切 ucLed(复制完后全部熄灭)
用四行代码和框架实现。
然后对比总结思考:
- 没有组合按键和单键的区分,怎么优化
这里顺手补一条你做题时要先注意的点:
如果组合键和单键共用同一批按键,最容易出的问题就是:
- 单键事件已经先触发了
- 组合键还没来得及判断
所以你后面做这个作业时,要重点思考的是:单键和组合键冲突时,优先级怎么处理。
核心
强化对 AI 的使用。
这句原稿保留,但收口成更实在的一句:
老师没展开、README 写得不够顺、示例代码看不懂时,不要卡住。主动让 AI 帮你拆调用链、拆结构、拆移植步骤,这本身也是你现在必须练的能力。