5 key以及etbn

easy_button 按键处理

说明:这篇先按你最终原稿的顺序走。原稿里已经有的思路、图片、代码顺序尽量保留;课件和 README 只在你原稿明显缺解释、写了“课件”或留了补充提示的地方补进去。课件示例主要是 STM32 + CubeMX + HAL,当前备赛主控是 GD32F470VET6,所以思路能复用,但引脚、参数、工程写法不能照抄。

先问自己:我这次回来到底要补哪块?

  • 我现在能不能先看原理图,再判断按键该配上拉还是下拉?
  • 我会不会还是一看到例程就照抄引脚,没有先改成自己的接线?
  • CubeMX 里按键这块,我到底最该盯哪两个配置?
  • MX_GPIO_Init() 放在我面前时,我能不能看出来按键脚是怎么初始化的?
  • 最简单的 4 行代码,我现在是“会抄”,还是已经知道它是在抓按下和松开的瞬间?
  • extern、调度器 、最小验证这几步,我会不会总漏掉一步?
  • 我现在是继续把最简单扫描版练熟,还是已经该上 ebtn 这种按键库?
  • ebtn 里面的 key_idkey_idx,我会不会还在混?
  • 我现在缺的是“会移植库”,还是缺“理解事件驱动 / 状态机 / 回调”这几个概念?

带着问题回来查时,可以这样找:

  • 想补输入模式和原理图判断:看“GPIO输入模式”
  • 想补 CubeMX 和初始化:看“cubemax配置”和“代码”
  • 想补最简单按键任务怎么搭:看“最简单的4行代码实现”
  • 想补 HAL_GPIO_ReadPin() 到底干了什么:直接看那一节
  • 想补为什么后面要上框架、框架怎么接:看“ebtn库”

GPIO输入模式

输入需要读,默认电平很重要。

上拉

是什么,有什么用,什么时候用。

先记一句话:上拉,就是这个输入脚平时没人碰它的时候,默认是高电平。

按键这里最常见的就是上拉。因为很多按键电路都是“按下接地”。这样平时高电平,按下变低电平,代码判断也顺手。

下拉

和上拉相反。

下拉就是让输入脚默认保持低电平,按下后再变成高电平。

一般使用上拉电阻。

这句不是说下拉不能用,而是按键这块大多数时候上拉更常见。你后面看到很多例程默认“低电平按下”,本质上就是因为它用的是上拉。

为什么可以通过电阻上下拉?爱上半导体 up 主有视频可以看一下。

这里先别钻太深。当前先吃透结论:上下拉电阻就是给输入脚一个稳定默认值,不然引脚容易悬空乱跳。

浮空

引脚高阻态,简单的数字输入极少用浮空模式,浮空很容易“受惊”。

这句很关键。浮空不是“高级”,而是没人管它,所以很容易受干扰。做普通按键时,一般不直接这样用。

小结

总结上面的三种模式:

模式 默认状态 按键里常见吗 你现在先怎么记
上拉 默认高电平 最常见 按下后常变低
下拉 默认低电平 也能用,但相对少 按下后常变高
浮空 默认不确定 一般不直接用 容易受干扰

如何通过看原理图判断使用上下拉电阻?

下方原理图是上拉电阻。

这里真正要记的不是“上拉电阻”四个字,而是顺序别反了。

不要先看例程怎么写,再去套自己的板子。

先看原理图。

先看三件事:

  • 按键一端接的是 GND 还是 VCC
  • 输入脚空闲时是被拉高还是拉低
  • 按下后电平怎么变化

如果按键按下后接地,空闲时又被电阻拉到高电平,那就是典型上拉输入,代码里通常就会写成“读到低电平表示按下”。

为什么还要接电容?

多问 AI:“在设计按键的时候为什么要设计一个电容”。

硬件消抖。

你这里先记住这几个点:

  • 电容不是装饰,是为了减小按键抖动
  • 有 RC 电路时,可以先做一层硬件消抖
  • 就算有硬件消抖,复杂按键功能后面通常还是要软件判断

为什么不去类比一下蓝桥杯?

蓝桥杯原理图就没有电容,所以要软件消抖。

这张图不是拿来“看个热闹”的。

