3. GPIO输入检测:按键消抖原理、HAL与LL库的读取函数,实现按键控制LED

按键输入,是嵌入式开发里最基础也最容易被轻视的一环。很多新手上来就写个 GPIO_ReadPin(),结果发现灯乱闪、状态跳变,然后开始怀疑芯片坏了。其实不是芯片的问题,是你没搞定「抖动」这个物理现象。

这一章,我们就来把按键消抖这件事彻底讲透。我会从原理讲到代码,从HAL库讲到LL库,最后带你亲手实现一个按键控制LED的完整例程。

3.1 按键为什么会抖动?

你按下按键的瞬间,金属触点并不是「啪」一下就稳定闭合的。它们会像乒乓球一样弹跳几次,每次持续几十微秒到几毫秒。这个弹跳过程,就是抖动。

抖动的典型特征:

  • 按下时:产生 5~20ms 的不稳定电平跳变
  • 释放时:同样会产生 5~20ms 的抖动
  • 抖动频率:通常在 100Hz ~ 1kHz 之间

如果不做消抖,MCU 会在几毫秒内读到几十次电平变化。你本来只想让 LED 亮一次,结果它闪了十几次。我在项目里遇到过最夸张的一次,是某个廉价按键抖动长达 30ms,差点把产品送去做EMC整改——后来发现只是消抖没做好。

3.2 消抖的三种主流方案

消抖的思路其实很简单:等抖动过去,再读状态。具体实现方式有三种:

方案 原理 适用场景 缺点
硬件消抖 RC低通滤波 + 施密特触发器 对实时性要求高的工业设备 增加BOM成本,PCB面积
软件延时消抖 检测到变化后,延时10~20ms再读 简单原型、教学实验 阻塞CPU,不适合多任务
软件状态机消抖 定时采样,连续N次一致才确认 产品级代码、RTOS环境 代码稍复杂,但最可靠

我个人习惯在产品里用状态机消抖。它不阻塞CPU,而且能同时处理短按、长按、双击。不过这一章我们先从最直观的延时消抖讲起,让你先理解核心逻辑。

3.3 HAL库的GPIO读取函数

HAL库把GPIO读取封装得很简单,就一个函数:

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

返回值只有两个:GPIO_PIN_SET(高电平)或 GPIO_PIN_RESET(低电平)。

用法示例:

if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET)
{
    // 按键被按下(假设低电平有效)
}

小技巧:HAL_GPIO_ReadPin 内部其实调用了 LL 库的 LL_GPIO_IsInputPinSet()。所以性能上两者没有本质区别,只是 HAL 多了一层类型检查。

3.4 LL库的GPIO读取函数

LL库更贴近寄存器,读取函数长这样:

uint32_t LL_GPIO_IsInputPinSet(GPIO_TypeDef* GPIOx, uint32_t PinMask);

注意第二个参数是 PinMask,不是引脚编号。比如你要读 PB0,传的是 LL_GPIO_PIN_0。返回值是 0 或 1。

用法示例:

if(LL_GPIO_IsInputPinSet(GPIOB, LL_GPIO_PIN_0) == 0)
{
    // 按键被按下(低电平有效)
}

LL库还有一个批量读取的函数:

uint32_t LL_GPIO_ReadInputPort(GPIO_TypeDef* GPIOx);

这个函数一次读回整个端口16个引脚的状态。如果你要同时检测多个按键,用这个效率更高。

3.5 延时消抖的完整实现

先看一个最简单的延时消抖代码。我习惯把它封装成一个函数:

/**
  * @brief  按键消抖读取(延时消抖法)
  * @param  GPIOx: GPIO端口
  * @param  GPIO_Pin: 引脚号
  * @param  ActiveLevel: 有效电平(0=低电平有效,1=高电平有效)
  * @retval 0: 未按下, 1: 按下
  */
