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(外部中断)可以配置成上升沿、下降沿或者双边沿触发。做按键检测,我一般用下降沿触发。按键按下时电平从高变低,正好触发中断。

配置中断的步骤也不复杂:

  1. 配置 GPIO 为上拉输入
  2. 配置 EXTI 线,选择对应的 GPIO 引脚
  3. 配置 NVIC,设置中断优先级
  4. 编写中断服务函数
// 配置 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 和按键这块就聊到这儿。你想想看,其实核心就三点:配置要正确、消抖要可靠、中断要高效。把这三点吃透了,按键这块基本就不会出问题了。下一章咱们聊聊定时器,那个更有意思。