4. GPIO 进阶:按键输入与消抖、外部中断 EXTI、中断优先级与 NVIC 配置

按键输入,看起来简单吧?不就是读个电平高低嘛。但实际做产品时,这里面的坑可不少。我记得刚入行那会儿,做一个手持设备,按键怎么按都不灵,后来才发现是抖动没处理好。今天咱们就把这块彻底讲透。

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 配置步骤

  1. 使能 GPIO 时钟和 AFIO/SYSCFG 时钟
  2. 配置 GPIO 为输入模式(上拉/下拉根据电路)
  3. 配置 EXTI 中断线,选择触发方式(上升沿/下降沿/双边沿)
  4. 配置 NVIC,使能中断通道
  5. 编写中断服务函数
// 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 知识结构总览

下面这张图把本章的核心逻辑串起来了:

GPIO 进阶:按键输入与中断体系 按键输入 硬件消抖(RC 滤波) 软件消抖(延时采样) 软件消抖(状态机) 轮询(Polling) 外部中断(EXTI) 中断优先级(NVIC) EXTI 配置:GPIO 映射 → 触发边沿选择 → 中断使能 → NVIC 使能 ⚠ 中断里不做耗时操作 | 消抖不能省 | 优先级分组全局只调一次 中断服务函数只设标志位,具体逻辑放主循环或定时器

4.6 避坑指南

最后分享几个我踩过的坑:

  • 中断里做延时:我曾经在 EXTI 中断里直接调 Delay_ms(20) 做消抖,结果系统响应变得奇慢无比。后来改成中断只设标志位,主循环里处理消抖。
  • 忘记清中断标志:EXTI 中断触发后,如果不手动清除挂起位,中断会反复进入。轻则系统卡死,重则看门狗复位。
  • 优先级分组不一致:不同模块各调一次 NVIC_PriorityGroupConfig(),导致优先级配置被覆盖。建议在 main 函数最开头统一配置。
  • GPIO 模式选错:按键电路如果是按下接地,GPIO 要配成上拉输入。如果配成浮空输入,引脚电平不确定,按键会乱触发。

总结一下:按键输入看似简单,但消抖和中断配置是基本功。硬件消抖省心但费钱,软件消抖灵活但要注意不阻塞。EXTI 让 CPU 从轮询中解放出来,但中断服务函数一定要短小精悍。NVIC 优先级配置,全局统一,一次搞定。