西门子笔记(1)按键篇

按键驱动开发指南 (基于 STM32CubeMX)

本文档旨在说明如何使用 STM32CubeMX 工具进行基础的按键(Key/Button)硬件配置,并提供一套基础及一套高级的软件驱动方案。

一、 CubeMX 硬件配置

1. 设置引脚为输入模式

  • Pinout & Configuration 视图中,找到并选择您计划用作按键的 GPIO 引脚。
  • 点击该引脚,将其功能设置为 GPIO_Input

2. 配置上拉模式

  • 选中该引脚后,在下方的 “GPIO Mode and Configuration” 面板中,将引脚模式设置为 上拉 (Pull-up)
  • 重要说明:
    • 为何选择上拉? 配置为上拉后,当按键未按下时,引脚稳定在高电平。当按键按下(通常是接地)时,引脚电平被拉低至低电平。这可以有效防止因引脚悬空而导致的程序误判。
    • 内部与外部上拉: 即使硬件已设计外部上拉电阻,也强烈建议开启 MCU 的内部上拉。这可以作为冗余保护,确保在外部硬件异常时,按键功能依然稳定。

3. 后续步骤:生成代码与驱动编写

  • 完成上述配置后,即可生成项目代码。
  • 后续需要在代码中编写驱动逻辑以实现按键检测。

二、 按键驱动示例代码 (基础方案)

此方案适用于简单的单按键检测场景,逻辑简单,资源占用极低。

1. 变量定义

// 假设已在全局范围内定义以下变量
uint8_t Key_Val;    // 用于存储 Key_Read() 函数返回的当前按键值
uint8_t Key_Down;   // 用于标记哪个按键被按下 (产生下降沿)
uint8_t Key_Up;     // 用于标记哪个按键被抬起 (产生上升沿)
uint8_t Key_Old;    // 用于存储上一次的按键值,用于与当前值比较

2. 按键扫描与状态处理函数

/**
  * @brief  读取当前按下的按键值
  * @note   此函数为原始实现,同一时间只能返回一个按键值。
  *         如果多个按键同时按下,将返回最后一个被检测到的按键值。
  * @retval uint8_t 返回按键编号 (1-6),无按键按下则返回 0。
  */
uint8_t Key_Read()
{
    uint8_t temp = 0; // 初始化临时变量为0,表示无按键
  
    // 依次检测每个按键,如果按下,则将对应的键值赋给temp
    if(HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET) temp = 1; // KEY1
    if(HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET) temp = 2; // KEY2
    if(HAL_GPIO_ReadPin(KEY3_GPIO_Port, KEY3_Pin) == GPIO_PIN_RESET) temp = 3; // KEY3
    if(HAL_GPIO_ReadPin(KEY4_GPIO_Port, KEY4_Pin) == GPIO_PIN_RESET) temp = 4; // KEY4
    if(HAL_GPIO_ReadPin(KEY5_GPIO_Port, KEY5_Pin) == GPIO_PIN_RESET) temp = 5; // KEY5
    if(HAL_GPIO_ReadPin(KEY6_GPIO_Port, KEY6_Pin) == GPIO_PIN_RESET) temp = 6; // KEY6
  
    return temp; // 返回最终检测到的按键值
}

/**
  * @brief  核心按键处理任务,计算按键的按下和抬起事件
  * @note   此函数需要被周期性调用 (例如在您自己的任务调度器中)
  */
void key_task()
{
    // 步骤1: 读取当前按键的原始值
    Key_Val = Key_Read();

    // 步骤2: 计算“按下”事件 (下降沿检测)
    Key_Down = Key_Val & (Key_Old ^ Key_Val);

    // 步骤3: 计算“抬起”事件 (上升沿检测)
    Key_Up = ~Key_Val & (Key_Old ^ Key_Val);

    // 步骤4: 更新旧值,为下一次循环做准备
    Key_Old = Key_Val;
}

