3. HAL库架构与工程结构

按键输入,是嵌入式开发里最基础、也最容易出问题的环节。你想想看,一个机械按键按下去,信号不是干净利落的0和1,而是一连串的毛刺抖动。如果不处理,一次按键可能被识别成十几次。我刚开始做项目时就吃过这个亏,一个简单的菜单切换,按一下跳好几页,调试了半天才发现是没做消抖。

这一章,我们就来彻底搞定按键输入。从硬件抖动原理讲起,再到软件延时消抖、定时器扫描,最后是HAL库和LL库的输入读取,以及外部中断EXTI的配置。嗯,内容不少,但都是实战干货。

本章核心知识点:
  • 机械按键的抖动特性与消抖原理
  • 软件延时消抖与定时器扫描消抖的实现
  • HAL库与LL库的GPIO输入读取API
  • 外部中断EXTI的配置与中断服务函数编写
按键输入检测知识体系 按键输入检测 机械抖动问题 消抖处理方法 GPIO输入读取 按下抖动 释放抖动 软件延时 定时器扫描 HAL_GPIO_ReadPin / LL_GPIO_IsInputPinSet 外部中断 EXTI 配置 中断优先级 中断服务函数 中断标志清除 消抖是按键检测的前提 中断不能替代消抖

3.1 机械按键的抖动特性

机械按键的核心是一个金属弹片。按下时,弹片接触;释放时,弹片分离。但弹片不是瞬间稳定接触的,它会反弹几次,产生一连串的脉冲信号。这个过程就叫「抖动」。

抖动的持续时间一般在 5ms ~ 20ms 之间。具体多长,取决于按键的机械品质。我遇到过一些便宜的按键,抖动能到 30ms 以上,真是让人头疼。

抖动阶段持续时间信号特征
按下抖动5 ~ 15ms高电平中出现多个低电平脉冲
稳定闭合按键按下期间稳定的低电平(假设按下为低)
释放抖动5 ~ 15ms低电平中出现多个高电平脉冲
稳定断开按键释放后稳定的高电平

如果不做消抖,MCU会在抖动期间读到多次电平变化。一次按键可能触发多次动作。说白了,就是「乱跳」。

3.2 软件延时消抖

最直接的消抖方法,就是延时。检测到电平变化后,等一段时间再读一次。如果电平稳定了,就认为是有效按键。

我个人习惯用 20ms 的延时。这个值能覆盖绝大多数按键的抖动时间。当然,如果你用的按键品质特别好,10ms 也够。

经验之谈: 延时消抖虽然简单,但会阻塞CPU。如果在延时期间有其他任务需要处理,这个方法就不太合适了。我一般在裸机小项目或者按键数量很少的时候用。

下面是一个典型的延时消抖流程:

  1. 检测到按键电平变化(比如从高变低)
  2. 调用 HAL_Delay(20) 延时 20ms
  3. 再次读取按键电平
  4. 如果电平与第一次检测一致,确认按键按下
  5. 等待按键释放(电平恢复为高)

代码实现也很简单:

// 延时消抖示例
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
{
    HAL_Delay(20);  // 延时20ms
    if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
    {
        // 确认按键按下
        key_pressed = 1;
    }
}

嗯,这里要注意:HAL_Delay 依赖 SysTick 中断。如果你在中断服务函数里调用它,会出问题。中断里不要用延时消抖。

3.3 定时器扫描消抖

延时消抖阻塞CPU,那怎么办?用定时器。定时器每隔一段时间(比如 10ms)扫描一次按键状态。连续扫描几次,如果状态一致,就认为按键有效。

这个方法不阻塞CPU,适合多任务场景。我在一个工业控制面板项目里用过,效果很好。按键多,任务也多,定时器扫描是最稳妥的方案。

定时器扫描的核心逻辑:

  • 设置一个定时器,中断周期为 10ms
  • 每次中断里读取按键电平
  • 将当前电平存入一个移位寄存器或状态数组
  • 连续 N 次(比如 3 次)读到相同电平,才确认状态变化

代码实现:

