
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- 一、中断基础
- [二、NVIC 与优先级](#二、NVIC 与优先级)
- [三、外部中断 EXTI](#三、外部中断 EXTI)
- [四、volatile 与内核异常](#四、volatile 与内核异常)
- [五、优先级案例与 HAL_Delay 原理](#五、优先级案例与 HAL_Delay 原理)
- 全文总结(一张图看懂整节课)
一、中断基础
1.1 什么是中断
生活中的中断:你正在写作业(主程序),突然电话响了(中断事件),你放下笔去接电话(中断服务程序 ISR),接完继续写作业(返回主程序)。

专业定义:
中断是计算机系统中,由硬件或软件触发的、暂停当前正在执行的主程序流程,转而执行特定处理程序(中断服务例程,ISR)以响应紧急事件,并在完成后自动恢复原程序执行的机制。
关键词:暂停、响应、恢复。
1.2 STM32 中中断的三种来源

在 STM32 中,中断分为三类:
- 内核异常/系统异常:ARM Cortex 内核内部产生的紧急事件(地址访问错误、栈溢出、非法指令等)。
- 外设中断:MCU 片上外设产生的事件(USART、I2C、SPI 可读可写等)。
- 外部中断:通过 GPIO 或其他方式,从外部引入 MCU 的事件。
由于内核异常和中断的处理流程一致,通常把它们统称为"中断"。
1.3 中断向量表:一个函数指针数组
在 STM32 工程里,除了 C 文件,还有汇编文件。C 和汇编最终都会被编译成 .o 文件,然后链接成可执行程序。真正烧录的是 .hex 文件。
关键问题:CPU 收到中断后,怎么知道该执行哪个函数?答案是中断向量表。
中断向量表定义在 startup_stm32f103xe.s 中:
asm
_Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位处理函数
DCD NMI_Handler ; 不可屏蔽中断
DCD HardFault_Handler ; 硬件错误
DCD MemManage_Handler ; 内存管理错误
DCD BusFault_Handler ; 总线错误
DCD UsageFault_Handler ; 用法错误
DCD 0 ; 保留
...
DCD SysTick_Handler ; 系统滴答定时器
; 外部中断
DCD WWDG_IRQHandler ; 窗口看门狗
DCD EXTI0_IRQHandler ; 外部中断线 0
...
DCD USART1_IRQHandler ; USART1
DCD DMA1_Channel5_IRQHandler ; DMA1 通道 5
从 C 语言视角看,它就是一个函数指针数组:
c
typedef void (*func_t)();
func_t int_table[N]; // 每个元素是一个中断处理函数的地址
核心结论:中断向量表的本质就是一个函数指针数组。 编译成功的 .hex 会把中断向量表放在最开头,烧写到 Flash 的起始地址 0x08000000。所以:
中断处理函数地址 = 0x08000000 + (中断编号) × 4
要执行中断处理函数,分两步:①找到中断向量表(固定地址 0x08000000);②根据中断编号索引到具体的函数。
1.4 软件生态:CMSIS 库、HAL 库、固件库

Cortex-M3 内核内部也有可以被软件配置的硬件模块,最典型的就是中断管理模块 NVIC。问题是:这些内核寄存器由谁来定义、怎么访问?
- ARM 提供技术手册 (如《Cortex-M3 技术参考手册》),告诉工程师内核寄存器的地址和用法;
- 但每家芯片公司都自己写一套访问代码,成本太高。于是 ARM 直接提供了 CMSIS 库(Cortex Microcontroller Software Interface Standard,Cortex 微控制器软件接口标准),里面封装了内核(含 NVIC)的访问代码;
- 意法半导体拿到 ARM 内核授权后设计自己的 MCU,为了便于访问片上外设,做了 HAL 库;
- 最后把 HAL 库 + CMSIS 库 打包,就形成了我们使用的固件库。
所以我们的工程代码来源:应用层(我们写的)+ HAL 库(意法)+ CMSIS 库(ARM)。

1.5 中断号(IRQn)
中断是发给 CPU 的,处理也由 CPU 完成。但中断来源既有内核(ARM),又有外设/引脚(意法),所以需要两家共同约定。ARM 提供了 15 个异常 + 240 个中断 的最大方案,并给每种中断分配了异常编号。
重要结论:
中断向量表中的方法下标,和异常编号是一一对应的!
在 HAL 库中,意法用枚举 IRQn_Type 定义了中断号:
c
typedef enum
{
/* Cortex-M3 内核异常,编号为负数 */
NonMaskableInt_IRQn = -14, // 不可屏蔽中断
HardFault_IRQn = -13, // 硬件错误
MemoryManagement_IRQn = -12, // 内存管理错误
BusFault_IRQn = -11, // 总线错误
UsageFault_IRQn = -10, // 用法错误
SVCall_IRQn = -5, // 系统服务调用
DebugMonitor_IRQn = -4, // 调试监控
PendSV_IRQn = -2, // 可挂起系统服务
SysTick_IRQn = -1, // 系统滴答
/* STM32 外设中断,编号为非负数 */
WWDG_IRQn = 0, // 窗口看门狗
PVD_IRQn = 1,
...
EXTI0_IRQn = 6, // 外部中断线 0
...
DMA1_Channel5_IRQn = 15, // DMA1 通道 5
...
SPI1_IRQn = 35,
USART1_IRQn = 37,
...
TIM6_IRQn = 54, // 基础定时器 6
TIM7_IRQn = 55, // 基础定时器 7
...
} IRQn_Type;
用负数区分"内核异常"和"外设/外部中断"。
关键换算关系:
中断向量表索引下标 = ARM 异常编号 = HAL 中断号 + 16
中断处理函数地址 = 0x08000000 + (HAL 中断号 + 16) × 4
这个 +16 的转换由硬件自动完成。
为什么没有 Reset 的中断号?因为 Reset 处理函数是固定的,不建议用户修改,所以 HAL 里没有提供它的 IRQn,也没有生成可修改的 Reset 处理函数。
"保留(Reserved)"是计算机中常见的做法:现在不用,先占位,方便未来扩展,避免新增异常导致所有偏移量改变。
知识点总结 ①
中断基础:概念、来源、向量表与中断号:
中断是硬件或软件触发的机制:CPU 暂停当前主程序,转去执行中断服务程序(ISR),处理完自动返回。STM32 中断来源分三类:内核异常、外设中断、外部中断,因处理流程一致而统称中断。中断向量表存放在 Flash 起始地址 0x08000000,本质是函数指针数组,表项顺序与中断编号一一对应,CPU 用「编号 × 4」索引并跳转。ARM 提供 CMSIS 库封装内核(含 NVIC)访问,ST 提供 HAL 库访问外设,两者合成固件库。HAL 用枚举 IRQn 定义中断号,内核异常为负(如 SysTick=-1)、外设中断为非负(如 USART1=37)。关键换算:向量表下标 = HAL 中断号 + 16 = ARM 异常编号,处理函数地址 = 0x08000000 + 下标 × 4。
二、NVIC 与优先级
2.1 NVIC:嵌套向量中断控制器
NVIC(Nested Vectored Interrupt Controller,嵌套向量中断控制器)是 ARM Cortex 处理器内部的一个模块,负责中断的管理和处理。
所有中断都是发给 CPU 的,严格说是发给 CPU 内的 NVIC,由它统一处理,而中断服务程序由 CPU 执行。
NVIC 的四大职责:
- 中断开关控制:使能/禁用某个中断;
- 中断响应:根据中断编号找到处理函数并跳转;
- 中断嵌套:允许高优先级中断打断低优先级中断;
- 中断优先级:存储和设置每个中断的优先级。
ARM 没有公开 NVIC 内部结构,但提供了寄存器供我们配置。NVIC 的寄存器基地址是 0xE000E100:
c
#define NVIC ((NVIC_Type*)NVIC_BASE)
#define NVIC_BASE (SCS_BASE + 0x0100UL)
#define SCS_BASE (0xE000E000UL)
NVIC 寄存器结构体(来自 CMSIS 的 core_cm3.h):
c
typedef struct
{
__IOM uint32_t ISER[8U]; // 中断使能寄存器(置位使能)
uint32_t RESERVED0[24U];
__IOM uint32_t ICER[8U]; // 中断禁用寄存器(置位禁用)
uint32_t RESERVED1[24U];
__IOM uint32_t ISPR[8U]; // 中断挂起置位寄存器
uint32_t RESERVED2[24U];
__IOM uint32_t ICPR[8U]; // 中断挂起清除寄存器
uint32_t RESERVED3[24U];
__IOM uint32_t IABR[8U]; // 中断活动状态寄存器(只读)
uint32_t RESERVED4[56U];
__IOM uint8_t IP[240U]; // 中断优先级寄存器(8 位宽)
uint32_t RESERVED5[644U];
__OM uint32_t STIR; // 软件触发中断寄存器
} NVIC_Type;
2.2 中断嵌套
中断嵌套就是:在处理一个中断的过程中,又来了一个新的、更高优先级的中断,于是暂停当前中断,先处理新中断,处理完再回来。

多个中断的嵌套处理,由 NVIC 负责。
什么叫"向量"?向量就是"有方向的量"。对 NVIC 来说,一个重要任务是"根据中断编号找到中断处理函数",这个查找过程通过"中断向量表"实现,它维护了"中断编号 → 中断处理函数"的映射关系。
2.3 中断优先级与分组(重点)
多个中断同时发生时,有轻重缓急,这就需要中断优先级。STM32 的优先级分两种:
- 抢占优先级(pre-emption priority) :决定能不能"打断"正在执行的中断。本质是中断嵌套权。
- 响应优先级(sub-emption priority,子优先级) :只在两个中断同时发生且抢占优先级相同时,决定谁先执行。本质是排队顺序权。
规则:数值越小,优先级越高。(像海底捞取号,号越小越先叫。)
两种情况:

情况一:两个中断同时发生
- 先比抢占优先级,数值小的先执行;
- 抢占相同,再比响应优先级,数值小的先执行;
- 两者都相同,则比中断编号,编号小的先执行(但通常认为这种顺序无所谓;要指定顺序,就一定要用不同的抢占/响应优先级区分)。
情况二:一个中断正在处理,另一个中断突然到来
- 只比抢占优先级,和响应优先级无关;
- 如果新中断抢占优先级更高,则打断当前中断;否则不能打断(即使响应优先级更高也不行)。
一句话:抢占优先级是核心,响应优先级只在"同时到达"时起辅助作用。实际开发中大部分情况只用抢占优先级就够了。
优先级用 4 个有效位(bit7:4)表示,被划分为"抢占位数 + 响应位数",这就是优先级分组。一共 5 组:
| 分组 | 抢占优先级位数 | 抢占可填范围 | 响应优先级位数 | 响应可填范围 |
|---|---|---|---|---|
| 组 0 | 0 位 | 不能配抢占 | 4 位 | 0~15 |
| 组 1 | 1 位 | 0~1 | 3 位 | 0~7 |
| 组 2 | 2 位 | 0~3 | 2 位 | 0~3 |
| 组 3 | 3 位 | 0~7 | 1 位 | 0~1 |
| 组 4 | 4 位 | 0~15 | 0 位 | 不能配子优先 |
分组由 AIRCR 寄存器的 PRIGROUP 字段控制:
011:4 位抢占,0 位响应(组 4)100:3 位抢占,1 位响应(组 3)101:2 位抢占,2 位响应(组 2)110:1 位抢占,3 位响应(组 1)111:0 位抢占,4 位响应(组 0)
为什么 NVIC_IPRx 要"浪费 4 bit"?因为 Cortex-M 内核不只供应给 ST 一家,其他厂商可能用更多位实现更多优先级。STM32F1 只用了 4 bit。
2.4 NVIC 内部寄存器详解
关于NVIC的详细说明参考:STM32F10xx Cortex-M3编程手册
中断使能/禁用寄存器 ISER/ICER(位图,1 个 bit 对应 1 个中断):
- ISER:某 bit 写 1 表示使能该中断,写 0 无效;
- ICER:某 bit 写 1 表示禁用该中断,写 0 无效;
- 写 ISER 不会清空 ICER,反之亦然;
- 不能同时把 ISER 和 ICER 的对应 bit 写 1(像同时踩油门和刹车,行为不确定)。


为什么是 uint32_t[8]?因为 ARM 最多支持 240 个中断,32×8=256 才能覆盖,32×7=224 不够。
挂起状态管理寄存器 ISPR/ICPR:
- 挂起状态表示"中断已触发,但尚未被 CPU 响应";
- 外设产生中断时,NVIC 自动把对应挂起标志置 1;CPU 执行完 ISR 后硬件自动清除。


中断活动状态寄存器 IABR(只读):
- bit=1 表示该中断当前正在执行中;bit=0 表示没在执行;
- 反映 CPU 真实的执行状态,软件无法修改。

中断优先级寄存器 IP:
- 每个中断占 8 位,但实际只有高 4 位(bit7:4)有效,低 4 位固定为 0;
- 只控制外设中断和外部引脚中断的优先级,系统异常用另一组寄存器(SHPR)。

2.5 系统异常处理(轻度了解)
系统异常的优先级通过 SCB_SHPRx 寄存器定义,规则和 IPRx 类似。SHPR 有三个,每个系统异常也分配 4 个 bit,异常分组同样由 AIRCR 决定。
记住一点:异常处理的流程和中断处理的流程是一样的,只是我们很少配置异常。SCB_SHPRx 与 NVIC 无关,NVIC 主要负责外部/外设中断。

知识点总结 ②
NVIC、中断嵌套与优先级:
NVIC 是 Cortex-M 内核内部的中断管理模块,职责包括中断使能/禁用、根据编号查找并跳转处理函数、支持中断嵌套、保存和设置优先级,寄存器基地址 0xE000E100。中断嵌套指高优先级中断打断低优先级中断的处理。STM32 优先级分抢占优先级和响应(子)优先级,数值越小越高;抢占优先级决定能否打断(嵌套权),响应优先级只在同时到达且抢占相同时决定顺序(排队权)。判断规则:同时到达先比抢占再比响应最后比编号;正在处理时突发新中断只比抢占。优先级用 4 个有效位表示,通过 AIRCR 的 PRIGROUP 分成 5 组(抢占:响应 = 0:4、1:3、2:2、3:1、4:0)。NVIC 关键寄存器:ISER/ICER(使能/禁用)、ISPR/ICPR(挂起/清除)、IABR(活动状态,只读)、IP(优先级,仅高 4 位有效)。系统异常优先级用 SCB_SHPRx 配置,流程与中断一致。
三、外部中断 EXTI
3.1 EXTI:外部中断/事件控制器
外部中断由专门的片上外设 EXTI(External Interrupt/Event Controller) 管理。为什么需要它?因为 GPIO 引脚太多,如果每个都直连内核,中断线会过多;而且还需要处理"上升沿还是下降沿触发""屏蔽某个引脚"等问题。
GPIO 内部有一条"中断信号输入"线路,把引脚电平变化传给 EXTI。EXTI 的结构(简化后):
GPIO 引脚 → 边沿检测电路 → 或门 → 请求挂起寄存器(PR) → 与门 → NVIC
↑ ↑
软件中断事件寄存器 中断屏蔽寄存器(IMR)

20 根 EXTI 线:
- EXTI Line 0~15:对应 GPIO 的 0~15。比如 PA0、PB0、PC0、PD0 共用 EXTI Line 0(同编号引脚共用一根线);
- EXTI Line 16:电源监测中断;
- EXTI Line 17:RTC 闹钟/入侵/时间戳中断;
- EXTI Line 18:USB 唤醒中断;
- EXTI Line 19:以太网唤醒中断(无以太网模块则没有)。
逐级拆解:
-
边沿检测电路:决定"什么样的电平变化才触发"。上升沿触发(低→高)、下降沿触发(高→低)、双边沿触发。由 RTSR(上升沿触发选择寄存器)和 FTSR(下降沿触发选择寄存器)配置,各 20 位对应 20 根线。
-
软件中断事件寄存器:接到或门。或门有一个输入为 1 就输出 1,所以即使没有边沿信号,通过软件写这个寄存器也能产生中断(软中断)。开发中很少用。
-
请求挂起寄存器 EXTI_PR:挂起 = "先等下,马上处理"。某 bit 被硬件置 1 表示发生了中断信号;软件写 1 清零(W1C,write 1 to clear),这样清零时不必担心影响其他位。
-
中断屏蔽寄存器 EXTI_IMR:接到与门。与门两个输入都为 1 才输出 1。某 bit 设为 0 表示屏蔽该中断,设为 1 表示开放。通过 IMR 后,中断信号离开 EXTI,进入 NVIC。
-
EXTI 与 NVIC 结合:EXTI Line 0~4 分别对应 NVIC 中断号 6~10;EXTI Line 5~9 共用中断号 23(EXTI9_5);EXTI Line 10~15 共用中断号 40(EXTI15_10)。
细节:给 GPIO 用的 EXTI 线共 16 根,每根对应 PAx~PGx 同编号 7 个引脚,理论上能管理 112 个 GPIO。因为 EXTI9_5 和 EXTI15_10 共享 NVIC 中断线,所以 112 个 GPIO 最终只占 NVIC 的 7 根中断线,有效防止中断线被过度占用。CubeMX 不允许同编号引脚同时用同一根 EXTI 线(硬件防呆)。
3.2 EXTI 寄存器详解
EXTI 是片上外设,和 GPIO、USART 一样,通过一组寄存器来配置。上面流程图里每个环节都对应一个(或一组)寄存器。下面按信号流向逐个说明,重点是 AFIO_EXTICR、RTSR/FTSR、PR、IMR。
① AFIO_EXTICR:选择"哪个 GPIO 端口"接到这根 EXTI 线(最容易被忽略的关键寄存器)

EXTI Line 0~15 每根线都对应 PAx~PGx 同编号的 7 个引脚(如 PA0、PB0、...、PG0 共用 Line 0),那具体让哪个端口的引脚生效?就是由 AFIO_EXTICR 决定。
- AFIO 全称 Alternate Function I/O,是"复用功能 I/O"配置模块;EXTI 的引脚映射属于它的职责,所以使用 EXTI 前要先开 AFIO 时钟:
__HAL_RCC_AFIO_CLK_ENABLE()。 - AFIO 提供 4 个寄存器
AFIO->EXTICR[0] ~ EXTICR[3],每个 32 位,管理 4 根 EXTI 线;每根线占 4 个 bit:EXTICR[0]:Line 0~3EXTICR[1]:Line 4~7EXTICR[2]:Line 8~11EXTICR[3]:Line 12~15
- 每根线那 4 个 bit 的取值表示端口:
0000=PA、0001=PB、0010=PC、0011=PD、0100=PE、0101=PF、0110=PG。
例:把 PG10 接到 EXTI Line 10,就是 EXTICR[2](10 在 8~11 范围)的第 2 组 4 bit 写成 0110。HAL 库用 GPIO_GET_INDEX(GPIOx) 得到端口编号,再左移到对应位置完成写入。
② RTSR / FTSR:配置触发边沿

- RTSR(Rising trigger selection register,上升沿触发选择寄存器):20 位有效,bitn 对应 Line n。bitn=1 表示 Line n 上升沿触发。
- FTSR(Falling trigger selection register,下降沿触发选择寄存器):20 位有效,bitn=1 表示 Line n 下降沿触发。
- 两个寄存器同一 bit 同时置 1,就是"双边沿触发"(上升沿、下降沿都触发)。
- 边沿含义:上升沿 = 低电平变高电平(对应按键松开);下降沿 = 高电平变低电平(对应按键按下)。
③ SWIER:软件中断事件寄存器

接到边沿检测电路之后的"或门"另一路输入。或门只要有一路为 1 就输出 1,所以即使没有边沿信号,软件写 SWIER 也能人为制造一个"中断信号"------这就是"软中断"。开发中很少用。
④ EXTI_PR:请求挂起寄存器(Pending)

- 20 位有效,bitn 对应 Line n。
- bit 被硬件置 1:表示该线发生了中断信号;
- 软件写 1 清零:表示"已经在处理这个中断了"。
- 清零方式是 W1C(write 1 to clear,写 1 清零)。好处:想清某一位时,直接把读到的值写回即可,不会误伤其他位。
⑤ EXTI_IMR:中断屏蔽寄存器(Mask)

- 20 位有效,bitn 对应 Line n。
- 接到"与门"的另一路输入。与门要两个输入都为 1 才输出 1:
- bit=0:屏蔽该中断(给与门输入 0,输出恒为 0,信号被挡下);
- bit=1:开放该中断(输出由边沿/挂起那一路决定)。
- 通过 IMR 之后,中断信号离开 EXTI,进入内核的 NVIC。
3.3 代码案例:外部中断处理
目标:LED4(PF8)每 2 秒翻转一次;LED5(PF9)每次按下按钮翻转一次。
版本 1(轮询,有缺陷):
c
while (1)
{
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8); // LED4 翻转
HAL_Delay(2000); // 阻塞 2 秒
// 轮询检测按键 PG10
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
HAL_Delay(20); // 消抖
if (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_9);
while (HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
HAL_Delay(1);
}
}
现象:LED4 正常闪烁,但按一下按钮 LED5 基本不切换;只有一直按住按钮才有效。
原因:HAL_Delay(2000) 阻塞时间太长。按下按键的那一瞬间,程序大概率正在 Delay,等程序回来读引脚时,手已经松开了。
版本 2(中断方式):
把 PG10 配置为"外部中断下降沿触发",使能 EXTI 中断。CubeMX 生成的初始化代码:
c
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOF_CLK_ENABLE();
__HAL_RCC_GPIOG_CLK_ENABLE();
// PF8/PF9 配置为推挽输出,默认高电平
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8 | GPIO_PIN_9, GPIO_PIN_SET);
GPIO_InitStruct.Pin = GPIO_PIN_8 | GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOF, &GPIO_InitStruct);
// PG10 配置为下降沿触发的外部中断
GPIO_InitStruct.Pin = GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿触发
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); // 内部会配置 EXTI 触发模式
// 配置并使能 EXTI15_10 中断(中断号 40)
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}
主循环只保留 LED4 闪烁:
c
while (1)
{
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
HAL_Delay(2000);
}
中断回调里切换 LED5:
c
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_10)
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_9);
}
调用链 :按下按钮 → 触发 EXTI15_10 中断 → NVIC → 执行 EXTI15_10_IRQHandler → HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_10) → 判定 EXTI->PR → 清除标志 → HAL_GPIO_EXTI_Callback(GPIO_PIN_10)(用户重写)。
c
// 中断服务函数(CubeMX 生成)
void EXTI15_10_IRQHandler(void)
{
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_10);
}
// HAL 通用处理:先判断、清标志,再回调用户函数
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u) // 判断 PR 是否置位
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); // 清标志(写 1 清零)
HAL_GPIO_EXTI_Callback(GPIO_Pin); // 调用用户回调
}
}
类比:医院护士处理呼唤铃------患者按铃(中断)→ 护士赶来(ISR)→ 确认是这个患者的铃(查 PR)→ 关铃(清 PR)→ 换药(执行用户回调)。
HAL_GPIO_EXTI_Callback 本身是 __weak 修饰的空函数,用户重写即可。
3.4 使能 EXTI 源码解析(HAL 库内部做了什么)
前面 MX_GPIO_Init() 里只是把 GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING 交给 HAL_GPIO_Init(),EXTI 的寄存器其实是在 HAL_GPIO_Init() 内部被配置的。核心逻辑如下:
c
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
#define GPIO_MODE_IT_FALLING 0x10210000u
/*--------------------- EXTI Mode Configuration ------------------------*/
if ((GPIO_Init->Mode & EXTI_MODE) == EXTI_MODE)
{
/* 1. 打开 AFIO 时钟(EXTI 引脚映射要用) */
__HAL_RCC_AFIO_CLK_ENABLE();
/* 2. 配置 AFIO->EXTICR:选择哪根 EXTI 线接到哪个 GPIO 端口 */
temp = AFIO->EXTICR[position >> 2u]; // 定位到 EXTICR[0..3]
CLEAR_BIT(temp, (0x0Fu) << (4u * (position & 0x03u))); // 先清掉该线原来的 4 bit
SET_BIT(temp, (GPIO_GET_INDEX(GPIOx)) << (4u * (position & 0x03u))); // 写入端口编号
AFIO->EXTICR[position >> 2u] = temp;
/* 3. 配置上升沿触发(RTSR) */
if ((GPIO_Init->Mode & RISING_EDGE) == RISING_EDGE)
SET_BIT(EXTI->RTSR, iocurrent);
else
CLEAR_BIT(EXTI->RTSR, iocurrent);
/* 4. 配置下降沿触发(FTSR) */
if ((GPIO_Init->Mode & FALLING_EDGE) == FALLING_EDGE)
SET_BIT(EXTI->FTSR, iocurrent);
else
CLEAR_BIT(EXTI->FTSR, iocurrent);
/* 5. 配置中断屏蔽(IMR) */
if ((GPIO_Init->Mode & GPIO_MODE_IT) == GPIO_MODE_IT)
SET_BIT(EXTI->IMR, iocurrent);
else
CLEAR_BIT(EXTI->IMR, iocurrent);
}
#define EXTI_MODE 0x10000000u
#define FALLING_EDGE 0x00200000u
#define GPIO_MODE_IT 0x00010000u
要点:
position是引脚编号(如 PG10 的 position=10);position >> 2等价position / 4,用来选EXTICR[0..3];position & 0x03等价position % 4,用来选该寄存器内第几个 4 bit 段。iocurrent是当前这根线对应的位掩码(即1 << position)。GPIO_MODE_IT_FALLING = 0x10210000u其实是一个"位图标志":0x10000000(EXTI_MODE)、0x00200000(FALLING_EDGE)、0x00010000(GPIO_MODE_IT) 这些位同时存在。HAL 用&逐位判断,分别决定去设置 AFIO_EXTICR、RTSR、FTSR、IMR 中的哪一个。
3.5 EXTI 补充细节
① EXTI 中英文手册区别
翻阅参考手册时要注意:英文版和中文版在 EXTI 结构框图上有两处差异:
- 线数量:英文版标注的是 19,中文版是 20;
- 信号顺序:英文版先经过"中断屏蔽寄存器",再经过"请求挂起寄存器";中文版是先"请求挂起寄存器"、后"中断屏蔽寄存器"。
中文手册相对英文手册内容滞后,但凡两者不一致,以英文手册为准。英文版的 19 根线,是针对"没有以太网模块"的设备。
② 关于"事件"(Event)
EXTI 全称是"外部中断/事件控制器",除了"中断"还有"事件"。
- 事件也是硬件检测到的信号(比如 GPIO 电平变化),表示出现了某个"突发状况";
- 但事件的处理不需要 CPU 参与,也不执行中断处理函数;
- 而是把这个信号直接传给另一个外设,由对应硬件处理。
事件的具体用法(比如配合 DMA、ADC)会在后续相关外设章节展开。
知识点总结 ③
EXTI 外部中断、寄存器与处理流程:
EXTI 是专门管理外部中断/事件的片上外设。引脚先经 AFIO 映射:AFIO->EXTICR0...3 每个 32 位管 4 根线、每线 4 bit 选择端口(A=0000...G=0110),使用前需开 AFIO 时钟。GPIO 电平变化进入 EXTI 后依次经过:边沿检测电路(RTSR/FTSR 各 20 位,配置上升沿/下降沿/双边沿)→ 或门(另一路为软件中断事件寄存器 SWIER,可软中断)→ 请求挂起寄存器 PR(20 位,硬件置 1 表示已发生,软件写 1 清零 W1C)→ 与门(另一路为中断屏蔽寄存器 IMR,0 屏蔽 1 开放)→ 进入 NVIC。EXTI 共 20 根线:Line0~15 对应 GPIO 015(同编号引脚共用一根),Line1619 为电源/RTC/USB/以太网;Line0~4 对应中断号 610,Line59 共用 23,Line10~15 共用 40。HAL 在 HAL_GPIO_Init() 内部通过 GPIO_MODE_IT_FALLING 等位图标志(EXTI_MODE=0x10000000、FALLING_EDGE=0x00200000、GPIO_MODE_IT=0x00010000)按位判断,分别写 AFIO_EXTICR、RTSR/FTSR、IMR。完整处理流程:配置引脚为中断模式 → 触发时硬件置位 EXTI->PR → NVIC 跳转到中断服务函数(如 EXTI15_10_IRQHandler)→ 调用 HAL_GPIO_EXTI_IRQHandler → 判断 PR、写 1 清零、调用用户回调 HAL_GPIO_EXTI_Callback。中断相比轮询,响应不受主循环阻塞影响。此外注意中英文手册差异(19/20 根线、IMR/PR 顺序,以英文版为准),以及"事件"路径不需 CPU 参与、直接交由其它外设处理。
四、volatile 与内核异常
4.1 volatile:中断与主循环通信的必修课
版本 3:为了代码健壮性,我们不在中断里做业务,而是"中断置标志位,主循环处理"。
c
volatile int led_switch = 0; // 标志位:1 表示要切换 LED
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_10)
led_switch = 1; // 中断里只置标志
}
while (1)
{
if (led_switch == 1)
{
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_9);
led_switch = 0;
}
}
如果不加 volatile,程序会失灵! 原因在于编译器优化:
编译器发现主循环里 led_switch 没有别的代码修改,就"自作聪明"地把"每次从 SRAM 读 led_switch"优化成"只读一次,缓存到 CPU 寄存器",后续都用寄存器里的旧值。但中断里其实会改它,编译器不知道。于是主循环永远看到旧值,感受不到变化。
类比:你天天去楼下餐馆,老板熟悉你了,你还没开口他就下单炸酱面。有一天你想换油泼面,老板却还是给你下炸酱面------这就是"惯性思维",也就是缓存旧值。
volatile 的意思是"易变的",告诉编译器:这个变量随时可能被改变,每次都必须重新从内存读取,不许优化。
优化触发是有条件的:如果把修改变量的操作也放在循环里,编译器容易感知变化,就不会优化;如果循环里有
HAL_Delay(开销大),优化掉读 SRAM 也没多大收益,也可能不优化。但为保险起见,涉及"可能被中断修改"的变量,一定要加volatile。
4.2 内核异常案例:HardFault
HardFault(硬件失效)是最需要关注的系统异常,下列情况会触发:
- 通过指针访问违规地址(比如访问 0x0);
- 栈溢出;
- 执行非法指令。
HardFault_Handler 在 stm32f1xx_it.c 中定义,默认是死循环:
c
void HardFault_Handler(void)
{
while (1)
{
// 默认死循环,等看门狗复位
}
}
默认死循环的目的是:让 MCU 被看门狗咬死从而复位。很多系统异常是概率性触发的,复位后大概率能恢复。
案例:构造一个空指针解引用,触发 HardFault,并在处理函数里点亮 LED(当"故障灯")。
c
// main 函数中,主循环前
HAL_Delay(2000);
volatile uint32_t *p = (uint32_t *)0x00000000; // 指向非法地址
*p = 0xDEADBEEF; // 解引用,触发 HardFault
// stm32f1xx_it.c 中
void HardFault_Handler(void)
{
// 硬件异常时,把两个灯都点亮,作为故障指示
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_9, GPIO_PIN_RESET);
while (1)
{
}
}
实际开发中,还可以在 HardFault_Handler 里把调试信息(如出错寄存器)记录到串口或 Flash,方便定位问题。
知识点总结 ④
volatile 与 HardFault:
volatile告诉编译器变量是"易变的",禁止读取优化,保证每次访问都从内存重新读取。在中断与主循环共享变量的场景中必须使用:主循环读取标志位、中断修改标志位,编译器无法感知中断的修改,可能把读取优化成只读一次并缓存,导致主循环感知不到变化。凡可能被中断、DMA、硬件或其他执行流修改的变量都应加 volatile。HardFault 是内核硬件异常,在访问非法地址、栈溢出、执行非法指令时触发,触发后跳转到 HardFault_Handler,默认实现是死循环(等待看门狗复位)。用户可在其中点亮"故障灯"或记录调试日志,用于故障定位。
五、优先级案例与 HAL_Delay 原理
5.1 案例目标
按下按键产生中断,两个 LED 闪烁 5 次,间隔 500ms。
初版代码:
c
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_10)
{
for (int i = 0; i < 10; i++) // 10 次翻转 = 亮灭 5 次
{
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_9);
HAL_Delay(500);
}
}
}
现象:按下按键,LED 能亮,但闪不起来(卡死在中断里)。
5.2 HAL_Delay 原理
c
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick(); // 记录起始 tick
uint32_t wait = Delay;
if (wait < HAL_MAX_DELAY)
wait += (uint32_t)(uwTickFreq);
while ((HAL_GetTick() - tickstart) < wait) // 等到时间够
{
}
}
__weak uint32_t HAL_GetTick(void)
{
return uwTick; // 返回全局变量
}
__weak void HAL_IncTick(void)
{
uwTick += uwTickFreq; // 每毫秒 +1
}
// SysTick 中断服务函数,每 1ms 调用一次
void SysTick_Handler(void)
{
HAL_IncTick();
}
所以 HAL_Delay 的本质是:靠 SysTick 每毫秒中断一次,让 uwTick 自增,Delay 循环比较这个值来判断是否到时间。
5.3 优先级冲突与解决
问题来了:CubeMX 默认把 SysTick 优先级设为 15 (最低),EXTI 优先级设为 0(最高)。于是:
- 在 EXTI 中断里调用
HAL_Delay,等待uwTick增加; - 但 SysTick 优先级比 EXTI 低,无法打断 EXTI;
- 于是
uwTick永远不变,HAL_Delay的 while 循环永远出不来 → 卡死。
解决办法 :调整优先级,让 SysTick 的优先级高于 EXTI。
- 把 System Tick 优先级改为 1;
- 把 EXTI 优先级改为 2(或任何比 SysTick 低的值)。
重新生成、编译、烧录,按下按键 LED 正常闪烁 5 次。
由此还能得出一条重要经验:不要在中断里做耗时操作(尤其是 HAL_Delay),更不要在中断里调用依赖其他中断的函数,否则极易因优先级问题卡死。
知识点总结 ⑤
HAL_Delay 原理与中断优先级冲突:
HAL_Delay 依赖 SysTick 中断实现:SysTick 每 1ms 触发一次中断,在中断里让全局变量 uwTick 自增;HAL_Delay 通过循环比较 uwTick 的变化判断延时是否结束。如果在某个中断(如 EXTI)里调用 HAL_Delay,而该中断优先级高于 SysTick,SysTick 就无法打断它、uwTick 无法增加,HAL_Delay 会永久卡死。解决办法是让 SysTick 的抢占优先级高于调用 HAL_Delay 的中断。由此得到的准则:中断服务函数中严禁执行耗时操作和依赖其他中断的函数(如 HAL_Delay),中断只做"事件通知",耗时逻辑放到主循环。
全文总结(一张图看懂整节课)
┌─────────────────────────────────────────────────────────────┐
│ 一、基础:中断=暂停→响应→恢复;来源=内核异常/外设/外部 │
│ 向量表(函数指针数组)@0x08000000;下标=IRQn+16 │
│ 二、NVIC:使能/响应/嵌套/优先级;IP 仅高4位有效 │
│ 抢占优先级(嵌套权) + 响应优先级(排队权),5种分组 │
│ 三、EXTI:边沿检测→挂起PR→屏蔽IMR→NVIC;Line0~15对应GPIO │
│ 流程:事件→EXTI→NVIC→ISR→HAL→用户回调 │
│ 四、volatile:中断与主循环共享变量必加;HardFault 故障灯 │
│ 五、HAL_Delay 依赖 SysTick;中断里调用会因优先级卡死 │
└─────────────────────────────────────────────────────────────┘
学完这一节,你应该能回答:
- 中断向量表的本质是什么,CPU 怎么找到处理函数?
- 抢占优先级和响应优先级有什么区别?
- 外部中断从引脚到回调,一共经过哪些环节?
- 为什么中断与主循环共享的变量要加
volatile?- 为什么中断里调用
HAL_Delay会卡死,怎么解决?