uint8_t Key_Read_Debounce(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, uint8_t ActiveLevel)
{
    uint8_t pinState;
    
    // 第一次读取
    pinState = HAL_GPIO_ReadPin(GPIOx, GPIO_Pin);
    
    // 如果当前状态与有效电平一致,说明可能按下了
    if(pinState == ActiveLevel)
    {
        // 延时20ms,跳过抖动期
        HAL_Delay(20);
        
        // 第二次读取,确认状态
        pinState = HAL_GPIO_ReadPin(GPIOx, GPIO_Pin);
        
        if(pinState == ActiveLevel)
        {
            return 1;  // 确认按下
        }
    }
    
    return 0;  // 未按下或抖动干扰
}

注意:HAL_Delay() 依赖于 SysTick 中断。如果你在中断服务函数里调用这个消抖函数,HAL_Delay 会卡死。中断里做消抖,请改用状态机或定时器轮询。

3.6 状态机消抖(产品级方案)

延时消抖虽然简单,但阻塞CPU。在真正的产品里,我更喜欢用状态机。它的核心思想是:每隔10ms采样一次,连续3次采样值一致,才认为状态稳定。

状态机定义:

typedef enum
{
    KEY_IDLE,      // 空闲状态
    KEY_DEBOUNCE,  // 消抖中
    KEY_PRESSED,   // 已确认按下
    KEY_RELEASE    // 等待释放
} KeyState_t;

typedef struct
{
    GPIO_TypeDef* port;
    uint16_t      pin;
    uint8_t       activeLevel;
    KeyState_t    state;
    uint8_t       stableCount;
    uint8_t       lastRawState;
} Key_t;

状态机处理函数:

void Key_Process(Key_t* key)
{
    uint8_t raw = HAL_GPIO_ReadPin(key->port, key->pin);
    
    switch(key->state)
    {
        case KEY_IDLE:
            if(raw == key->activeLevel)
            {
                key->state = KEY_DEBOUNCE;
                key->stableCount = 0;
            }
            break;
            
        case KEY_DEBOUNCE:
            if(raw == key->activeLevel)
            {
                key->stableCount++;
                if(key->stableCount >= 3)  // 连续3次一致
                {
                    key->state = KEY_PRESSED;
                    // 这里可以触发按键回调
                    Key_EventCallback(KEY_EVENT_PRESSED);
                }
            }
            else
            {
                key->stableCount = 0;  // 出现抖动,重新计数
            }
            break;
            
        case KEY_PRESSED:
            if(raw != key->activeLevel)
            {
                key->state = KEY_RELEASE;
                key->stableCount = 0;
            }
            break;
            
        case KEY_RELEASE:
            if(raw != key->activeLevel)
            {
                key->stableCount++;
                if(key->stableCount >= 3)
                {
                    key->state = KEY_IDLE;
                    Key_EventCallback(KEY_EVENT_RELEASED);
                }
            }
            else
            {
                key->stableCount = 0;
            }
            break;
    }
    
    key->lastRawState = raw;
}

这个状态机需要在定时器中断里每10ms调用一次 Key_Process()。它不阻塞、不等待,非常适合用在RTOS或者前后台系统中。

3.7 按键控制LED的完整例程

下面是一个完整的按键控制LED翻转的例程。我用的是LL库,因为它的代码更精简,适合做底层教学。

// main.c
#include "stm32f1xx_ll_gpio.h"
#include "stm32f1xx_ll_bus.h"
#include "stm32f1xx_ll_utils.h"

// 按键和LED的引脚定义
#define KEY_PORT    GPIOB
#define KEY_PIN     LL_GPIO_PIN_0
#define LED_PORT    GPIOB
#define LED_PIN     LL_GPIO_PIN_1

// 按键有效电平(假设按下为低电平)
#define KEY_ACTIVE_LEVEL  0

