4、GPIO与按键:GPIO输入输出配置、按键消抖处理、中断方式读取按键
好,咱们今天聊点实在的。GPIO 和按键,这俩东西看着简单,但坑是真不少。我刚开始做嵌入式那会儿,觉得按键不就是读个电平嘛,有啥难的?结果被现实狠狠教育了一顿。嗯,咱们今天就把这块彻底讲透。
4.1 GPIO 输入输出配置——别小看这一步
GPIO 的配置,说白了就是告诉芯片:你这个脚是往外送信号,还是往里收信号。STM32 的 GPIO 模式比 51 单片机复杂不少,但灵活度也高得多。
我个人习惯,先把 GPIO 的配置分成两大类:输出模式和输入模式。输出模式里又分推挽输出和开漏输出。推挽输出最常用,驱动能力强,高低电平都能主动拉。开漏输出呢,一般用在 I2C 这种需要“线与”的场合。
输入模式就更有意思了。有浮空输入、上拉输入、下拉输入,还有模拟输入。做按键检测,我一般用上拉输入。为什么?因为按键按下时接地,电平从高变低,逻辑清晰,不容易受干扰。
重点来了:配置 GPIO 时,别忘了开启对应的 GPIO 时钟。我见过太多新手,代码写了一大堆,结果灯不亮、按键没反应,最后发现是 RCC 时钟没开。这坑我踩过,真的。
// 以 PA0 为例,配置为上拉输入
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
你看,代码就这么几行。但每一行都有它的道理。GPIO_Speed 在输入模式下其实不影响,但我习惯写上,保持代码风格统一。
4.2 按键消抖处理——硬件抖一下,软件抖三抖
按键按下和松开的时候,触点会机械抖动。这个抖动时间大概 5-20ms。如果你直接读电平,可能会读到好几次跳变。一次按键,程序可能认为你按了十几次。
我在项目中遇到过这种情况:一个简单的菜单选择按键,按一下跳两三个选项。用户反馈说这产品“手抖”……其实不是手抖,是按键抖。
消抖的方法有两种:硬件消抖和软件消抖。硬件消抖就是在按键两端并联一个电容,利用电容的充放电来平滑信号。但成本高,而且占 PCB 面积。我一般用软件消抖,省钱又灵活。
软件消抖的原理很简单:检测到电平变化后,延时 10-20ms,再读一次。如果电平状态一致,就确认按键动作。
// 简单的按键消抖函数
uint8_t Key_Scan(void)
{
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) // 检测到按下
{
Delay_ms(10); // 延时消抖
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) // 再次确认
{
while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0); // 等待松开
return 1; // 返回按键有效
}
}
return 0;
}
注意:Delay_ms(10) 这个延时函数,在裸机程序里可以用 SysTick 实现。但如果你跑 RTOS,千万别用这种阻塞延时,否则整个系统都会被卡住。我曾经在一个 FreeRTOS 项目里用了这种延时,结果任务调度全乱了……后来改用 vTaskDelay,问题解决。
4.3 中断方式读取按键——别让 CPU 傻等
轮询方式读取按键,CPU 得一直去查电平状态。如果按键不多还好,按键一多,或者系统有其他任务,轮询就变得很低效。这时候就该中断出场了。
STM32 的 EXTI(外部中断)可以配置成上升沿、下降沿或者双边沿触发。做按键检测,我一般用下降沿触发。按键按下时电平从高变低,正好触发中断。
配置中断的步骤也不复杂:
- 配置 GPIO 为上拉输入
- 配置 EXTI 线,选择对应的 GPIO 引脚
- 配置 NVIC,设置中断优先级
- 编写中断服务函数
// 配置 PA0 为 EXTI0 中断
void EXTI0_Config(void)
{
// 1. 开启时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// 2. 配置 GPIO
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 3. 连接 EXTI 线到 PA0
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// 4. 配置 EXTI
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 下降沿触发
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
// 5. 配置 NVIC
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x01;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x01;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
// 中断服务函数
void EXTI0_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
// 这里加一个软件消抖,防止误触发
Delay_ms(10);
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0)
{
// 按键有效,执行对应操作
Key_Action();
}
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
小技巧:中断服务函数里尽量别做耗时操作。按键消抖的 10ms 延时,如果在中断里做,会阻塞其他中断。我一般会在中断里只设置一个标志位,然后在主循环里处理按键逻辑。这样既响应快,又不影响系统实时性。
4.4 三种方式的对比——选哪个?
你可能会问:那我到底用轮询还是中断?我整理了一个表格,方便你对比:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 轮询(无消抖) | 代码简单,占用资源少 | 容易误触发,CPU 占用高 | 教学演示、简单测试 |
| 轮询(软件消抖) | 稳定可靠,实现简单 | 阻塞延时影响系统响应 | 按键少、任务简单的系统 |
| 中断(硬件消抖) | 响应快,不占用 CPU | 硬件成本高,配置稍复杂 | 对实时性要求高的系统 |
| 中断(软件消抖) | 响应快,灵活 | 中断服务函数不宜过长 | 大多数实际项目 |
我个人建议:实际项目里,优先用中断加软件消抖。既保证了响应速度,又控制了成本。但如果你做的是超低功耗设备,轮询可能更省电——因为中断会频繁唤醒 MCU。
4.5 避坑指南——我曾经踩过的雷
最后分享几个我实际项目中遇到的坑,你遇到了可以少走弯路:
- GPIO 复用功能冲突:有一次我把 PA0 同时配成了 EXTI 和 ADC,结果两个功能都不正常。后来查手册才发现,PA0 的 EXTI 和 ADC 不能同时用。配置前一定要看数据手册的“Alternate Function”表格。
- 中断优先级设置不当:按键中断优先级设得太高,结果系统滴答定时器中断被抢占了,导致延时函数不准。按键消抖反而失效了。嗯,优先级这东西,得全局考虑。
- 忘记清除中断标志位:这个错误我犯过不止一次。中断服务函数里如果不调用 EXTI_ClearITPendingBit,中断会一直触发,CPU 就卡在中断里出不来了。调试的时候还以为芯片坏了……
- 按键长按和短按的处理:有些场景需要区分长按和短按。我一般用定时器来计时:按键按下时启动定时器,松开时判断定时时间。超过 1 秒算长按,否则算短按。这个逻辑在中断里实现比较方便。
好了,GPIO 和按键这块就聊到这儿。你想想看,其实核心就三点:配置要正确、消抖要可靠、中断要高效。把这三点吃透了,按键这块基本就不会出问题了。下一章咱们聊聊定时器,那个更有意思。