简介:这份资源面向STM32初学者与嵌入式入门开发者,围绕外部中断EXTI实现按键控制LED灯这一典型场景,帮助读者摆脱轮询方式带来的CPU资源浪费,建立中断响应式编程的基本思路。压缩包共185个文件,约4.75MB,以c源文件、h头文件、o目标文件与crf编译中间文件为主,另含axf、hex可执行与烧录文件、uvprojx与uvoptx工程配置、map映射文件及少量txt、htm说明文档,构成一套可直接用Keil打开编译的完整工程。资源中涵盖GPIO初始化、EXTI线触发方式配置、NVIC优先级设置与中断服务函数编写等关键环节,读者可对照代码理解下降沿触发、中断标志清除与LED状态翻转的实现逻辑,并在此基础上迁移到传感器信号检测、无线通信接收等场景。目前已有4933人学习下载,适合作为中断外设入门实践与工程模板参考。
1. 从一次按键抖动说起:EXTI 到底解决什么问题
去年帮一个做工业面板的朋友调板子,现象很怪:按键明明只按了一下,LED 却闪了三四次,串口日志里中断进了好几遍。查了半天不是电路问题,是主循环里HAL_Delay阻塞太久,加上按键没做消抖,轮询采样正好踩在抖动窗口上。后来把按键改成 EXTI 外部中断触发,主循环彻底解放出来,抖动在中断回调里用时间戳过滤掉,问题当场消失。这就是 STM32 外部中断 EXTI 最典型的落地场景:把「等一个电平变化」这件事从 CPU 的轮询里摘出来,交给硬件边沿检测器去盯,CPU 只在真正发生跳变时才被打断。这篇笔记围绕一个最小可跑的项目——按键接 EXTI 线,中断里翻转 LED——把工程结构、寄存器与 HAL 两层实现、CubeMX 配置、常见翻车点全部拆开讲,适合刚上手 STM32 中断、想把这套机制吃透再往编码器、霍尔信号、红外接收上迁移的开发者。
2. 工程骨架与中断链路:从按键引脚到 NVIC 的完整通路
2.1 项目目录与文件职责
这个项目我按「最小可复现」的原则组织,不引入 RTOS,不挂文件系统,纯裸机 + HAL 库,芯片以 STM32F103C8T6 这类常见型号为例(换 F4/G0 系列思路一致,只是时钟和库函数名略有差异)。目录结构大致如下:
KeyExtiLed/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── stm32f1xx_it.h // 中断服务函数声明 │ └── Src/ │ ├── main.c // 初始化 + 主循环 │ ├── stm32f1xx_it.c // EXTIx_IRQHandler 实体 │ ├── stm32f1xx_hal_msp.c // GPIO/NVIC 底层初始化 │ └── system_stm32f1xx.c ├── Drivers/ │ ├── CMSIS/ // 内核头文件 │ └── STM32F1xx_HAL_Driver/ // HAL 库源码 ├── KeyExtiLed.ioc // CubeMX 工程配置 └── MDK-ARM/ 或 STM32CubeIDE/ // 编译产物目录关键点在于stm32f1xx_it.c和stm32f1xx_hal_msp.c这两个文件的分工:前者放中断向量对应的入口函数,后者放 GPIO 时钟使能、引脚复用、NVIC 优先级配置这些「板级」初始化。很多人改代码时把 NVIC 配置写进main.c,结果 CubeMX 重新生成一次就被覆盖,血泪经验就是——凡是 CubeMX 管的文件,手写代码只往USER CODE BEGIN/END区块里塞。
2.2 EXTI 的信号链路
一条按键信号从引脚到 CPU 要经过四道关,理解这条链路,后面调不通时才知道该在哪一环下探:
| 环节 | 作用 | 对应配置 |
|---|---|---|
| GPIO 输入 | 引脚设为输入,决定上下拉 | GPIO_MODE_IT_FALLING等 |
| AFIO/EXTI 复用 | 把某个 Port 的 Pin 映射到 EXTI 线 | __HAL_AFIO_REMAP/ SYSCFG |
| EXTI 边沿检测 | 检测上升/下降沿,置挂起位 | EXTI->RTSR/FTSR |
| NVIC | 使能中断线、设优先级 | HAL_NVIC_SetPriority/EnableIRQ |
F1 系列有个容易忽略的点:EXTI 线号等于 Pin 号,PA0/PB0/PC0 共用 EXTI0,同一时刻只能选一个 Port 映射上去。所以如果你把按键接在 PB0,又想把另一个信号接 PA0 做中断,硬件上就冲突了,得换引脚。这是选型阶段就该确认的事,不是代码能救的。
2.3 CubeMX 里的关键勾选项
图形化配置能省掉大量寄存器查手册的时间,但几个勾选项必须心里有数。以按键接 PB0、下降沿触发、内部上拉为例:
- Pinout 里把 PB0 设为
GPIO_EXTI0,不是普通GPIO_Input; - GPIO 设置里 Pull-up/Pull-down 选
Pull-up,因为按键另一端接地,按下是低电平; - NVIC 标签页勾选
EXTI line0 interrupt,抢占优先级设 2、子优先级设 0(数值越小越优先,具体看你的系统里还有没有更急的中断); - 生成代码时勾选「Generate peripheral initialization as a pair of .c/.h files」,避免全塞进 main.c。
生成后MX_GPIO_Init()里会自动出现HAL_GPIO_Init和HAL_NVIC_EnableIRQ(EXTI0_IRQn),这就是链路里后两环的落地代码。
3. 核心代码拆解:HAL 回调与寄存器两套写法
3.1 HAL 库方式:初始化 + 回调
先看初始化部分,CubeMX 生成后大致长这样,我加了注释说明每个参数的意义:
void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // 使能 GPIOB 时钟,漏了这句引脚永远不动 __HAL_RCC_GPIOA_CLK_ENABLE(); // LED 在 PA5,也要使能 /* LED 引脚:推挽输出,初始高电平(假设低电平点亮) */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); GPIO_InitStruct.Pin = GPIO_PIN_5; 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); /* 按键引脚:中断下降沿触发,内部上拉 */ GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 关键:IT 表示中断模式 GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); /* NVIC 配置:抢占优先级 2,子优先级 0 */ HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn); }GPIO_MODE_IT_FALLING这个宏背后同时做了三件事:把引脚设为输入、打开 EXTI 的下降沿触发位、把引脚映射到 EXTI 线。参数Pull = GPIO_PULLUP决定空闲电平,如果你的按键电路是外部上拉、按下接地,这里也可以写GPIO_NOPULL,但内部上拉能省一颗电阻,调试阶段更稳。
中断入口在stm32f1xx_it.c,CubeMX 生成的是标准模板:
void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); // 清挂起位 + 调回调 }真正干活的是回调,写在main.c的 USER CODE 区:
/* 用户回调:HAL 在清完挂起位后调用 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { static uint32_t last_tick = 0; uint32_t now = HAL_GetTick(); if (now - last_tick < 20) { // 20ms 软件消抖窗口 return; } last_tick = now; HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // 翻转 LED } }逻辑说明:HAL_GPIO_EXTI_IRQHandler内部先判断挂起位,再调用HAL_GPIO_EXTI_Callback,最后清挂起位。所以回调里不需要手动清标志,但回调必须短——这里只做时间戳比较和一次 GPIO 翻转,耗时在微秒级。参数GPIO_Pin是位掩码,多根 EXTI 线共用一个回调时靠它区分,所以判断要用==而不是&(除非你确实想匹配多线)。
3.2 寄存器方式:看清 HAL 背后做了什么
想彻底搞懂,建议至少手写一遍寄存器版本。以 PB0 下降沿为例:
void EXTI0_Register_Init(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN; // GPIOB + AFIO 时钟 /* PB0 输入上拉:CNF=10, MODE=00 */ GPIOB->CRL &= ~(0xF << 0); GPIOB->CRL |= (0x8 << 0); /* 映射 EXTI0 到 PortB:AFIO_EXTICR1 低 4 位 = 0001 */ AFIO->EXTICR[0] &= ~0xF; AFIO->EXTICR[0] |= 0x1; EXTI->IMR |= EXTI_IMR_MR0; // 打开 EXTI0 中断屏蔽 EXTI->FTSR |= EXTI_FTSR_TR0; // 下降沿触发 EXTI->RTSR &= ~EXTI_RTSR_TR0; // 关掉上升沿,避免双边沿重复进 NVIC_SetPriority(EXTI0_IRQn, 2); NVIC_EnableIRQ(EXTI0_IRQn); }对照 HAL 版本看,GPIO_MODE_IT_FALLING展开后就是CRL的输入模式位 +AFIO->EXTICR映射 +EXTI->IMR/FTSR置位这一串操作。参数上,EXTICR[0]管 Pin0~3,EXTICR[1]管 Pin4~7,以此类推,每 4 位一个引脚,写错索引是新手最常见的坑之一。中断服务函数里则要手动EXTI->PR = EXTI_PR_PR0;清挂起位,写 1 清零,别写成&= ~。
3.3 主循环与中断的协作
主循环此时可以非常干净,只做和按键无关的事:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { /* 主循环做别的任务,比如刷新显示、跑状态机 */ HAL_Delay(100); } }注意HAL_Delay依赖 SysTick 中断,而 SysTick 优先级默认最低,EXTI 优先级设成 2 后能打断它,所以按键响应不会被HAL_Delay拖慢。这也是中断相对轮询的核心收益:响应延迟从「主循环一圈的时间」压缩到「中断延迟 + 回调耗时」,通常差一到两个数量级。
4. 避坑与排查:五个真实翻车现场
4.1 现象:中断只进一次,之后再也不触发
原因:回调里没清挂起位,或者用了寄存器方式但清错了位。HAL 的HAL_GPIO_EXTI_IRQHandler会自动清,但如果你自己写了EXTI0_IRQHandler又没调 HAL 函数,挂起位一直为 1,NVIC 认为中断没处理完,不会再次进入。解决:确认入口函数调用了HAL_GPIO_EXTI_IRQHandler,或寄存器方式下写EXTI->PR = EXTI_PR_PR0。
4.2 现象:一上电就疯狂进中断
原因:引脚浮空,电平随机跳变;或者触发边沿选错,按键松开时的上升沿也被当成有效事件。解决:输入引脚必须配内部或外部上下拉,别留GPIO_NOPULL悬空;单边沿触发就只开FTSR或RTSR一个,别两个都开。
4.3 现象:按键按一下,LED 翻转好几次
原因:机械按键抖动,5~20ms 内电平反复跳变,硬件边沿检测器照单全收。解决:软件消抖(如 3.1 里的时间戳窗口),或在引脚上并 0.1uF 电容做硬件滤波。注意消抖窗口别设太大,超过 50ms 会感觉按键迟钝。
4.4 现象:改了引脚,中断死活不进
原因:EXTI 线映射没跟着改。比如按键从 PB0 挪到 PB1,GPIO_MODE_IT_FALLING会帮你映射到 EXTI1,但如果你手写了AFIO->EXTICR还留着旧值,或者 NVIC 里使能的还是EXTI0_IRQn,就对不上。解决:引脚号 = EXTI 线号 = IRQn 编号,三者必须一致,改一处要同步改三处。
4.5 现象:中断里调HAL_Delay导致死机
原因:HAL_Delay依赖 SysTick 中断,而 EXTI 优先级高于 SysTick 时,在 EXTI 回调里等 SysTick 永远等不到,直接卡死。解决:中断回调里禁止任何阻塞调用,需要延时就用计数器或时间戳在主循环里处理。这条是中断编程的铁律,不只是这个项目。
5. 二次开发:把单按键扩成多路信号采集
单按键跑通只是起点,真实项目里往往要同时处理编码器 A/B 相、霍尔传感器、红外接收头好几路信号。我的改造思路是先把「中断回调」和「业务处理」解耦:回调里只记录事件(时间戳 + 线号)塞进一个环形缓冲区,主循环再取出来做消抖、状态机、业务动作。这样回调永远短,业务逻辑可以随便写,也不会因为某个处理慢而丢中断。
/* 环形缓冲区:中断写,主循环读 */ typedef struct { uint16_t pin; uint32_t tick; } ExtiEvent; static volatile ExtiEvent evt_buf[16]; static volatile uint8_t evt_head = 0, evt_tail = 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { uint8_t next = (evt_head + 1) % 16; if (next != evt_tail) { // 缓冲区未满才写,满了丢弃最旧事件 evt_buf[evt_head].pin = GPIO_Pin; evt_buf[evt_head].tick = HAL_GetTick(); evt_head = next; } } /* 主循环里消费 */ void ProcessExtiEvents(void) { while (evt_tail != evt_head) { ExtiEvent e = evt_buf[evt_tail]; evt_tail = (evt_tail + 1) % 16; /* 在这里做消抖判断、状态机迁移、业务动作 */ } }验证方法很直接:用逻辑分析仪或示波器抓按键引脚和 LED 引脚,按下一次应该只看到一次 LED 翻转;再快速连按十次,检查事件计数是否等于十,缓冲区有没有溢出。多路信号时,把每路的GPIO_Pin掩码和对应的处理函数做成表驱动,加一路信号只改表不改流程。
优先级分配上我一般这么排:编码器/霍尔这类高频信号给最高抢占优先级,按键给中等,串口接收给低,SysTick 垫底。改优先级后一定要回归测试,因为抢占关系一变,之前隐藏的时序问题可能冒出来。从那以后我每次加新中断源,都强制用示波器把「触发边沿 → 回调执行 → 业务响应」这条链路抓一遍,确认延迟在预期内才收工。希望这套拆法帮到你,把 EXTI 从「能跑」推到「敢用在量产板上」。
本文还有配套的精品资源,点击获取