3、GPIO输入检测:按键消抖处理与外部中断
按键输入,是嵌入式开发里最基础、也最容易出问题的环节。你想想看,一个机械按键按下去,信号不是干净利落的0和1,而是一连串的毛刺抖动。如果不处理,一次按键可能被识别成十几次。我刚开始做项目时就吃过这个亏,一个简单的菜单切换,按一下跳好几页,调试了半天才发现是没做消抖。
这一章,我们就来彻底搞定按键输入。从硬件抖动原理讲起,再到软件延时消抖、定时器扫描,最后是HAL库和LL库的输入读取,以及外部中断EXTI的配置。嗯,内容不少,但都是实战干货。
- 机械按键的抖动特性与消抖原理
- 软件延时消抖与定时器扫描消抖的实现
- HAL库与LL库的GPIO输入读取API
- 外部中断EXTI的配置与中断服务函数编写
3.1 机械按键的抖动特性
机械按键的核心是一个金属弹片。按下时,弹片接触;释放时,弹片分离。但弹片不是瞬间稳定接触的,它会反弹几次,产生一连串的脉冲信号。这个过程就叫「抖动」。
抖动的持续时间一般在 5ms ~ 20ms 之间。具体多长,取决于按键的机械品质。我遇到过一些便宜的按键,抖动能到 30ms 以上,真是让人头疼。
| 抖动阶段 | 持续时间 | 信号特征 |
|---|---|---|
| 按下抖动 | 5 ~ 15ms | 高电平中出现多个低电平脉冲 |
| 稳定闭合 | 按键按下期间 | 稳定的低电平(假设按下为低) |
| 释放抖动 | 5 ~ 15ms | 低电平中出现多个高电平脉冲 |
| 稳定断开 | 按键释放后 | 稳定的高电平 |
如果不做消抖,MCU会在抖动期间读到多次电平变化。一次按键可能触发多次动作。说白了,就是「乱跳」。
3.2 软件延时消抖
最直接的消抖方法,就是延时。检测到电平变化后,等一段时间再读一次。如果电平稳定了,就认为是有效按键。
我个人习惯用 20ms 的延时。这个值能覆盖绝大多数按键的抖动时间。当然,如果你用的按键品质特别好,10ms 也够。
下面是一个典型的延时消抖流程:
- 检测到按键电平变化(比如从高变低)
- 调用 HAL_Delay(20) 延时 20ms
- 再次读取按键电平
- 如果电平与第一次检测一致,确认按键按下
- 等待按键释放(电平恢复为高)
代码实现也很简单:
// 延时消抖示例
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;
}
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)
{
// 按键按下
}
3.5 外部中断EXTI配置
按键检测除了轮询,还可以用外部中断。按键按下时触发中断,CPU立即响应。这样不用一直扫描,省电又高效。
配置外部中断的步骤:
- 使能GPIO时钟和SYSCFG时钟
- 配置GPIO为输入模式,并选择上下拉
- 将GPIO连接到EXTI线(通过SYSCFG)
- 配置EXTI触发方式(上升沿、下降沿或双边沿)
- 使能EXTI中断(在NVIC中)
- 编写中断服务函数
用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库读取,效率更高。外部中断适合需要快速响应的场景,但别忘了配合消抖。