4. GPIO端口编程与实战
按键输入,看起来简单吧?不就是读个电平高低嘛。但实际做产品时,这里面的坑可不少。我记得刚入行那会儿,做一个手持设备,按键怎么按都不灵,后来才发现是抖动没处理好。今天咱们就把这块彻底讲透。
4.1 按键输入的本质
按键说白了就是一个机械开关。按下时导通,松开时断开。我们通过 GPIO 读到的就是高低电平变化。
但有个问题——机械触点不是理想开关。按下瞬间,金属片会弹跳几次,然后才稳定接触。松开时也一样。这个弹跳过程,就叫“抖动”。
抖动时间:通常 5~20ms,取决于按键质量。劣质按键可能更长。
如果不处理抖动,你按一次按键,程序可能读到 3~5 次跳变。结果就是:按一下,功能触发好几次。用户体验极差。
4.2 硬件消抖 vs 软件消抖
消抖有两种思路。我个人的习惯是:能用软件解决的,尽量不动硬件。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 硬件消抖 | RC 低通滤波 + 施密特触发器 | 不占 CPU,响应快 | 增加 BOM 成本,PCB 面积 |
| 软件消抖 | 延时采样 / 状态机 | 零成本,灵活可调 | 占用 CPU 时间(可用定时器优化) |
硬件消抖适合对响应速度要求极高的场景。但大部分嵌入式产品,软件消抖完全够用。
4.2.1 最简单的软件消抖:延时采样
思路很简单:检测到电平变化后,等 20ms 再读一次。如果电平一致,就确认是有效按键。
// 简易按键扫描函数
uint8_t Key_Scan(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
if (GPIO_ReadInputDataBit(GPIOx, GPIO_Pin) == 0) // 检测到按下(低电平)
{
Delay_ms(20); // 跳过抖动区
if (GPIO_ReadInputDataBit(GPIOx, GPIO_Pin) == 0) // 再次确认
{
while (GPIO_ReadInputDataBit(GPIOx, GPIO_Pin) == 0); // 等待松开
return 1; // 有效按键
}
}
return 0;
}
注意:Delay_ms(20) 是阻塞延时。如果在中断或实时性要求高的任务里用,会卡住整个系统。我曾经在一个电机控制项目里这么写过,结果按键按下时电机响应直接延迟了 20ms,差点出事故。
4.2.2 更专业的做法:状态机消抖
不阻塞,用定时器轮询。每次中断里读一次按键状态,连续读到 N 次相同电平才确认。
// 状态机消抖(定时器 5ms 调用一次)
#define DEBOUNCE_COUNT 4 // 4 * 5ms = 20ms
uint8_t Key_StateMachine(uint8_t current_level)
{
static uint8_t count = 0;
static uint8_t last_level = 1; // 假设默认高电平
static uint8_t stable_level = 1;
if (current_level == last_level)
{
if (count < DEBOUNCE_COUNT)
count++;
else
stable_level = current_level;
}
else
{
count = 0;
last_level = current_level;
}
return stable_level;
}
这种写法不阻塞,适合在 Systick 中断或定时器中断里调用。我个人项目里基本都用这种。
4.3 外部中断 EXTI
轮询按键有个缺点:CPU 得一直查。如果系统有休眠需求,轮询就尴尬了。这时候就该 EXTI 上场了。
外部中断 EXTI,说白了就是 GPIO 电平变化时,硬件自动触发中断。CPU 不用一直盯着,省电又高效。
4.3.1 EXTI 的硬件结构
STM32 的 EXTI 有 20 条中断线(EXTI0~EXTI19)。但 GPIO 引脚很多,怎么映射?
答案是:通过 AFIO(复用功能 I/O)或 SYSCFG 配置。同一组中断线只能选一个引脚。比如 EXTI0 可以选 PA0、PB0、PC0……但同一时间只能选一个。
小技巧:如果你需要多个引脚触发同一个中断服务函数,可以把它们接到不同的中断线上,然后在中断里统一判断。
4.3.2 EXTI 配置步骤
- 使能 GPIO 时钟和 AFIO/SYSCFG 时钟
- 配置 GPIO 为输入模式(上拉/下拉根据电路)
- 配置 EXTI 中断线,选择触发方式(上升沿/下降沿/双边沿)
- 配置 NVIC,使能中断通道
- 编写中断服务函数
// EXTI 配置示例(PA0 下降沿触发)
void EXTI_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
EXTI_InitTypeDef EXTI_InitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
// 2. 配置 PA0 为上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 3. 映射 PA0 到 EXTI0
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
// 4. 配置 EXTI
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_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
// 中断服务函数
void EXTI0_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
// 按键按下,执行动作
// 注意:这里最好加一个软件消抖,或者用定时器延迟处理
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
重要:中断服务函数里不要做耗时操作。按键消抖、长按检测这些逻辑,建议放到主循环或定时器里处理。中断里只做标志位设置。
4.4 中断优先级与 NVIC 配置
NVIC(嵌套向量中断控制器)是 Cortex-M 内核的中断管家。它决定了:多个中断同时发生时,先处理谁。
4.4.1 抢占优先级 vs 响应优先级
STM32 把优先级分成两组:
- 抢占优先级:数值越小,优先级越高。高抢占优先级可以打断低抢占优先级的中断。
- 响应优先级(子优先级):数值越小,优先级越高。但响应优先级不能打断,只在同抢占优先级时决定谁先执行。
举个例子:
| 中断 | 抢占优先级 | 响应优先级 |
|---|---|---|
| 定时器中断 | 0 | 0 |
| EXTI 中断 | 1 | 0 |
| UART 中断 | 1 | 1 |
定时器中断可以打断 EXTI 和 UART。EXTI 和 UART 同抢占优先级,但 EXTI 响应优先级更高,所以 EXTI 先执行。
4.4.2 优先级分组
STM32 用 NVIC_PriorityGroupConfig() 来分配抢占优先级和响应优先级的位数。
// 常用分组:4 位抢占 + 0 位响应
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
// 这样所有中断只有抢占优先级,没有子优先级
我个人习惯用 Group_4,简单粗暴。如果项目里中断嵌套需求复杂,再考虑其他分组。
经验之谈:中断优先级配置,整个系统只调用一次 NVIC_PriorityGroupConfig()。中途改分组,会导致优先级混乱。我见过有人在不同模块里各调一次,结果中断响应全乱套了。
4.5 知识结构总览
下面这张图把本章的核心逻辑串起来了:
4.6 避坑指南
最后分享几个我踩过的坑:
- 中断里做延时:我曾经在 EXTI 中断里直接调 Delay_ms(20) 做消抖,结果系统响应变得奇慢无比。后来改成中断只设标志位,主循环里处理消抖。
- 忘记清中断标志:EXTI 中断触发后,如果不手动清除挂起位,中断会反复进入。轻则系统卡死,重则看门狗复位。
- 优先级分组不一致:不同模块各调一次 NVIC_PriorityGroupConfig(),导致优先级配置被覆盖。建议在 main 函数最开头统一配置。
- GPIO 模式选错:按键电路如果是按下接地,GPIO 要配成上拉输入。如果配成浮空输入,引脚电平不确定,按键会乱触发。
总结一下:按键输入看似简单,但消抖和中断配置是基本功。硬件消抖省心但费钱,软件消抖灵活但要注意不阻塞。EXTI 让 CPU 从轮询中解放出来,但中断服务函数一定要短小精悍。NVIC 优先级配置,全局统一,一次搞定。