3. STM32硬件基础
按键输入,是嵌入式开发里最基础也最容易被轻视的一环。很多新手上来就写个 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图,把本章的核心知识点串在了一起。你可以把它当作一个快速复习的思维导图:
3.10 避坑指南
最后,分享几个我在实际项目中踩过的坑:
- GPIO内部上拉/下拉没配置——按键引脚浮空,读到的状态随机跳变。我曾经因为这个排查了整整一个下午,最后发现是忘了开内部上拉。
- 消抖延时太短——5ms以下的延时基本没用,按键该抖还是抖。我建议至少10ms,保险一点20ms。
- 中断里用HAL_Delay——这个前面提过,SysTick中断优先级如果比当前中断低,HAL_Delay会死等。要么改用LL库的LL_mDelay(它也是阻塞的),要么用状态机。
- 没做释放检测——按下一次,LED翻转好几次。原因就是手指松开时的抖动又被当成了一次新的按下。
嗯,这一章的内容就到这里。按键消抖看起来简单,但它是嵌入式开发里「细节决定成败」的典型例子。把这一章吃透,后面学中断、定时器、外部事件检测都会轻松很多。