STM32 零基础可移植教程 06:外部中断按键,不用一直在 while 里盯着它

STM32 零基础可移植教程 06:外部中断按键,不用一直在 while 里盯着它

前面两篇,我们已经把按键这件事讲了两层:

  • 第 04 篇:用轮询方式读按键电平;

  • 第 05 篇:用软件消抖,让按下一次只触发一次事件。

这两种写法都需要主循环不断调用按键扫描函数。

这一篇我们换一种方式:外部中断。

也就是让按键引脚自己"提醒"CPU:

bash 复制代码
按键电平发生变化了,你过来看一下

不过这里先说清楚:外部中断不是万能药。机械按键会抖,换成中断以后仍然会抖。中断只是改变"怎么发现按键变化",不代表自动解决消抖。

所以这一篇只做一个明确目标:

bash 复制代码
按键触发 EXTI 中断,主循环收到按下事件后翻转 LED

中断里不直接翻转 LED,只记录事件。业务动作仍然放在主循环里。

本篇目标

最终现象:

bash 复制代码
按下一次按键,LED 翻转一次

按住不放,LED 不会一直翻转

松开后再按,才会再次翻转

本篇用到的外设:

bash 复制代码
GPIO Input with EXTI

GPIO Output

NVIC

SysTick / HAL_GetTick

本篇跑通标准:

  • Keil 编译通过;

  • 程序能下载到开发板;

  • CubeMX 里按键引脚配置为 EXTI;

  • NVIC 中对应 EXTI 中断已经打开;

  • 按下一次按键,LED 只翻转一次;

  • 能说清楚 EXTI 中断函数、HAL 回调函数、主循环事件处理之间的关系。

准备工作

你需要准备:

|

项目

|

说明

|

| --- | --- |

|

STM32 开发板

|

任意 STM32 开发板

|

|

下载器

|

ST-LINK/V2 或板载 ST-LINK

|

|

LED

|

沿用第 02 篇 LED 工程

|

|

按键

|

沿用第 04/05 篇按键硬件

|

|

原理图

|

确认按键引脚、上拉/下拉、有效电平

|

建议从上一篇 05_key_debounce 复制一份,改名为:

bash 复制代码
06_key_exti

这样 LED、按键引脚、有效电平这些基础内容都能保留,我们只把按键配置从普通输入改成外部中断。

外部中断到底是什么

先别急着看 CubeMX 里的 EXTI、NVIC 这些词。

我们先把"中断"这个概念讲明白。

前面几篇用的是轮询方式。轮询就像 CPU 在主循环里一遍遍问:

bash 复制代码
按键按下了吗?

按键按下了吗?

按键按下了吗?

只要你想及时发现按键变化,主循环就要经常回来检查它。

中断的思路不一样。

中断不是 CPU 一直去问外设,而是外设或硬件事件主动提醒 CPU:

bash 复制代码
先停一下,我这里有事要处理

CPU 收到这个提醒后,会先暂停当前正在执行的主循环代码,跳到一个专门的中断入口函数里处理这件事。处理完以后,再回到刚才被打断的位置继续往下跑。

可以把它理解成这样的流程:

bash 复制代码
主循环正常运行

  -> 按键电平发生变化

  -> EXTI 产生中断请求

  -> NVIC 判断这个中断是否允许响应

  -> CPU 跳到对应的中断服务函数

  -> HAL 调用用户回调函数

  -> 记录按键事件

  -> 回到主循环继续运行

这里有两个点,新手一定要先记住。

第一,中断不是另一个 while 在旁边同时跑。

它更像是临时打断主循环,先处理一件急事,处理完再回来。

第二,中断里不要写太多业务。

比如 LED 翻转、串口打印、复杂判断,这些都可以放到主循环里做。中断里最好只做一件轻量的事:

bash 复制代码
记录一下:按键事件发生了

然后主循环看到这个事件,再去翻转 LED。

这样程序会更稳,后面加串口、定时器、ADC、DMA 时也不容易乱。

理解了"中断"之后,再看"外部中断"就简单了。

轮询方式像这样:

bash 复制代码
while 循环一直问:按键按下了吗?

外部中断方式像这样:

bash 复制代码
按键电平变化时,硬件主动通知 CPU

对 STM32 来说,按键接在某个 GPIO 引脚上。这个 GPIO 可以被配置成 EXTI,也就是外部中断/事件线。

当引脚电平出现指定变化时,比如从高变低,或者从低变高,EXTI 就会触发中断。

常见触发方式有三种:

|

触发方式

