前言
本系列学习笔记是本人跟随米醋电子工作室学习嵌入式的学习笔记,自用为主,不是教学或经验分享,若有误,大佬轻喷,同时欢迎交流学习,侵权即删。
一、GPIO 引脚的三种:输入模式
在连接按键之前,我们必须了解单片机 GPIO (通用输入/输出) 引脚作为输入时,有三种基础的"性格设定":上拉 (Pull-up) 、下拉 (Pull-down) 和 浮空 (Floating)。它们决定了引脚在没有明确外部信号时(比如按键没按下)的默认电平状态,这对于稳定读取按键至关重要。
1.1. 上拉 (Pull-up) ⬆️ ------ 默认"开"
它是啥? 想象引脚内部有一根"橡皮筋"轻轻地把它往"高电平"(通常是电源 VCC)拉。当没有更强的力量(比如按键按下接地)作用时,它就保持在高电平。
为啥用? 防止引脚"悬空"乱飘。就像一个开关,默认是开着的,只有你按下时才关断。
何时用? 当你需要引脚在空闲时稳定地输出高电平,或者连接的按键按下时会把引脚拉到低电平 (GND)。这是最常见的按键连接方式之一。
1.2. 下拉 (Pull-down) ⬇️ ------ 默认"关"
它是啥? 和上拉相反,这次"橡皮筋"是把引脚往"低电平"(地线 GND)拉。默认状态是低电平。
为啥用? 同样是为了防止悬空,提供一个明确的默认状态。
何时用? 当你需要引脚空闲时稳定在低电平,或者连接的按键按下时会把引脚接到高电平 (VCC)。相对上拉来说,用得稍微少一些。
1.3. 浮空 (Floating / High-Impedance) 👻 ------ "随波逐流"
它是啥? 引脚内部既不上拉也不下拉,像个与世无争的"隐士"。它的电平完全由外部连接决定。如果什么都没接,它的状态就像空气中的羽毛,极其不稳定,容易受到各种电磁干扰。
为啥用? 有些特定场景需要引脚呈现高阻态,不影响外部电路(比如模拟信号输入,或者需要外部电路精确控制电平)。但在简单的数字输入,尤其是按键检测中,极少使用浮空模式,因为它太容易"受惊"了。
注意! 直接用浮空模式连接按键通常是不可靠的,你需要外部电路(比如外部上拉或下拉电阻)来明确默认电平。
✨ 小结
- ⬆️上拉: 默认高电平 (
VCC),按键通常接到GND。 - ⬇️下拉: 默认低电平 (
GND),按键通常接到VCC。 - 👻浮空: 无默认电平,易受干扰,一般不直接用于按键。
正确选择和配置输入模式是按键能够被可靠读取的前提。对于大多数简单的按键电路,使用单片机内部的上拉或下拉功能是最方便有效的方式。
二、常规按键驱动(无组件库)代码解析
2.1. 代码整体说明
该代码是嵌入式中最基础的按键驱动实现方式,无依赖第三方库,通过 "状态对比法" 检测按键按下事件,实现按键 1 控制 LED0 翻转功能。核心逻辑为 "读取硬件状态→对比历史状态→识别按键事件→执行业务逻辑",适合入门理解按键驱动的底层原理。
2.2. 完整代码逐行解析
cpp
#include "key_app.h"
// 全局变量:存储按键状态(核心状态变量)
uint8_t key_val, key_old, key_down, key_up;
/**
* @brief 读取按键硬件状态(底层GPIO操作)
* @return 按键编号(1-6对应6个按键,0表示无按键按下)
*/
uint8_t key_read()
{
uint8_t temp = 0; // 初始值0(无按键按下)
// 逐个读取GPIO引脚电平,判断按键是否按下
// 硬件逻辑:按键为上拉输入,按下时引脚为低电平(GPIO_PIN_RESET)
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_15) == GPIO_PIN_RESET ) temp = 1;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_13) == GPIO_PIN_RESET ) temp = 2;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_11) == GPIO_PIN_RESET ) temp = 3;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_9) == GPIO_PIN_RESET ) temp = 4;
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_7) == GPIO_PIN_RESET ) temp = 5;
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET ) temp = 6;
return temp;
}
/**
* @brief 按键任务函数(需周期性调用,如10ms/次)
* @note 核心逻辑:通过"当前状态-历史状态"对比识别按键事件
*/
void key_task()
{
// 1. 读取当前按键状态
key_val = key_read();
// 2. 计算按键按下/松开事件(位运算核心逻辑)
// key_down:仅当"当前按下"且"历史未按下"时为非0(按下事件)
key_down = key_val & (key_old ^ key_val);
// key_up:仅当"当前松开"且"历史按下"时为非0(松开事件)
key_up = ~key_val & (key_old ^ key_val);
// 3. 更新历史状态(为下一次对比做准备)
key_old = key_val;
// 4. 业务逻辑:按键1按下时,翻转LED0状态
if(key_down == 1)
{
ucLed[0] ^= 1; // ^=1 表示翻转(1→0,0→1)
}
}
2.3. 核心变量与逻辑说明
| 变量名 | 作用 |
|---|---|
key_val |
本次扫描读取的当前按键状态(1-6 表示对应按键按下,0 表示无按键) |
key_old |
上一次扫描的历史按键状态(用于与当前状态对比) |
key_down |
按键按下事件标志(仅在 "从无到有" 时为对应按键编号,其余时间为 0) |
key_up |
按键松开事件标志(仅在 "从有到无" 时为对应按键编号,其余时间为 0) |
关键位运算解析(事件识别核心)
key_old ^ key_val:异或运算,结果为 1 的位表示 "状态变化"(按下 / 松开);key_val & (key_old ^ key_val):仅保留 "状态变化且当前按下" 的位→识别按下事件;~key_val & (key_old ^ key_val):仅保留 "状态变化且当前松开" 的位→识别松开事件。
三、使用组件库的核心思想
在嵌入式开发中,外设管理(如按键、LED、传感器等)是常见需求。直接从零开发不仅耗时,还容易忽略边界场景(如按键消抖、多事件识别)。高效的开发方式是优先选用成熟的开源组件库:
- 成熟库经过多场景验证,稳定性更高,能减少重复造轮子的成本;
- 通用性强的库往往适配多种硬件环境,便于移植和维护;
- 可基于库的设计思想学习外设管理的核心逻辑(如状态机、事件驱动),提升自身设计能力。
本次学习的 easy_button(GitHub 地址)是一款针对嵌入式按键管理的轻量库,解决了传统按键驱动在组合按键、资源占用、多事件识别等方面的不足,非常适合多按键场景(如键盘、控制面板)。
3.1.基于 easy_button 库的按键驱动代码解析
整体框架说明
该代码基于easy_button库实现了 6 个按键的管理,支持单击 / 双击控制 LED、组合键(复制 / 粘贴 / 剪切)功能。核心逻辑分为参数配置 、硬件适配 、事件处理 、初始化 和周期性扫描 五部分,充分复用了easy_button库的事件驱动机制。
核心代码解析
1. 按键参数配置(defaul_ebtn_param)
cpp
const ebtn_btn_param_t defaul_ebtn_param = EBTN_PARAMS_INIT(
20, // 按下消抖20ms
20, // 释放消抖20ms
50, // 单击最短按下50ms
500, // 单击最长按下500ms
300, // 多击间隔300ms
500, // 长按周期500ms
5 // 最多支持5连击
);
- 作用:定义按键检测的时间规则,是
easy_button库识别事件的核心依据。 - 关键参数说明:
- 消抖时间(20ms):过滤按键机械抖动,确保状态稳定后才识别为有效状态。
- 单击时间范围(50~500ms):按下到释放的时间在此区间内,才视为有效单击。
- 多击间隔(300ms):两次点击的间隔小于 300ms,视为连击(如双击、三击)。
- 长按周期(500ms):长按状态下,每 500ms 触发一次
KEEPALIVE事件(本代码未使用)。
2. 按键 ID 与硬件映射(user_button_t)
cpp
typedef enum
{
USER_BUTTON_0 = 0, // 对应GPIOE.15
USER_BUTTON_1, // 对应GPIOE.13
USER_BUTTON_2, // 对应GPIOE.11
USER_BUTTON_3, // 对应GPIOE.9
USER_BUTTON_4, // 对应GPIOE.7
USER_BUTTON_5, // 对应GPIOB.0
USER_BUTTON_MAX, // 按键总数(6个)
} user_button_t;
- 作用:将 6 个物理按键(对应不同 GPIO)抽象为唯一 ID,便于软件管理。
USER_BUTTON_MAX用于标记按键总数,方便后续数组大小计算(如btns数组、ucLed数组)。
3. 按键数组初始化(btns)
cpp
static ebtn_btn_t btns[] =
{
EBTN_BUTTON_INIT(USER_BUTTON_0, &defaul_ebtn_param),
EBTN_BUTTON_INIT(USER_BUTTON_1, &defaul_ebtn_param),
// ... 其余4个按键初始化
};
- 作用:静态注册按键,将每个按键 ID 与参数配置关联,是
easy_button库管理按键的基础数据结构。 EBTN_BUTTON_INIT宏:初始化按键结构体(包含 key_id、参数指针等),库通过该数组遍历所有按键。
4. 硬件状态读取函数(prv_btn_get_state)
cpp
uint8_t prv_btn_get_state(struct ebtn_btn *btn)
{
switch (btn->key_id)
{
case USER_BUTTON_0: // 按键0 → GPIOE.15
return !HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_15);
// ... 其余按键的GPIO读取
default:
return 0;
}
}
- 作用:
easy_button库的核心回调函数,用于读取按键的实时硬件状态,是软件与硬件的桥梁。 - 逻辑说明:
- 假设按键硬件为上拉输入(未按下时引脚为高电平,按下时为低电平)。
HAL_GPIO_ReadPin返回GPIO_PIN_SET(高电平,未按下)或GPIO_PIN_RESET(低电平,按下)。- 取反(
!)后,返回1表示按键按下,0表示松开,符合库对 "活跃状态" 的定义。
5. 事件处理函数(prv_btn_event)
该函数是按键逻辑的核心,处理easy_button库上报的事件(ONCLICK、ONPRESS等)。
(1)单击 / 双击控制 LED(EBTN_EVT_ONCLICK事件)
cpp
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; // 单击:LED0亮
else if (click_cnt == 2) ucLed[0] = 0; // 双击:LED0灭
// ... 其余按键类似
}
}
- 逻辑:
ONCLICK事件在按键释放且符合单击时间规则时触发,通过ebtn_click_get_count获取连击次数,分别控制 LED 的亮灭。
(2)组合键逻辑(EBTN_EVT_ONPRESS事件)
cpp
if (evt == EBTN_EVT_ONPRESS)
{
if (prv_btn_get_state(&btns[USER_BUTTON_0]) == 1) // 按键0按下时
{
switch (btn->key_id)
{
case USER_BUTTON_1: // 0+1:复制LED状态到缓存
for (int i=0; i<USER_BUTTON_MAX; i++)
ucLed_copy[i] = ucLed[i];
break;
case USER_BUTTON_2: // 0+2:粘贴缓存到LED
for (int i=0; i<USER_BUTTON_MAX; i++)
ucLed[i] = ucLed_copy[i];
break;
case USER_BUTTON_3: // 0+3:剪切(复制后清零LED)
for (int i=0; i<USER_BUTTON_MAX; i++)
{
ucLed_copy[i] = ucLed[i];
ucLed[i] = 0;
}
break;
}
}
}
- 逻辑:
ONPRESS事件在按键按下且消抖完成后触发。当按键 0 处于按下状态时,检测其他按键的按下事件,实现复制、粘贴、剪切功能。 - 依赖:通过
prv_btn_get_state实时检查按键 0 的状态,确保组合键的有效性。
6. 初始化与周期性扫描
(1)初始化函数(app_ebtn_init)
cpp
void app_ebtn_init(void)
{
ebtn_init(btns, EBTN_ARRAY_SIZE(btns), NULL, 0, prv_btn_get_state, prv_btn_event);
}
- 作用:初始化
easy_button库,参数包括:- 静态按键数组(
btns)及数量; - 组合按键数组(此处为
NULL,未使用库自带的组合键功能,而是自定义实现); - 状态读取回调(
prv_btn_get_state)和事件回调(prv_btn_event)。
- 静态按键数组(
(2)周期性扫描任务(btn_task)
cpp
void btn_task(void)
{
ebtn_process(HAL_GetTick());
}
- 作用:调用库的核心处理函数
ebtn_process,传入系统时间(HAL_GetTick()获取 ms 级时间)。 - 机制:
ebtn_process会周期性扫描所有按键状态,与历史状态对比,根据参数配置判断并触发事件(如ONCLICK、ONPRESS),最终调用prv_btn_event处理。 - 建议:任务周期一般为 5~20ms(与消抖时间匹配),确保状态检测及时。
核心设计思想
- 硬件与软件解耦 :通过
prv_btn_get_state隔离硬件 GPIO 操作,库只关心 "1(按下)/0(松开)" 的逻辑状态,便于移植到不同硬件。 - 事件驱动 :基于库的事件机制(
ONCLICK、ONPRESS等),上层逻辑无需关心扫描细节,只需响应事件,简化代码。 - 灵活复用 :通过参数配置(
defaul_ebtn_param)调整按键特性,无需修改核心逻辑;组合键功能通过自定义判断实现,比库自带组合键更灵活。
ebtn 库使用总结
- 🧩定义参数与按键: 使用宏精心配置按键行为 (时间、ID)。
- 📞提供回调函数: 实现 `get_state_fn` (读硬件) 和 `evt_fn` (处理事件)。
- 🚀初始化: 调用 `ebtn_init` 注册按键和回调,并配置组合键。
- ⏱️周期处理: 必须 在定时器中断 (最佳) 或主循环中,以固定间隔 (如 5-20ms) 调用 `ebtn_process(HAL_GetTick())`。
- 🎉处理事件: 在 `evt_fn` 中根据 `key_id` 和 `evt` 类型编写简洁的事件响应代码,避免阻塞。
遵循这五个步骤,你就能充分利用 ebtn 库,告别繁琐的按键处理逻辑,专注于实现更有趣的应用功能!