4、GPIO与按键输入:GPIO模式配置、按键消抖(硬件/软件)、中断触发与轮询
各位好,我是老张。今天咱们聊聊嵌入式开发里最基础、但也最容易翻车的一个话题——GPIO与按键输入。
你想想看,一个智能剃须刀,用户怎么跟它交互?无非就是按个开关、调个档位、看看电量。这些动作背后,全是GPIO在干活。我做了十几年嵌入式,经手的项目少说也有几十个,但每次遇到按键处理,还是得打起十二分精神。为什么?因为看似简单的东西,坑往往最多。
4.1 GPIO模式配置:别小看这步
GPIO,全称是General Purpose Input/Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的引脚。
但这里有个关键点:GPIO不是只有一种模式。我见过不少新手,上来就把引脚配成推挽输出,结果接了个按键,读回来的电平全是乱的。嗯,这里要注意。
常见的GPIO模式有这些:
| 模式 | 说明 | 典型用途 |
|---|---|---|
| 推挽输出 | 可以输出高电平或低电平,驱动能力强 | 驱动LED、蜂鸣器 |
| 开漏输出 | 只能输出低电平,高电平需要外部上拉 | I2C总线、多路复用信号 |
| 浮空输入 | 引脚电平完全由外部决定,内部无上下拉 | 外部有明确驱动源的信号 |
| 上拉输入 | 内部接上拉电阻,默认高电平 | 按键输入(按下为低) |
| 下拉输入 | 内部接下拉电阻,默认低电平 | 按键输入(按下为高) |
| 模拟模式 | 引脚用于ADC等模拟信号采集 | 电池电压检测 |
我个人习惯,做按键输入时,优先用上拉输入。为什么?因为大多数按键电路都是按下接地,上拉输入正好匹配。而且内部上拉电阻一般在40kΩ左右,功耗低,省得你外接电阻了。
核心原则:输入引脚一定要有确定的电平状态。浮空输入就像悬在半空中,稍微有点干扰,电平就乱跳。我踩过这个坑,后来再也不敢用浮空输入接按键了。
配置代码也很简单,以STM32为例:
// 配置PA0为上拉输入模式
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE(); // 开启GPIOA时钟
GPIO_InitStruct.Pin = GPIO_PIN_0; // 选择PA0
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // 输入模式
GPIO_InitStruct.Pull = GPIO_PULLUP; // 上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
4.2 按键消抖:硬件还是软件?
按键消抖,这可能是嵌入式开发里最经典的坑之一。你按一下按键,理论上应该只触发一次动作。但实际呢?机械按键在按下和释放的瞬间,触点会弹跳几次,产生一连串的脉冲信号。
为什么会这样?说白了,就是金属片碰撞时的物理反弹。这个弹跳时间一般在5-20ms之间。如果不处理,一次按键可能被误判成十几次。
消抖有两种方式:硬件消抖和软件消抖。
4.2.1 硬件消抖
硬件消抖,就是在按键电路上加电容和电阻,构成一个低通滤波器。电容把尖峰脉冲平滑掉,电阻限制充放电电流。
典型电路是这样的:按键一端接地,另一端接GPIO,同时通过一个10kΩ电阻上拉到VCC,再并联一个0.1μF的电容到地。
这个RC时间常数τ = R × C = 10kΩ × 0.1μF = 1ms。实际效果能滤掉大部分弹跳。我有个项目,客户要求按键响应必须快,我就用了硬件消抖,软件里只做简单的去抖,效果很好。
小技巧:电容不要选太大,否则按键响应会变慢。0.1μF到1μF之间比较合适。我曾经试过10μF,结果按下去要等100ms才能读到电平变化,用户体验极差。
4.2.2 软件消抖
软件消抖更灵活,成本也低。最常见的做法是:检测到电平变化后,延时10-20ms再读一次,如果电平状态一致,就确认是有效按键。
代码示例:
uint8_t Key_Scan(void)
{
static uint8_t last_state = 1; // 上次状态,默认高电平
uint8_t current_state;
current_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
// 检测到下降沿(按下)
if (last_state == 1 && current_state == 0)
{
// 延时消抖
HAL_Delay(10); // 延时10ms
current_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
if (current_state == 0) // 确认按下
{
last_state = 0;
return 1; // 返回按键按下事件
}
}
// 检测到上升沿(释放)
if (last_state == 0 && current_state == 1)
{
HAL_Delay(10);
current_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
if (current_state == 1)
{
last_state = 1;
// 释放事件,可根据需要处理
}
}
return 0; // 无有效按键
}
注意:HAL_Delay()是阻塞延时,如果在中断里调用,会导致系统卡死。我建议在中断里用状态机或定时器来做非阻塞消抖。
4.3 中断触发与轮询:两种思路
读取按键状态,有两种主流方式:轮询和中断。
4.3.1 轮询方式
轮询,就是主循环里不停地去读GPIO电平。简单直接,适合对实时性要求不高的场景。
比如智能剃须刀的开关按键,用户按下去后,电机启动。这个响应时间在100ms以内用户都感觉不到,轮询完全够用。
但轮询有个问题:浪费CPU。你想想看,主循环里大部分时间都在做无用功——读一个没变化的电平。对于低功耗设备来说,这简直是灾难。
4.3.2 中断方式
中断方式就好多了。平时CPU该干嘛干嘛,按键按下时,GPIO电平变化触发中断,CPU立刻响应。
配置中断的代码:
// 配置PA0为外部中断,下降沿触发
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_SYSCFG_CLK_ENABLE();
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)
{
// 这里不要直接处理业务逻辑
// 建议设置一个标志位,在主循环里处理
key_press_flag = 1;
// 清除中断标志
__HAL_GPIO_EXTI_CLEAR_IT(KEY_Pin);
}
}
我的经验:中断里只做最轻量级的事——设置标志位、启动定时器。千万别在中断里做延时、打印、复杂计算。我曾经在一个项目里,中断里调用了printf,结果系统频繁死机,查了两天才发现是中断嵌套导致的堆栈溢出。
4.4 实际项目中的选择
回到智能剃须刀这个场景。我建议这样设计:
- 开关按键:用中断+软件消抖。用户按下开关,需要立即响应,中断最合适。
- 档位切换:用轮询。档位切换不需要那么快,而且可以结合长按、双击等复杂逻辑,轮询更容易实现。
- 低功耗模式:用中断唤醒。剃须刀待机时,CPU进入睡眠模式,只有按键中断能唤醒它。
嗯,说到低功耗,我补充一句。很多芯片在睡眠模式下,GPIO中断依然有效。你配置好下降沿触发,按一下按键,CPU就醒了。这个功能在电池供电的设备里特别实用。
好了,这一章就到这里。GPIO看似简单,但配置错了、消抖没做好、中断用得不恰当,都会让产品出问题。下一章我们聊聊定时器,这东西在按键长按检测、PWM调速里都会用到。