|

适合场景

|

| --- | --- |

|

Falling Edge

|

高电平变低电平触发,常用于低电平有效按键

|

|

Rising Edge

|

低电平变高电平触发,常用于高电平有效按键

|

|

Rising/Falling Edge

|

上升沿和下降沿都触发,适合同时关心按下和松开

|

本篇默认按键是低电平有效,也就是:

bash 复制代码
松开:高电平

按下:低电平

所以我们优先选择:

bash 复制代码
External Interrupt Mode with Falling edge trigger detection

硬件连接

按键硬件还是沿用前两篇。

常见低电平有效按键:

bash 复制代码
GPIO ---- 按键 ---- GND

这种情况下通常使用上拉:

bash 复制代码
松开:读到 1

按下:读到 0

常见高电平有效按键:

bash 复制代码
GPIO ---- 按键 ---- 3.3V

这种情况下通常使用下拉:

bash 复制代码
松开:读到 0

按下:读到 1

如果你的按键是低电平有效,本篇用 Falling Edge。

如果你的按键是高电平有效,就应该改成 Rising Edge,并且代码里的 APP_KEY_PRESSED_LEVEL 也要改成 GPIO_PIN_SET

CubeMX 配置步骤

1. 保留 LED 输出配置

LED 沿用第 02 篇配置:

|

配置项

|

推荐值

|

| --- | --- |

|

GPIO mode

|

GPIO_Output

|

|

User Label

|

LED

|

|

初始电平

|

按 LED 有效电平设置为默认灭

|

这一篇仍然用 LED 来显示按键事件。

2. 把按键引脚改成 EXTI

找到按键对应的 GPIO 引脚。

假设按键接在 PA0,点击 PA0,不要再选普通 GPIO_Input,而是选择:

bash 复制代码
GPIO_EXTI0

或者在不同 CubeMX 版本里显示为:

bash 复制代码
GPIO_EXTI0 / External Interrupt Mode

如果你的按键接在 PB12,就会对应 EXTI12。

注意:EXTI 是按"线号"来的。PA0PB0PC0 都属于 EXTI0,但同一个 EXTI 线通常不能同时给多个端口一起用。

3. 配置按键 GPIO 参数

进入:

bash 复制代码
System Core -> GPIO

找到按键引脚,重点确认这些项:

|

配置项

|

低电平有效按键推荐值

|

| --- | --- |

|

GPIO mode

|

External Interrupt Mode with Falling edge trigger detection

|

|

GPIO Pull-up/Pull-down

|

Pull-up

|

|

User Label

|

KEY

|

如果你的按键是高电平有效,通常改成:

|

配置项

|

高电平有效按键推荐值

|

| --- | --- |

|

GPIO mode

|

External Interrupt Mode with Rising edge trigger detection

|

|

GPIO Pull-up/Pull-down

|

Pull-down

|

|

User Label

|

KEY

|

4. 打开 NVIC 中断

配置 EXTI 后,还要打开 NVIC。

在 CubeMX 左侧找到:

bash 复制代码
System Core -> NVIC

然后根据你的按键引脚,勾选对应的 EXTI 中断。

常见对应关系:

|

按键引脚

|

常见中断名

|

| --- | --- |

|

Px0

|

EXTI line0 interrupt

|

|

Px1

|

EXTI line1 interrupt

|

|

Px2

|

EXTI line2 interrupt

|

|

Px3

|

EXTI line3 interrupt

|

|

Px4

|

EXTI line4 interrupt

|

|

Px5 ~ Px9

|

EXTI line[9:5] interrupts

|

|

Px10 ~ Px15

|

EXTI line[15:10] interrupts

|

比如按键是 PB12,就要打开:

bash 复制代码
EXTI line[15:10] interrupts

如果你忘了打开 NVIC,GPIO 配成 EXTI 也没用,中断函数不会进。

5. 生成 Keil 工程

配置完成后点击:

bash 复制代码
GENERATE CODE

打开 Keil 工程,先编译一次确认没有错误。

Keil 工程生成和编译

打开 Keil 后,先编译:

bash 复制代码
Build / F7

确认输出里没有错误:

bash 复制代码
0 Error(s)

然后可以打开 CubeMX 生成的中断文件看一眼:

bash 复制代码
Core/Src/stm32f1xx_it.c

不同芯片系列文件名会不一样,比如:

bash 复制代码
stm32f4xx_it.c

stm32g4xx_it.c

如果按键是 PA0,你会看到类似:

