【stm32】中断与异常

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


目录

  • 一、中断基础
  • [二、NVIC 与优先级](#二、NVIC 与优先级)
  • [三、外部中断 EXTI](#三、外部中断 EXTI)
  • [四、volatile 与内核异常](#四、volatile 与内核异常)
  • [五、优先级案例与 HAL_Delay 原理](#五、优先级案例与 HAL_Delay 原理)
  • 全文总结(一张图看懂整节课)

一、中断基础

1.1 什么是中断

生活中的中断:你正在写作业(主程序),突然电话响了(中断事件),你放下笔去接电话(中断服务程序 ISR),接完继续写作业(返回主程序)。

专业定义:

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

关键词:暂停、响应、恢复。

1.2 STM32 中中断的三种来源

在 STM32 中,中断分为三类:

  1. 内核异常/系统异常:ARM Cortex 内核内部产生的紧急事件(地址访问错误、栈溢出、非法指令等)。
  2. 外设中断:MCU 片上外设产生的事件(USART、I2C、SPI 可读可写等)。
  3. 外部中断:通过 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 的四大职责:

  1. 中断开关控制:使能/禁用某个中断;
  2. 中断响应:根据中断编号找到处理函数并跳转;
  3. 中断嵌套:允许高优先级中断打断低优先级中断;
  4. 中断优先级:存储和设置每个中断的优先级。

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,子优先级) :只在两个中断同时发生且抢占优先级相同时,决定谁先执行。本质是排队顺序权。

规则:数值越小,优先级越高。(像海底捞取号,号越小越先叫。)

两种情况:

情况一:两个中断同时发生

  1. 先比抢占优先级,数值小的先执行;
  2. 抢占相同,再比响应优先级,数值小的先执行;
  3. 两者都相同,则比中断编号,编号小的先执行(但通常认为这种顺序无所谓;要指定顺序,就一定要用不同的抢占/响应优先级区分)。

情况二:一个中断正在处理,另一个中断突然到来

  • 只比抢占优先级,和响应优先级无关;
  • 如果新中断抢占优先级更高,则打断当前中断;否则不能打断(即使响应优先级更高也不行)。

一句话:抢占优先级是核心,响应优先级只在"同时到达"时起辅助作用。实际开发中大部分情况只用抢占优先级就够了。

优先级用 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:以太网唤醒中断(无以太网模块则没有)。

逐级拆解:

  1. 边沿检测电路:决定"什么样的电平变化才触发"。上升沿触发(低→高)、下降沿触发(高→低)、双边沿触发。由 RTSR(上升沿触发选择寄存器)和 FTSR(下降沿触发选择寄存器)配置,各 20 位对应 20 根线。

  2. 软件中断事件寄存器:接到或门。或门有一个输入为 1 就输出 1,所以即使没有边沿信号,通过软件写这个寄存器也能产生中断(软中断)。开发中很少用。

  3. 请求挂起寄存器 EXTI_PR:挂起 = "先等下,马上处理"。某 bit 被硬件置 1 表示发生了中断信号;软件写 1 清零(W1C,write 1 to clear),这样清零时不必担心影响其他位。

  4. 中断屏蔽寄存器 EXTI_IMR:接到与门。与门两个输入都为 1 才输出 1。某 bit 设为 0 表示屏蔽该中断,设为 1 表示开放。通过 IMR 后,中断信号离开 EXTI,进入 NVIC。

  5. 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~3
    • EXTICR[1]:Line 4~7
    • EXTICR[2]:Line 8~11
    • EXTICR[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;中断里调用会因优先级卡死             │
└─────────────────────────────────────────────────────────────┘

学完这一节,你应该能回答:

  1. 中断向量表的本质是什么,CPU 怎么找到处理函数?
  2. 抢占优先级和响应优先级有什么区别?
  3. 外部中断从引脚到回调,一共经过哪些环节?
  4. 为什么中断与主循环共享的变量要加 volatile?
  5. 为什么中断里调用 HAL_Delay 会卡死,怎么解决?
相关推荐
沐欣工作室_lvyiyi2 小时前
无线遥控电动阀门开关模块设计(论文+源码)
单片机·无线遥控
番茄灭世神2 小时前
PN学堂GD32教程第11篇——I2C与EEPROM
c语言·stm32·单片机·嵌入式·gd32
阿钱真强道3 小时前
27 嵌入式操作系统 | C 网关:串口 ⇄ UDP 转发(先用 UDP 当上位机替身)
c语言·单片机·网关·udp·uloop
沐欣工作室_lvyiyi3 小时前
基于STM32的便捷式智能气象仪设计(论文+源码)
stm32·单片机·嵌入式硬件·单片机毕业设计·电子信息工程毕业设计·河北工程技术学院·廊坊师范学院
iCxhust4 小时前
STM32F103C8T6 CAN 通讯
stm32·单片机·嵌入式硬件
铅笔小新z4 小时前
【stm32】SPI Flash 驱动开发
驱动开发·stm32·嵌入式硬件
周洲083013 小时前
STM32 串口底层深度解析|USART 中断标志、硬件 FIFO,乱码根源底层定位
stm32·单片机·嵌入式硬件
女神下凡18 小时前
芯参谋(29):UFS UMCP 软件设计规范
arm开发·单片机·嵌入式硬件
李日华大战鸡红19 小时前
IC-MU128编码器使用教程
stm32