void GPIO_Init(void)
{
    // 使能GPIOB时钟
    LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_GPIOB);
    
    // 配置PB0为输入(按键)
    LL_GPIO_SetPinMode(KEY_PORT, KEY_PIN, LL_GPIO_MODE_INPUT);
    LL_GPIO_SetPinPull(KEY_PORT, KEY_PIN, LL_GPIO_PULL_UP);  // 内部上拉
    
    // 配置PB1为推挽输出(LED)
    LL_GPIO_SetPinMode(LED_PORT, LED_PIN, LL_GPIO_MODE_OUTPUT);
    LL_GPIO_SetPinSpeed(LED_PORT, LED_PIN, LL_GPIO_SPEED_FREQ_LOW);
    LL_GPIO_SetPinOutputType(LED_PORT, LED_PIN, LL_GPIO_OUTPUT_PUSHPULL);
}

uint8_t Key_Read_LL(void)
{
    // LL库读取按键状态
    if(LL_GPIO_IsInputPinSet(KEY_PORT, KEY_PIN) == 0)
    {
        LL_mDelay(20);  // 延时消抖
        if(LL_GPIO_IsInputPinSet(KEY_PORT, KEY_PIN) == 0)
        {
            return 1;  // 确认按下
        }
    }
    return 0;
}

int main(void)
{
    GPIO_Init();
    
    while(1)
    {
        if(Key_Read_LL())
        {
            // 翻转LED状态
            LL_GPIO_TogglePin(LED_PORT, LED_PIN);
            
            // 等待按键释放,防止一次按下触发多次
            while(LL_GPIO_IsInputPinSet(KEY_PORT, KEY_PIN) == 0);
        }
    }
}

关于等待释放:上面代码里 while(LL_GPIO_IsInputPinSet(...) == 0) 这个循环是必要的。如果不加,你按一次按键,LED可能会翻转好几次——因为手指松开时也有抖动。当然,这个等待也是阻塞的。产品级代码里,我会用状态机来管理释放检测。

3.8 HAL库 vs LL库:读取性能对比

我经常被问到:到底用HAL还是LL?我的回答是:看场景。下面这张表可以帮你快速决策:

对比项 HAL_GPIO_ReadPin LL_GPIO_IsInputPinSet
代码行数 约20行(含参数校验) 1行(直接读IDR寄存器)
执行时间(72MHz) 约0.5μs 约0.05μs
可读性 高,类型安全 中等,需要理解位操作
适合场景 原型开发、快速验证 高性能、资源受限、底层驱动

说实话,对于按键读取这种毫秒级操作,两者的性能差异完全可以忽略。我更看重的是代码的可维护性。如果你团队里都是新手,用HAL;如果你在做Bootloader或者对时序要求苛刻的驱动,用LL。

3.9 本章知识体系

下面这张SVG图,把本章的核心知识点串在了一起。你可以把它当作一个快速复习的思维导图:

按键输入检测知识体系 按键输入检测知识体系 GPIO输入检测 抖动原理 消抖方案 读取函数 按下抖动 释放抖动 硬件消抖 延时消抖 状态机消抖 HAL_ReadPin LL_IsSet 应用:按键控制LED翻转 GPIO初始化 按键读取+消抖 LED状态翻转 等待按键释放 核心原则:先确认状态稳定,再执行动作

3.10 避坑指南

最后,分享几个我在实际项目中踩过的坑:

  • GPIO内部上拉/下拉没配置——按键引脚浮空,读到的状态随机跳变。我曾经因为这个排查了整整一个下午,最后发现是忘了开内部上拉。
  • 消抖延时太短——5ms以下的延时基本没用,按键该抖还是抖。我建议至少10ms,保险一点20ms。
  • 中断里用HAL_Delay——这个前面提过,SysTick中断优先级如果比当前中断低,HAL_Delay会死等。要么改用LL库的LL_mDelay(它也是阻塞的),要么用状态机。
  • 没做释放检测——按下一次,LED翻转好几次。原因就是手指松开时的抖动又被当成了一次新的按下。

嗯,这一章的内容就到这里。按键消抖看起来简单,但它是嵌入式开发里「细节决定成败」的典型例子。把这一章吃透,后面学中断、定时器、外部事件检测都会轻松很多。