bash 复制代码
void EXTI0_IRQHandler(void)
{

  HAL_GPIO_EXTI_IRQHandler(KEY_Pin);

}

如果按键是 PB12,可能是:

bash 复制代码
void EXTI15_10_IRQHandler(void)
{

  HAL_GPIO_EXTI_IRQHandler(KEY_Pin);

}

这段是 CubeMX 自动生成的,一般不要手动乱改。

完整代码

这一篇我们继续保留 LED 模块:

bash 复制代码
Core/Inc/app_led.h

Core/Src/app_led.c

按键模块改成 EXTI 事件版本:

bash 复制代码
Core/Inc/app_key.h

Core/Src/app_key.c

中断里只做一件事:记录"按键按下事件"。

LED 翻转仍然放在主循环里做。

1. 更新 Core/Inc/app_key.h

打开:

bash 复制代码
Core/Inc/app_key.h

替换为下面代码:

bash 复制代码
#ifndef APP_KEY_H

#define APP_KEY_H


#include "main.h"


typedef
enum

{

    APP_KEY_EVENT_NONE = 
0
,

    APP_KEY_EVENT_PRESSED

} App_KeyEvent;


void App_Key_EXTI_Init(void)
;

App_KeyEvent App_Key_GetEvent(void)
;


#endif

这里我们只保留"按下事件"。

松开事件以后再扩展也可以,但本篇目标是按下一次翻转 LED,一篇不要贪多。

2. 更新 Core/Src/app_key.c

打开:

bash 复制代码
Core/Src/app_key.c

替换为下面代码:

bash 复制代码
#include "app_key.h"


#ifndef KEY_GPIO_Port

#error "KEY_GPIO_Port is not defined. Set the key pin User Label to KEY in CubeMX."

#endif


#ifndef KEY_Pin

#error "KEY_Pin is not defined. Set the key pin User Label to KEY in CubeMX."

#endif


/* * Default: active-low key. * If your key is active-high, change this macro to GPIO_PIN_SET * and set CubeMX EXTI trigger to Rising edge. */

#ifndef APP_KEY_PRESSED_LEVEL

#define APP_KEY_PRESSED_LEVEL  GPIO_PIN_RESET

#endif


#ifndef APP_KEY_EXTI_DEBOUNCE_MS

#define APP_KEY_EXTI_DEBOUNCE_MS  20u

#endif


static
volatile
uint8_t
 s_key_pressed_event = 
0u
;

static
uint32_t
 s_last_exti_tick = 
0u
;


void App_Key_EXTI_Init(void)
{

    s_key_pressed_event = 
0u
;

    s_last_exti_tick = HAL_GetTick();

}


App_KeyEvent App_Key_GetEvent(void)
{

    App_KeyEvent event = APP_KEY_EVENT_NONE;


    __disable_irq();

    
if
 (s_key_pressed_event != 
0u
)

    {

        s_key_pressed_event = 
0u
;

        event = APP_KEY_EVENT_PRESSED;

    }

    __enable_irq();


    
return
 event;

}


void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{

    
uint32_t
 now;


    
if
 (GPIO_Pin != KEY_Pin)

    {

        
return
;

    }


    now = HAL_GetTick();


    
if
 ((now - s_last_exti_tick) < APP_KEY_EXTI_DEBOUNCE_MS)

    {

        
return
;

    }


    s_last_exti_tick = now;


    
if
 (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == APP_KEY_PRESSED_LEVEL)

    {

        s_key_pressed_event = 
1u
;

    }

}

这里用了 __disable_irq() / __enable_irq() 临时保护事件变量,避免主循环读取时被中断打断。它属于"临界区"问题,本篇先按固定写法使用,后面会单独开一篇解释它的原理和使用边界。

这段代码有几个重点:

  1. HAL_GPIO_EXTI_Callback() 是 HAL 提供的外部中断回调函数。

  2. CubeMX 生成的中断函数会先进入 EXTIx_IRQHandler()

  3. EXTIx_IRQHandler() 里会调用 HAL_GPIO_EXTI_IRQHandler(KEY_Pin)

  4. HAL 处理完中断标志后,会调用我们的 HAL_GPIO_EXTI_Callback()

  5. 回调里不直接翻转 LED,只设置 s_key_pressed_event

  6. 主循环通过 App_Key_GetEvent() 取走事件,再执行 LED 翻转。

为什么不在中断里直接翻转 LED?

因为实际工程里,中断函数越短越好。中断里适合做"记录事件",不适合做一堆业务动作。现在只是翻 LED 看起来没事,后面如果换成串口打印、Flash 写入、复杂状态机,就容易把工程搞乱。