// 定时器中断服务函数(10ms周期)
void TIM_IRQHandler(void)
{
    static uint8_t key_filter_cnt = 0;
    static GPIO_PinState last_state = GPIO_PIN_SET;
    GPIO_PinState current_state;

    current_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);

    if (current_state == last_state)
    {
        key_filter_cnt++;
        if (key_filter_cnt >= 3)  // 连续3次一致,确认状态
        {
            if (current_state == GPIO_PIN_RESET)
            {
                key_pressed = 1;
            }
            key_filter_cnt = 0;
        }
    }
    else
    {
        key_filter_cnt = 0;  // 状态不一致,重新计数
    }

    last_state = current_state;
}
注意: 定时器扫描的周期和连续次数需要根据实际按键调整。周期太短,消抖效果差;周期太长,按键响应慢。我一般用 10ms 周期,连续 3 次确认,总共 30ms 的消抖时间,效果比较均衡。

3.4 HAL库与LL库的GPIO输入读取

STM32的HAL库和LL库都提供了GPIO输入读取的API。两者风格不同,但功能一样。

库类型API函数返回值特点
HAL库HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)GPIO_PinState (GPIO_PIN_RESET / GPIO_PIN_SET)参数检查完善,代码可读性好
LL库LL_GPIO_IsInputPinSet(GPIO_TypeDef* GPIOx, uint32_t PinMask)uint32_t (0 / 1)直接操作寄存器,效率高

HAL库的 ReadPin 函数内部做了参数有效性检查,返回的是枚举类型。LL库的 IsInputPinSet 直接读取 IDR 寄存器,返回 0 或 1。我个人在性能敏感的场景下会用 LL 库,比如高速采样。普通按键读取,HAL 库完全够用。

使用示例:

// HAL库读取
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
{
    // 按键按下
}

// LL库读取
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0)
{
    // 按键按下
}
小技巧: 如果你在代码里混用 HAL 和 LL 库,注意引脚宏定义。HAL 库用 GPIO_PIN_0,LL 库用 LL_GPIO_PIN_0。两者数值一样,但宏名不同,别搞混了。

3.5 外部中断EXTI配置

按键检测除了轮询,还可以用外部中断。按键按下时触发中断,CPU立即响应。这样不用一直扫描,省电又高效。

配置外部中断的步骤:

  1. 使能GPIO时钟和SYSCFG时钟
  2. 配置GPIO为输入模式,并选择上下拉
  3. 将GPIO连接到EXTI线(通过SYSCFG)
  4. 配置EXTI触发方式(上升沿、下降沿或双边沿)
  5. 使能EXTI中断(在NVIC中)
  6. 编写中断服务函数

用HAL库配置,其实很简单。CubemX里勾选一下,代码就生成了。但手动配置更能理解原理。我带你走一遍手动配置的流程。

首先,使能时钟:

__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();

然后,配置GPIO和EXTI:

GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

这里 Mode 参数很关键。常用的有:

模式宏触发方式适用场景
GPIO_MODE_IT_RISING上升沿触发按键释放检测
GPIO_MODE_IT_FALLING下降沿触发按键按下检测
GPIO_MODE_IT_RISING_FALLING双边沿触发按下和释放都检测

最后,配置NVIC并编写中断服务函数:

// 在main函数中使能中断
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

// 中断服务函数
void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

// HAL库的中断回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_0)
    {
        // 按键中断触发
        // 注意:这里仍然需要消抖!
    }
}
避坑指南: 我曾经在一个项目里,只配置了外部中断,没做消抖。结果按键按下时,中断连续触发了十几次。后来在中断里加了软件消抖,才解决问题。记住,外部中断不能替代消抖!它只是告诉你有电平变化,但变化是不是有效的按键动作,还得靠消抖来判断。

中断里的消抖,我一般用定时器辅助。在中断里启动一个定时器,延时 20ms 后再读取按键状态。这样既响应快,又不会阻塞主循环。

3.6 总结

按键输入检测,核心就三件事:消抖、读取、中断。消抖是基础,读取是手段,中断是优化。三者结合,才能做出稳定可靠的按键交互。

我个人建议,小项目用延时消抖 + HAL库读取,简单直接。大项目或者多任务场景,用定时器扫描消抖 + LL库读取,效率更高。外部中断适合需要快速响应的场景,但别忘了配合消抖。