第4章 GPIO与外部中断:从按键到中断的实战之路

各位同学,欢迎来到第四章。这一章我们聊点实在的——GPIO和外部中断。

说实话,GPIO是嵌入式开发里最基础、也最容易被忽视的模块。我见过不少新手,上来就调SPI、I2C,结果连个LED都点不亮。嗯,这其实挺尴尬的。所以今天咱们把GPIO和中断彻底讲透,尤其是那个“按键控制LED”的经典案例,我会把我在飞控项目里踩过的坑都告诉你。

4.1 GPIO模式配置:不只是输入输出那么简单

很多人以为GPIO就是“输入”和“输出”两种模式。其实不然。在STM32这类MCU里,GPIO有8种模式。我挑几个最常用的说说。

模式 说明 典型应用
推挽输出 可以输出高电平和低电平 驱动LED、蜂鸣器
开漏输出 只能输出低电平,高电平靠外部上拉 I2C总线、多设备共享线
浮空输入 引脚电平不确定,容易受干扰 基本不用,除非你清楚自己在干嘛
上拉/下拉输入 内部有电阻固定电平 按键检测、外部信号读取
模拟输入 信号直接进ADC 采集模拟电压

我个人习惯是:输出用推挽,输入用上拉或下拉。为什么?因为浮空输入太不可控了。我在项目中遇到过,一个按键引脚没配置上拉,结果手指还没碰到按键,电平就乱跳。你想想看,这要是飞控的急停按键,后果不堪设想。

核心原则:GPIO配置一定要明确。不要依赖默认值,每个引脚都要手动配置。

来看一段配置代码。这是STM32的标准库写法,HAL库也类似:

// 配置PA0为推挽输出,驱动LED
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置PA1为上拉输入,检测按键
GPIO_InitStruct.Pin = GPIO_PIN_1;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

注意那个Speed参数。很多人喜欢设成最高速,其实没必要。驱动LED用低速就够了,高速反而会增加功耗和EMI。我在飞控上就吃过这个亏——所有GPIO都设成高速,结果板子一上电,电源纹波大得吓人。

4.2 按键中断:别让CPU空转

按键检测有两种方式:轮询和中断。

轮询就是CPU不停地读引脚电平。说白了就是“你按了吗?你按了吗?你按了吗?”——CPU累不累?而且你想想,飞控里CPU要算姿态、控电机、处理传感器,哪有空陪你轮询按键?

所以,按键一定要用中断

外部中断的原理很简单:引脚电平变化时,MCU会暂停当前任务,跳到一个叫“中断服务函数”的地方去处理。处理完再回来继续干活。就像你写代码时手机响了,接个电话再继续写——效率高多了。

配置外部中断的步骤:

  1. 使能GPIO时钟和SYSCFG时钟
  2. 配置GPIO为输入模式
  3. 将GPIO引脚连接到EXTI线
  4. 配置中断触发方式(上升沿、下降沿或双边沿)
  5. 配置NVIC中断优先级
  6. 编写中断服务函数

代码示例:

// 配置PA1为外部中断,下降沿触发(按键按下时电平从高变低)
GPIO_InitStruct.Pin = GPIO_PIN_1;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置NVIC
HAL_NVIC_SetPriority(EXTI1_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI1_IRQn);

// 中断服务函数
void EXTI1_IRQHandler(void)
{
    if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_1) != RESET)
    {
        // 按键被按下,翻转LED
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_1);
    }
}

小技巧:中断服务函数里尽量少做事。我一般只设一个标志位,真正的处理放在主循环里。否则中断里做太多事,容易导致其他中断响应不及时。

4.3 外部中断优先级:谁先谁后?

一个MCU可能有几十个中断源。如果两个中断同时发生,谁先执行?这就看优先级了。

STM32的NVIC支持抢占优先级和子优先级。抢占优先级高的可以打断抢占优先级低的。子优先级只在同抢占优先级时起作用。

举个例子:

中断源 抢占优先级 子优先级
定时器中断 0 0
外部中断 1 0
串口中断 1 1

定时器中断优先级最高,可以打断其他所有中断。外部中断和串口中断同抢占优先级,但外部中断的子优先级更高,所以外部中断先执行。

我在飞控项目里是怎么设的?关键任务(如电机控制)用最高优先级,按键这种非关键任务用低优先级。为什么?因为按键按慢一点没关系,但电机控制延迟几微秒,飞机可能就翻了。

注意:中断优先级不是越多越好。优先级太多,嵌套太深,容易导致栈溢出。我一般只用3-4个优先级层级。

4.4 实战:按键控制LED

好了,理论讲完了,咱们来实战。这个案例很简单:按一下按键,LED亮;再按一下,LED灭。

但别小看它。这个案例包含了GPIO配置、外部中断、中断优先级、消抖处理——全是嵌入式开发的硬功夫。

完整代码:

#include "main.h"

// 按键消抖时间,单位ms
#define DEBOUNCE_TIME 50

volatile uint8_t key_pressed = 0;

void SystemClock_Config(void);
void GPIO_Init(void);
void EXTI_Init(void);

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    GPIO_Init();
    EXTI_Init();

    while (1)
    {
        if (key_pressed)
        {
            key_pressed = 0;
            HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
        }
    }
}

void GPIO_Init(void)
{
    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef GPIO_InitStruct = {0};

    // PA0: LED输出
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // PA1: 按键输入,上拉
    GPIO_InitStruct.Pin = GPIO_PIN_1;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

void EXTI_Init(void)
{
    __HAL_RCC_SYSCFG_CLK_ENABLE();

    HAL_NVIC_SetPriority(EXTI1_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI1_IRQn);
}

void EXTI1_IRQHandler(void)
{
    if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_1) != RESET)
    {
        // 简单的消抖:延时后再检测
        HAL_Delay(DEBOUNCE_TIME);
        if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET)
        {
            key_pressed = 1;
        }
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_1);
    }
}

注意那个消抖处理。机械按键按下时,电平会抖动几次。如果不消抖,一次按键可能触发多次中断。我见过有人用硬件消抖(加电容),也有人用软件消抖(延时再读)。我个人推荐软件消抖,因为灵活,而且不占PCB面积。

避坑指南:我曾经在消抖延时里用过HAL_Delay,结果发现中断里调用HAL_Delay会导致系统卡死。为什么?因为HAL_Delay依赖SysTick中断,而SysTick中断优先级可能比外部中断低。解决办法:要么用定时器做延时,要么把消抖放在主循环里做。

嗯,说到这里,我想起一个真实案例。有一次调试飞控的遥控器信号丢失保护,按键中断没处理好,导致CPU一直在中断里打转,电机直接失控。从那以后,我对中断服务函数的代码量就特别敏感——能少写一行是一行。

好了,这一章的内容就到这里。记住:GPIO配置要明确,中断处理要简洁,优先级分配要合理。下一章我们讲定时器,那才是飞控里真正的大头。

公众号:蓝海资料掘金营,微信deep3321