4. GPIO 输入驱动实战:按键检测与外部中断

好,咱们进入第四章。前面几章我们把 GPIO 输出玩得差不多了,LED 能亮能灭,继电器能吸能放。但单片机要是只会输出,那跟一个只会喊口号的指挥官有什么区别?真正的交互,得靠输入。

这一章,我们就来搞定 GPIO 输入。核心场景就是——按键检测。别小看按键,这里面的坑,我当年可是一个一个踩过来的。

4.1 按键检测的基础:电平读取

按键的本质是什么?说白了,就是一个机械开关。按下时导通,松开时断开。我们通过 GPIO 读取引脚电平,就能知道按键状态。

硬件上通常有两种接法:

  • 上拉输入:按键一端接地,另一端接 GPIO。平时引脚被内部或外部上拉电阻拉到高电平,按下后拉低。
  • 下拉输入:按键一端接 VCC,另一端接 GPIO。平时引脚被拉到低电平,按下后拉高。

我个人习惯用上拉接法,因为低电平有效更符合直觉——按下就是 0,松开就是 1。而且大多数 MCU 内部都有上拉电阻,省一个外部元件。

关键点:读取 GPIO 输入前,务必确认引脚模式配置正确。STM32 中要设置 MODE 为输入,并开启上拉或下拉电阻。

// 以 STM32F1 为例,配置 PA0 为上拉输入
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;  // 上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 读取电平
uint8_t key_state = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
if (key_state == GPIO_PIN_RESET) {
    // 按键按下
}

4.2 消抖处理:别让机械特性坑了你

直接读电平就能用了吗?太天真了。你想想看,机械按键在按下和松开的瞬间,触点会弹跳。这个弹跳时间通常持续 5~20ms,期间电平会反复跳变。

我第一次做按键项目时,就吃了这个亏。按下一次按键,程序检测到了七八次触发。那感觉,就像你只按了一下门铃,屋里的人却以为你在疯狂砸门。

避坑指南:我曾经在一个工业控制面板上,因为没做消抖,导致按键一次按下触发了多次模式切换。现场调试时被客户盯着,那叫一个尴尬。从此以后,消抖成了我的肌肉记忆。

消抖有两种主流方式:

方式 原理 优点 缺点
硬件消抖 并联电容(通常 0.1μF)或加 RC 滤波 不占 CPU,响应快 增加 BOM 成本,不灵活
软件消抖 检测到电平变化后,延时 10~20ms 再确认 零成本,可调参数 占用 CPU 时间(可用定时器优化)

实际项目中,我几乎只用软件消抖。原因很简单——省钱、省 PCB 面积、改参数方便。

软件消抖的标准流程:

  1. 检测到引脚电平变化(比如从高变低)
  2. 延时 10~20ms(用定时器轮询或阻塞延时)
  3. 再次读取引脚电平
  4. 如果电平与第一次一致,确认按键有效
  5. 否则视为抖动,忽略
// 简单的阻塞式消抖示例
#define DEBOUNCE_TIME_MS  20

uint8_t Key_Scan(void) {
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        HAL_Delay(DEBOUNCE_TIME_MS);  // 延时消抖
        if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
            // 等待按键释放,避免长按重复触发
            while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET);
            return 1;  // 有效按键
        }
    }
    return 0;
}

提示:阻塞式延时在简单系统中能用,但如果你跑 RTOS 或有时序敏感的任务,建议用状态机 + 定时器实现非阻塞消抖。后面章节我会专门讲状态机写法。

4.3 外部中断(EXTI):让 CPU 别再傻等

轮询读取按键,CPU 得一直盯着引脚。这就像你等快递,一直站在门口盯着猫眼——啥也干不了。

外部中断(EXTI)就是解决方案。它让 GPIO 引脚在电平变化时,主动通知 CPU。CPU 平时该干嘛干嘛,中断来了再处理。

EXTI 的配置要点:

  • 中断触发方式:上升沿、下降沿、双边沿
  • 中断线映射:每个 EXTI 线只能对应一个 GPIO 引脚(比如 EXTI0 只能对应 PA0、PB0、PC0 中的一个)
  • 中断服务函数:在 ISR 中处理按键逻辑,注意要清除中断标志位
// STM32F1 EXTI 配置示例(PA0 下降沿触发)
void EXTI0_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
        // 延时消抖(ISR 中尽量短,这里仅演示)
        HAL_Delay(20);
        if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
            // 有效按键,执行动作
            key_pressed = 1;
        }
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);  // 清标志
    }
}

注意:ISR 里不要做复杂操作!延时、打印、大循环都是大忌。我见过有人在中断里放了个 HAL_Delay(500),结果系统直接卡死。中断服务函数的原则是:快进快出,只设标志位,具体处理丢到主循环或任务里。

4.4 中断优先级分组:谁先谁后得有规矩

当系统里有多个中断源时(比如按键、定时器、串口、ADC),就得给它们排个队。这就是中断优先级分组干的事。

ARM Cortex-M 内核支持中断优先级分组,说白了就是把优先级寄存器拆成两部分:

  • 抢占优先级:数值越小,优先级越高。高抢占优先级可以打断低抢占优先级的中断。
  • 子优先级:当抢占优先级相同时,子优先级决定谁先执行。但子优先级不能互相打断。

STM32 通过 HAL_NVIC_SetPriorityGrouping() 设置分组方式。一共有 5 种分组:

分组编号 抢占优先级位数 子优先级位数 典型场景
NVIC_PRIORITYGROUP_0 0 4 所有中断同级,无嵌套
NVIC_PRIORITYGROUP_1 1 3 简单系统,少量中断
NVIC_PRIORITYGROUP_2 2 2 常用配置,平衡灵活
NVIC_PRIORITYGROUP_3 3 1 多中断嵌套需求
NVIC_PRIORITYGROUP_4 4 0 完全抢占,无子优先级

我个人习惯用分组 2(4 级抢占,4 级子优先级)。这个配置在大多数项目中够用,而且不容易搞乱。

// 设置中断优先级分组为 Group 2
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

// 配置 EXTI0 中断优先级:抢占 1,子优先级 0
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

// 配置 TIM2 中断优先级:抢占 2,子优先级 0
HAL_NVIC_SetPriority(TIM2_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(TIM2_IRQn);

经验之谈:中断优先级一旦定下来,整个项目周期最好别改。我有个同事中途改了分组方式,结果所有中断优先级都得重新算,调试了两天才发现是这里埋的雷。

4.5 本章知识体系总览

下面这张图,把 GPIO 输入驱动的核心脉络画出来了。从硬件到软件,从轮询到中断,从消抖到优先级,一条线串起来。

GPIO 输入驱动知识体系 GPIO 输入驱动知识体系 硬件基础:上拉 / 下拉输入 核心问题:机械抖动 → 需要消抖 方案一:硬件消抖 方案二:软件消抖 方案三:外部中断 EXTI 中断配置:优先级分组 + NVIC 设置 最终目标:可靠、高效的按键检测 从硬件接法到中断优先级,每一步都影响最终系统的稳定性和实时性

4.6 小结

这一章我们干了三件事:

  • 学会了怎么读 GPIO 电平,以及为什么不能直接读
  • 掌握了消抖的两种方法,重点推荐软件消抖
  • 理解了外部中断和优先级分组,让系统从轮询的苦力活中解放出来