它在说明按键外围怎么接,也是在提醒你:先看原理图,再决定软件里怎么判断,后面要不要消抖。

image-20260425163204079

尽量不要和例程的引脚保持一致,连最简单的原理图都不会看,是失败的。

这句说得重,但意思是对的。引脚一定跟自己的板子走,不要跟例程走。

cubemax配置

选取对应引脚配置 input

先把对应引脚配成输入模式。

image-20260425164840955

这张图在说明:第一步先别急着管别的,先把按键对应引脚改成输入。

配置 GPIO 上拉电阻。

再按原理图去配上拉。

image-20260425165025517

这张图在说明:输入模式定完以后,还要继续把内部上下拉选对,这里是上拉。

这里先别想复杂,先记住两件事:

  • 引脚模式要对
  • 上下拉要对

你后面换标准库或者自己写寄存器,变的是写法,不变的是这两个动作。

代码

配置完依旧是在 main.c 里看 MX_GPIO_Init();

CubeMX 点完不是结束,还是得回到代码里看它到底生成了什么。

image-20260425165339641

这张图在说明:最后还是要落回 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

所以这里如果不写 externkey_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_SETGPIO_PIN_RESET
  • 所以你写 == GPIO_PIN_RESET,本质上就是在判断当前是不是低电平

ebtn库

日常开发,不可能所有东西是自己写,也不是靠 AI 写,框架用现成的。

而且靠网上资源。

比如,问 AI:“请你在 GitHub 上帮我搜索几个嵌入式开发中比较完善的按键管理框架”。

ai 进行第一遍过滤。

readme 不是中文的一般不移植。

找到好用的框架,或者工作室推荐。

你现在这个阶段,优先用自己看得懂、例子完整、能改得动的库。

1 下载(梯子)

2 看 readme

3 新建文件夹,Components 组件库

image-20260425201217766

这张图在说明:库文件别乱塞,先在工程里给第三方组件单独留位置,后面才不容易越用越乱。

4 按照 APP 的思路添加以及配置

这个容易忘记,忘记了引用不了 ebtn.h

image-20260425210130112

这张图在说明:除了把源码加进工程,还得把头文件路径配进去,不然编译器根本找不到库。

5 好的框架是可以直接编译成功的

6 APP 里,创建组件库对应的应用层,比如 btn_app.c

image-20260425201346786

这张图在说明:库本体和你自己的应用层要分开,业务代码别直接糊进库里。

要改的代码,在 app 里注册。

和之前一样的代码实现思路。

先理解:这个库到底强在哪

README 里明确写

它自己强调的优势主要是这几个:

  • 支持组合按键
  • 支持批量扫描
  • 单个按键 RAM 占用比较小
  • 支持静态注册,也支持动态注册

README 里还把它和 FlexibleButtonMultiButtonlwbtn 做了对比,结论就是:功能很全,尤其适合按键多的场景,比如键盘、矩阵键盘这类。

放到你现在的理解里,可以先记成一句话:

这个库不是为了“帮你读一个键”,而是为了“把一整套按键事件管理起来”。

移植之前的概念

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不同)
};

这三步你先这样理解:

  1. 先定义“这批按键的行为规则”
  2. 再定义“有哪些普通按键”
  3. 最后如果要组合键,再单独定义组合键对象

这里最容易混的是参数宏那一坨。你现在先不用硬背顺序,先记每项大概在管什么:

  • 消抖
  • 单击时间范围
  • 多击间隔
  • 长按周期
  • 最大连击数

枚举,这里是区分度。

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_cntkeepalive_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 */

这一串代码你现在可以直接记成移植最小闭环:

  1. 库文件放进工程
  2. 头文件路径配好
  3. 定义参数和按键数组
  4. 写状态读取函数和事件回调函数
  5. 初始化
  6. 周期调 ebtn_process()

到底怎么移植

作者没写很详细怎么办?问 ai。

“请你阅读这个框架,把我当成小白一步步教我怎么移植使用在 STM32 HAL 库的应用场景中”。

这里把这段话落成真正的做法:

  • 先看 README,把调用链理出来
  • 再看 example_user.cexample_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 帮你拆调用链、拆结构、拆移植步骤,这本身也是你现在必须练的能力。