3. 确认 app_key.c 加入 Keil 工程

如果你是从上一篇复制工程过来,app_key.c 应该已经在 Keil 工程里。

还是建议确认一下:

bash 复制代码
Application/User/Core

里面应该能看到:

bash 复制代码
app_key.c

app_led.c

如果没有,就右键 Application/User/Core,选择:

bash 复制代码
Add Existing Files to Group 'Application/User/Core'

然后添加:

bash 复制代码
Core/Src/app_key.c

main.c 调用方式

1. Includes 区域

找到:

bash 复制代码
/* USER CODE BEGIN Includes */

/* USER CODE END Includes */

改成:

bash 复制代码
/* USER CODE BEGIN Includes */

#include "app_led.h"

#include "app_key.h"

/* USER CODE END Includes */

2. 初始化区域

确保 MX_GPIO_Init() 已经在前面执行:

bash 复制代码
MX_GPIO_Init();

然后在 USER CODE BEGIN 2 里添加:

bash 复制代码
/* USER CODE BEGIN 2 */

App_LED_Init();

App_Key_EXTI_Init();

/* USER CODE END 2 */

3. while 循环区域

主循环只做事件处理:

bash 复制代码
while
 (
1
)

{

  
/* USER CODE END WHILE */


  
/* USER CODE BEGIN 3 */

  
if
 (App_Key_GetEvent() == APP_KEY_EVENT_PRESSED)

  {

      App_LED_Toggle();

  }

  
/* USER CODE END 3 */

}

这里不需要一直 HAL_GPIO_ReadPin() 读按键,也不需要放 HAL_Delay(5) 扫描。

主循环可以很快地转,等中断来了以后事件标志就会被置位。

编译、下载和验证

代码加完后,先编译:

bash 复制代码
Build / F7

如果没有错误,再下载:

bash 复制代码
Download

下载后观察现象:

bash 复制代码
按下一次按键 -> LED 翻转一次

按住不放 -> LED 不连续翻转

松开再按 -> LED 再翻转一次

如果按键没有反应,不要马上改代码。先查 CubeMX 的 EXTI 模式和 NVIC 是否打开。

移植到其他板子的修改点

这篇的移植点主要有 7 个。

|

要改的地方

|

为什么要改

|

在哪里改

|

| --- | --- | --- |

|

按键引脚

|

不同板子的按键接到不同 GPIO

|

CubeMX Pinout 页面

|

|

EXTI 线号

|

Px0/Px1/Px12 对应不同 EXTI 中断

|

CubeMX 自动生成,对照 NVIC

|

|

触发边沿

|

低电平有效用 Falling,高电平有效用 Rising

|

CubeMX GPIO mode

|

|

Pull-up/Pull-down

|

按键接 GND 还是 3.3V 不同

|

CubeMX GPIO Pull-up/Pull-down

|

|

User Label

|

代码依赖 KEY_PinKEY_GPIO_Port

|

CubeMX GPIO 页面,标签设为 KEY

|

|

有效电平

|

回调里会再次确认当前是否真按下

| APP_KEY_PRESSED_LEVEL |

|

消抖时间

|

不同按键抖动时间不同

| APP_KEY_EXTI_DEBOUNCE_MS |

推荐移植顺序:

  1. 看原理图,确认按键接到哪个 MCU 引脚;

  2. 确认按下时是高电平还是低电平;

  3. CubeMX 把该引脚设置成 GPIO_EXTI

  4. 按硬件选择 Rising 或 Falling Edge;

  5. 按硬件选择 Pull-up 或 Pull-down;

  6. User Label 填 KEY

  7. NVIC 勾选对应 EXTI line interrupt;

  8. 修改 APP_KEY_PRESSED_LEVEL

  9. 编译下载,用 LED 验证。

常见问题排查

1. 按键完全没反应

优先检查:

|

优先检查

|

具体方法

|

| --- | --- |

|

是否配置成 EXTI

|

CubeMX Pinout 里不是普通 GPIO_Input

|

|

NVIC 是否打开

| System Core -> NVIC

勾选对应 EXTI

|

|

触发边沿是否正确

|

低电平有效通常 Falling,高电平有效通常 Rising

|

|

User Label 是否为 KEY

| main.h

里应有 KEY_PinKEY_GPIO_Port

|

| app_key.c

是否加入工程

|

Keil 工程树里确认有 app_key.c

|

| App_Key_EXTI_Init()

是否调用

|

放在 MX_GPIO_Init() 后面