3. 代码逻辑解读

  • Key_Read() 函数:

    • 该函数通过逐个检查按键引脚来确定哪个按键被按下。
    • 它的实现非常简单,但存在一个特性:由于连续使用赋值 = 操作,如果多个按键同时按下,函数只会返回最后一个被检测到的按键编号。
  • key_task() 函数:

    • 这是非常经典的“四行代码”按键状态机,其核心思想是通过比较本次和上次的按键状态差异,来精确捕捉事件。
    • 它能将持续的电平状态(按键被一直按着)转换为瞬时的事件触发(“刚刚被按下的那一刻”和“刚刚被松开的那一刻”)。
  • 重要逻辑说明:

    • key_task() 中的位运算逻辑是为位掩码 (bitmask) 处理而设计的。然而,当前的 Key_Read() 函数返回的是按键编号
    • 这种组合导致该方案仅在单按键操作时可靠。如果存在组合按键或按键切换,key_task 可能会产生错误的 Key_Up(抬起)事件。

三、 高级按键驱动方案 (基于 easy_button 库)

为了实现更复杂的按键功能,如单击、双击、长按等,同时保持代码的整洁和可移植性,可以采用功能强大的开源按键库 easy_button

:light_bulb: 跨平台移植核心要点

easy_button 库本身是平台无关的,移植到其他芯片(如 ESP32, TI 等)非常简单,只需修改与硬件相关的部分即可:

  • :white_check_mark: GPIO读取函数: 在 my_get_key_state() 中,将 HAL_GPIO_ReadPin() 替换为您目标平台的 GPIO 读取 API。
  • :white_check_mark: 时间获取函数: 在 btn_task() 中,将 HAL_GetTick() 替换为您目标平台的毫秒级计时 API。
  • :white_check_mark: 引脚定义: 根据新硬件,修改 KEY1_GPIO_Port, KEY1_Pin 等相关宏定义。
  • :white_check_mark: 库本身无需修改: 只需正确实现上述硬件抽象层,ebtn 核心代码无需任何改动。

下面是 ebtn 库的使用代码示例,已按照五步法拆分展示。

:one: 第一步:包含头文件

在你的 C 文件顶部,包含 ebtn 库的头文件。

#include "ebtn.h"

:two: 第二步:定义参数与按键列表

这是配置按键行为的核心步骤。这个优化版本使用枚举(enum)来定义按键 ID,极大地增强了代码的可读性和可维护性。

/* 1. 定义按键参数实例 */
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
    5       // max_consecutive: 最多支持 5 连击
);

/* 前置:为每个按键注册ID,便于直接阅读按键作用 */
typedef enum
{
	KEY1 = 1, //注册单个按键ID
    KEY2,
    KEY3,
    KEY4,
    KEY5,
    KEY6,
    KEY_MAX,

    COM_KEY1 = 101, //注册组合按键ID
    COM_KEY2 = 102,

}user_button_t;

/* 2. 定义静态按键列表 */
ebtn_btn_t my_buttons[] = {
    EBTN_BUTTON_INIT(KEY1, &key_param_normal), // KEY1, ID=1
    EBTN_BUTTON_INIT(KEY2, &key_param_normal), // KEY2, ID=2
	EBTN_BUTTON_INIT(KEY3, &key_param_normal), // KEY3, ID=3
	EBTN_BUTTON_INIT(KEY4, &key_param_normal), // KEY4, ID=4
	EBTN_BUTTON_INIT(KEY5, &key_param_normal), // KEY5, ID=5
	EBTN_BUTTON_INIT(KEY6, &key_param_normal), // KEY6, ID=6
};

/* 3. 定义静态组合按键列表 (可选) */
ebtn_btn_combo_t my_combos[] = {
    EBTN_BUTTON_COMBO_INIT(COM_KEY1, &key_param_normal), // 组合键, ID=101
	EBTN_BUTTON_COMBO_INIT(COM_KEY2, &key_param_normal), // 组合键, ID=102
};

:three: 第三步:编写回调函数

你需要提供两个函数给 ebtn 库:一个用于读取硬件状态,一个用于处理库检测到的事件。

/* 1. 实现获取按键状态的回调函数 */
uint8_t my_get_key_state(struct ebtn_btn *btn) {
    // 根据传入的按钮实例中的 key_id 判断是哪个物理按键
    switch (btn->key_id) {
        case KEY1:
            // 假设按下为低电平 (返回 1 代表按下)
            return (HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET);
        case KEY2:
            return (HAL_GPIO_ReadPin(KEY2_GPIO_Port, KEY2_Pin) == GPIO_PIN_RESET);
        case KEY3:
			return (HAL_GPIO_ReadPin(KEY3_GPIO_Port, KEY3_Pin) == GPIO_PIN_RESET);
		case KEY4:
			return (HAL_GPIO_ReadPin(KEY4_GPIO_Port, KEY4_Pin) == GPIO_PIN_RESET);
		case KEY5:
			return (HAL_GPIO_ReadPin(KEY5_GPIO_Port, KEY5_Pin) == GPIO_PIN_RESET);
		case KEY6:
			return (HAL_GPIO_ReadPin(KEY6_GPIO_Port, KEY6_Pin) == GPIO_PIN_RESET);
        default:
            // 对于未知的 key_id,安全起见返回 0 (未按下)
            return 0;
    }
}

