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 是按"线号"来的。PA0、PB0、PC0 都属于 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() 临时保护事件变量,避免主循环读取时被中断打断。它属于"临界区"问题,本篇先按固定写法使用,后面会单独开一篇解释它的原理和使用边界。
这段代码有几个重点:
-
HAL_GPIO_EXTI_Callback()是 HAL 提供的外部中断回调函数。 -
CubeMX 生成的中断函数会先进入
EXTIx_IRQHandler()。 -
EXTIx_IRQHandler()里会调用HAL_GPIO_EXTI_IRQHandler(KEY_Pin)。 -
HAL 处理完中断标志后,会调用我们的
HAL_GPIO_EXTI_Callback()。 -
回调里不直接翻转 LED,只设置
s_key_pressed_event。 -
主循环通过
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_Pin 和 KEY_GPIO_Port
|
CubeMX GPIO 页面,标签设为 KEY
|
|
有效电平
|
回调里会再次确认当前是否真按下
| APP_KEY_PRESSED_LEVEL |
|
消抖时间
|
不同按键抖动时间不同
| APP_KEY_EXTI_DEBOUNCE_MS |
推荐移植顺序:
-
看原理图,确认按键接到哪个 MCU 引脚;
-
确认按下时是高电平还是低电平;
-
CubeMX 把该引脚设置成
GPIO_EXTI; -
按硬件选择 Rising 或 Falling Edge;
-
按硬件选择 Pull-up 或 Pull-down;
-
User Label 填
KEY; -
NVIC 勾选对应 EXTI line interrupt;
-
修改
APP_KEY_PRESSED_LEVEL; -
编译下载,用 LED 验证。
常见问题排查
1. 按键完全没反应
优先检查:
|
优先检查
|
具体方法
|
| --- | --- |
|
是否配置成 EXTI
|
CubeMX Pinout 里不是普通 GPIO_Input
|
|
NVIC 是否打开
| System Core -> NVIC
勾选对应 EXTI
|
|
触发边沿是否正确
|
低电平有效通常 Falling,高电平有效通常 Rising
|
|
User Label 是否为 KEY
| main.h
里应有 KEY_Pin 和 KEY_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_Pin 或 USER_KEY_Pin,就说明 User Label 不是 KEY。
解决方法:
-
回 CubeMX;
-
找到按键 GPIO;
-
把 User Label 改成
KEY; -
重新 Generate Code。
6. 短按偶尔没反应
优先检查:
-
触发边沿是否选对;
-
按键硬件是否接触不良;
-
消抖时间是否设置太长;
-
是否在别的地方长时间关闭中断;
-
是否在中断里做了太多耗时操作。
本篇代码没有在中断里做耗时动作,这是一个好习惯。
本篇小结
这一篇我们把按键从"主循环扫描"升级到了"外部中断触发"。
你现在至少应该知道:
-
EXTI 是外部中断/事件线,用来捕捉 GPIO 电平变化;
-
低电平有效按键通常选 Falling Edge;
-
高电平有效按键通常选 Rising Edge;
-
CubeMX 里除了 GPIO_EXTI,还要打开 NVIC;
-
EXTIx_IRQHandler()是中断入口; -
HAL_GPIO_EXTI_Callback()是 HAL 给用户留的回调; -
中断里尽量只记录事件,主循环里再处理业务;
-
机械按键换成中断以后仍然需要消抖。
下一篇我们开始进入串口:
STM32 USART 串口打印:从 CubeMX 配置到 printf 重定向。
串口是后面调试所有外设的基础。有了串口输出,很多问题就不用靠猜了。