|

2. 进了中断,但 LED 不翻转

优先检查:

  • 主循环里是否调用 App_Key_GetEvent()

  • 是否只在中断里设置了事件,但主循环没有处理;

  • app_led.c 是否加入工程;

  • LED 有效电平是否配置正确;

  • App_LED_Init() 是否在 MX_GPIO_Init() 后面调用。

3. 按一次还是触发好几次

机械按键在中断方式下仍然会抖。

优先调整:

bash 复制代码
#define APP_KEY_EXTI_DEBOUNCE_MS  20u

如果还是多次触发,可以试:

bash 复制代码
#define APP_KEY_EXTI_DEBOUNCE_MS  30u

或者:

bash 复制代码
#define APP_KEY_EXTI_DEBOUNCE_MS  50u

注意,消抖时间太长会让快速连按变迟钝。一般先从 20 ms 开始。

4. 编译报 HAL_GPIO_EXTI_Callback 重复定义

说明你的工程里已经有另一个文件实现了:

bash 复制代码
void
 
HAL_GPIO_EXTI_Callback
(uint16_t GPIO_Pin)

一个工程里不能有两个同名非 weak 函数。

解决方法:

  • 保留一个统一的 HAL_GPIO_EXTI_Callback()

  • 在这个回调里按 GPIO_Pin 分发给不同模块;

  • 不要在多个 .c 文件里各写一个同名回调。

比如可以统一写成:

bash 复制代码
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{

    App_Key_EXTI_Callback(GPIO_Pin);

}

这种结构后面外设多了会更清楚。本篇为了新手少绕一层,直接把回调写在 app_key.c 里。

5. 编译报 KEY_GPIO_Port is not defined

说明 CubeMX 没有生成:

bash 复制代码
KEY_GPIO_Port

KEY_Pin

Core/Inc/main.h 看一下。如果是 KEY0_PinUSER_KEY_Pin,就说明 User Label 不是 KEY

解决方法:

  1. 回 CubeMX;

  2. 找到按键 GPIO;

  3. 把 User Label 改成 KEY

  4. 重新 Generate Code。

6. 短按偶尔没反应

优先检查:

  • 触发边沿是否选对;

  • 按键硬件是否接触不良;

  • 消抖时间是否设置太长;

  • 是否在别的地方长时间关闭中断;

  • 是否在中断里做了太多耗时操作。

本篇代码没有在中断里做耗时动作,这是一个好习惯。

本篇小结

这一篇我们把按键从"主循环扫描"升级到了"外部中断触发"。

你现在至少应该知道:

  • EXTI 是外部中断/事件线,用来捕捉 GPIO 电平变化;

  • 低电平有效按键通常选 Falling Edge;

  • 高电平有效按键通常选 Rising Edge;

  • CubeMX 里除了 GPIO_EXTI,还要打开 NVIC;

  • EXTIx_IRQHandler() 是中断入口;

  • HAL_GPIO_EXTI_Callback() 是 HAL 给用户留的回调;

  • 中断里尽量只记录事件,主循环里再处理业务;

  • 机械按键换成中断以后仍然需要消抖。

下一篇我们开始进入串口:

STM32 USART 串口打印:从 CubeMX 配置到 printf 重定向。

串口是后面调试所有外设的基础。有了串口输出,很多问题就不用靠猜了。

相关推荐
大卡片9 小时前
GPIO控制器原理
单片机·嵌入式硬件
余生皆假期-9 小时前
J-link Commander 命令操作 MCU 连接、调试、烧录、擦除等
单片机·嵌入式硬件
lingzhilab9 小时前
零知派ESP32——ULN2003AN驱动28BYJ-48步进电机控制系统
单片机·嵌入式硬件
╰ㄣ浮华若梦︶ _10 小时前
51单片机的SPI协议
单片机·嵌入式硬件·51单片机·8051·spi协议
NPE~10 小时前
[嵌入式]嵌入式在线仿真平台 —— Wokwi 入门指南
stm32·嵌入式·esp32·教程·平台
崇山峻岭之间10 小时前
单片机按键实验
单片机·嵌入式硬件
踏着七彩祥云的小丑10 小时前
嵌入式测试学习第 16 天:复位电路、电源电路基础原理
单片机·嵌入式硬件
小手智联老徐10 小时前
Arduino IDE环境搭建与点亮ESP32 D1板载LED
嵌入式硬件·esp32·arduino
坤坤藤椒牛肉面11 小时前
stm32学习1--新建工程
stm32·单片机·学习