/* 2. 实现处理按键事件的回调函数 */
void my_handle_key_event(struct ebtn_btn *btn, ebtn_evt_t evt) {
    uint16_t key_id = btn->key_id;                 // 获取触发事件的按键 ID
    uint16_t click_cnt = ebtn_click_get_count(btn); // 获取连击次数 (仅在 ONCLICK 事件时有意义)

    // 根据事件类型进行处理
    switch (evt) {
        case EBTN_EVT_ONPRESS: // 按下事件
            if (key_id == KEY1) { /* Do something for KEY1 press */ }
            break;

        case EBTN_EVT_ONRELEASE: // 释放事件
             if (key_id == KEY1) { /* Do something for KEY1 release */ }
            break;

        case EBTN_EVT_ONCLICK: // 单击/连击事件
            if (key_id == KEY1) {
                if (click_cnt == 1) { /* KEY1 单击 */ } 
                else if (click_cnt == 2) { /* KEY1 双击 */ }
            } else if (key_id == KEY2) {
                 if (click_cnt == 1) { /* KEY2 单击 */ }
			} else if (key_id == COM_KEY1) {
                if (click_cnt == 1) { /* 组合键1 单击 */ }
			}
            break;

        case EBTN_EVT_KEEPALIVE: // 长按事件
            if (key_id == KEY1) { /* KEY1 长按 */ }
            break;

        default:
            break;
    }
}

:four: 第四步:初始化 ebtn 库

在系统启动的初始化阶段,调用一个封装好的初始化函数,将之前准备好的按键列表和回调函数"注册"给 ebtn 库,并绑定组合键。

void my_ebtn_init(void)
{
	/* 绑定按键列表、回调函数 */
	ebtn_init(my_buttons, EBTN_ARRAY_SIZE(my_buttons), 
              my_combos, EBTN_ARRAY_SIZE(my_combos), 
              my_get_key_state, my_handle_key_event);

    /* 配置组合键 (如果使用了) */
    // 1. 找到参与组合的普通按键的内部索引 (Index)
    int key1_index = ebtn_get_btn_index_by_key_id(KEY1);
    int key2_index = ebtn_get_btn_index_by_key_id(KEY2);

    // 2. 将这些索引对应的按键添加到组合键定义中
    if (key1_index >= 0 && key2_index >= 0) {
        // 假设 my_combos[0] 是 COM_KEY1 (KEY1+KEY2)
        ebtn_combo_btn_add_btn_by_idx(&my_combos[0], key1_index); // 将 KEY1 添加到组合键
        ebtn_combo_btn_add_btn_by_idx(&my_combos[0], key2_index); // 将 KEY2 添加到组合键
    }
}

:five: 第五步:周期性调用处理函数

这是让 ebtn 库运转起来的最后一步。你需要在一个固定时间间隔(推荐 5ms 到 20ms)调用的地方,不断地调用 ebtn 的核心处理函数。

/* 封装一个周期任务函数 */
void btn_task(void)
{
	ebtn_process(HAL_GetTick());
}

如何调用 btn_task()

  • 推荐方式 (定时器中断): 在一个周期为 5-20ms 的定时器中断服务程序中调用 btn_task()

    // 例如,在 HAL 库的定时器周期溢出回调中
    void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
        if (htim->Instance == TIMx) { // TIMx 是你的定时器实例
            btn_task(); // 调用按键处理任务
        }
    }
    
  • 简单方式 (主循环): 在 main 函数的 while(1) 循环中直接调用 btn_task(),但要确保主循环没有长时间阻塞。

    int main(void) {
        // ... 初始化 ...
        my_ebtn_init();
    
        while (1) {
            btn_task();
            // ... 其他主循环任务 ...
        }
    }