3. GPIO 与按键控制:GPIO 模式配置、按键消抖处理(硬件+软件)、外部中断实现唤醒
好,咱们进入第三章。这一章聊的是嵌入式开发里最基础、但也最容易翻车的内容——GPIO 与按键控制。
你可能会想,按键嘛,不就是读个电平高低?其实没那么简单。我在做共享单车锁控项目时,就遇到过因为按键抖动导致误触发开锁的严重 bug。嗯,今天咱们就把这块彻底讲透。
3.1 GPIO 模式配置:别小看这个初始化
GPIO 是芯片和外界打交道的「手脚」。但手脚怎么摆,是有讲究的。
我个人习惯,在写任何外设驱动前,先把 GPIO 的寄存器手册翻一遍。别嫌麻烦,很多坑就藏在模式配置里。
常见的 GPIO 模式有这些:
| 模式 | 说明 | 典型用途 |
|---|---|---|
| 推挽输出 | 能输出高电平和低电平,驱动能力强 | LED 控制、蜂鸣器 |
| 开漏输出 | 只能输出低电平,高电平靠外部上拉 | I2C 总线、多设备共用线 |
| 浮空输入 | 输入电平完全由外部决定,内部无上下拉 | 外部中断检测 |
| 上拉输入 | 内部接上拉电阻,默认高电平 | 按键检测(常开型) |
| 下拉输入 | 内部接下拉电阻,默认低电平 | 按键检测(常闭型) |
| 模拟模式 | 关闭数字功能,信号直接进 ADC 等外设 | ADC 采样 |
对于共享单车锁控板上的按键,我一般用上拉输入模式。为什么?因为按键按下时接地,不按时靠内部上拉保持高电平,这样最省电,也最稳定。
核心要点:按键检测的 GPIO 一定要配置为输入模式,并且使能内部上拉或下拉。千万别忘了配置速度——输入模式不需要高速,设为低速即可,能减少 EMI 干扰。
来看一段初始化代码,以 STM32 为例:
// 按键 GPIO 初始化
void Key_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 使能 GPIO 时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
// 配置 PA0 为上拉输入
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}
这段代码看着简单,但我曾经犯过一个错:忘了使能时钟。结果 GPIO 死活读不到电平,排查了半天。你想想看,时钟都没开,外设怎么工作?
3.2 按键消抖处理:硬件和软件两手抓
按键按下和松开时,机械触点会弹跳。这个弹跳时间大概 5~20ms。如果不处理,一次按键可能被误判成多次。
我在项目中遇到过最离谱的情况:用户按一次开锁键,锁控板检测到了 7 次下降沿,结果锁开了又关、关了又开。嗯,这就是没做消抖的后果。
3.2.1 硬件消抖
硬件消抖,说白了就是加个 RC 低通滤波器。电容把毛刺滤掉,电阻限制充放电电流。
典型电路是这样的:
VCC
|
R (10kΩ)
|
+-----> 到 GPIO
|
C (0.1μF)
|
GND
时间常数 τ = R × C = 10k × 0.1μ = 1ms。这个值能滤掉大部分抖动,但不会让按键响应太迟钝。
我的经验:硬件消抖适合对成本不敏感、对实时性要求高的场景。共享单车锁控板因为要省成本,我一般只用软件消抖,硬件上只保留一个 100Ω 的限流电阻。
3.2.2 软件消抖
软件消抖有两种主流方法:
- 延时消抖:检测到电平变化后,延时 10~20ms 再读一次。如果电平一致,就确认按键动作。
- 状态机消抖:用状态机记录按键状态,每隔几毫秒扫描一次,连续多次采样一致才确认。
我个人更推荐状态机方式。为什么?延时消抖会阻塞 CPU,在低功耗场景下简直是灾难。
来看一个简单的状态机消抖代码:
// 按键状态机
typedef enum {
KEY_STATE_IDLE, // 空闲
KEY_STATE_PRESSED, // 按下确认
KEY_STATE_RELEASED // 松开确认
} KeyState;
KeyState key_scan(void)
{
static KeyState state = KEY_STATE_IDLE;
static uint8_t cnt = 0;
uint8_t level = HAL_GPIO_ReadPin(KEY_GPIO_PORT, KEY_PIN);
switch(state) {
case KEY_STATE_IDLE:
if(level == 0) { // 检测到低电平(按下)
cnt++;
if(cnt >= 3) { // 连续 3 次采样为低
cnt = 0;
state = KEY_STATE_PRESSED;
return KEY_STATE_PRESSED;
}
} else {
cnt = 0;
}
break;
case KEY_STATE_PRESSED:
if(level == 1) { // 检测到高电平(松开)
cnt++;
if(cnt >= 3) {
cnt = 0;
state = KEY_STATE_IDLE;
return KEY_STATE_RELEASED;
}
} else {
cnt = 0;
}
break;
}
return state; // 无有效事件
}
这段代码每 5ms 调用一次。连续 3 次采样一致(15ms),才认为按键状态变化。这样既消抖,又不阻塞。
注意:采样间隔和连续次数要根据实际按键的抖动特性调整。我遇到过一种劣质按键,抖动长达 30ms,最后不得不把连续次数改成 6 次。所以,量产前一定要实测按键波形。
3.3 外部中断实现唤醒:低功耗的关键
共享单车锁控板是电池供电的。如果 CPU 一直轮询按键,功耗根本扛不住。所以必须用外部中断——平时 CPU 睡大觉,按键按下时唤醒。
外部中断的配置要点:
- 触发方式:一般用下降沿触发(按键按下时电平从高变低)。
- 中断优先级:按键中断优先级不要设太高,避免打断更重要的任务(比如通信)。
- 中断服务函数:要短小精悍,只做标志位设置,不要在里面做复杂处理。
来看配置代码:
// 外部中断初始化
void Key_EXTI_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();
// PA0 作为外部中断线
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 配置中断优先级
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}
// 中断服务函数
void EXTI0_IRQHandler(void)
{
if(__HAL_GPIO_EXTI_GET_IT(KEY_PIN) != RESET) {
// 清除中断标志
__HAL_GPIO_EXTI_CLEAR_IT(KEY_PIN);
// 设置按键事件标志(不要在这里做消抖!)
g_key_event_flag = 1;
}
}
中断里只设一个标志位。然后在主循环里检测这个标志,再做消抖和处理。这样中断服务函数执行时间极短,不会影响其他中断。
低功耗流程:
- 主循环检测到无事可做,调用 HAL_PWR_EnterSTOPMode() 进入停止模式。
- 用户按下按键,外部中断唤醒 CPU。
- CPU 从停止模式恢复,执行中断服务函数,设置标志位。
- 主循环检测到标志位,执行消抖和按键处理。
- 处理完毕,再次进入停止模式。
这样,待机电流可以做到 10μA 以下。我实测过,一节 18650 电池能撑半年以上。
3.4 避坑指南
最后,分享几个我踩过的坑:
- GPIO 复用功能冲突:有些引脚默认是 JTAG/SWD 调试口。我曾在 PA15 上接了按键,结果调试器死活连不上。后来才发现 PA15 是 JTDI 复用引脚,需要先关闭 JTAG 功能。
- 中断标志位没清干净:外部中断触发后,一定要在中断服务函数里清除标志位。否则会一直进中断,CPU 根本没法休眠。
- 按键唤醒后要重新配置时钟:从停止模式唤醒后,HSE/PLL 等时钟源可能已经关闭。我遇到过唤醒后系统时钟变成 HSI,导致外设工作异常。所以唤醒后第一件事就是重新配置时钟。
嗯,这一章的内容就到这里。GPIO 和按键看似简单,但做好了,整个系统的稳定性和功耗就成功了一半。下一章咱们聊定时器,那又是另一片天地了。