0%

STM32——深入外部中断硬件原理

从”能用”到”理解”

上一篇博客中,我们完成了外部中断的功能:红灯闪烁,按下 KEY1 触发中断,翻转绿灯。

但当时更多的只是照着教程写了代码,对于”为什么这样配置就能触发中断”、”从按键按下到 ISR 执行中间发生了什么”,只有一个模糊的概念。

这篇就来填补这个空白——沿着信号的路径,从 KEY1 引脚一直追踪到 CPU 执行中断服务函数,搞懂完整的中断触发流程。


完整信号路径概览

从 KEY1(PB12)引脚的电平变化到执行中断服务函数,总共经过 5 个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GPIO 引脚(PB12)
│
▼
① GPIO 输入驱动器 ← 引脚的硬件电路,处理电平信号
│
▼
② 边沿检测电路 ← 检测上升沿/下降沿
│
▼
③ EXTI 寄存器组 ← 请求挂起(PR) + 中断屏蔽(IMR)
│
▼
④ NVIC ← 中断控制器,管理所有中断的优先级和使能
│
▼
⑤ CPU 执行 ISR ← EXTI15_10_IRQHandler()

image-20260728220434864

image-20260728220635360

image-20260728220809865

接下来逐一深入每个阶段。


第一阶段:GPIO 输入驱动器

信号从 KEY1(PB12)引脚进入芯片,首先经过的是 GPIO 输入驱动器。

image-20260728220933045

输入驱动器的结构

每个 GPIO 引脚内部都有一套输入驱动电路,核心是两个串接的施密特触发器:

1
2
3
4
5
6
7
8
9
10
11
12
13
PB12 引脚
│
▼
┌──────────┐
│ 施密特 │──→ 到片上外设(如 EXTI)
│ 触发器 │
└──────────┘
│
▼
┌──────────┐
│ 施密特 │──→ 到 GPIO 输入数据寄存器 (IDR)
│ 触发器 │
└──────────┘

施密特触发器的作用是整形——把缓慢变化或不干净的电平信号转换成干净的数字方波:

想象一下,机械按键按下时电平不是瞬间从高跳到低的,中间会有抖动和毛刺。施密特触发器的作用就是”过滤”这些不稳定的中间态——低于阈值判为 0,高于阈值判为 1,输出一个干净的数字信号。

输入驱动器在做什么

不管 PB12 配置成普通 GPIO 输入还是 EXTI 中断模式,GPIO 的输入驱动器都在做同一件事——信号整形:

  1. 施密特触发器接收引脚上的模拟电平
  2. 将其转换成干净的 0/1 数字信号(低于阈值判 0,高于判 1,过滤中间抖动)
  3. 整形后的信号分两路输出:一路到 **GPIO 输入数据寄存器 (IDR)**,另一路到 片上外设(这里是 EXTI 模块的边沿检测电路)
1
2
3
4
5
6
7
8
PB12 引脚上的模拟电平(可能带抖动)
│
▼
施密特触发器(整形过滤)
│
├──→ GPIO 输入数据寄存器 (IDR) ← 用 HAL_GPIO_ReadPin() 读的就是它
│
└──→ EXTI 边沿检测电路(下一阶段)

这也解释了为什么上一篇中可以在 ISR 里用 HAL_GPIO_ReadPin() 再次确认按键状态——因为有单独一路信号始终通往 IDR,不依赖 EXTI 的工作状态。

注意:边沿检测(下降沿/上升沿)不在这里发生,那是下一阶段 EXTI 电路的工作。输入驱动器的职责到此为止——只负责整形和转发。

关于 GPIO 八大模式的详细解析,参考之前那篇 GPIO 模式的博客。


第二阶段:边沿检测电路

经过输入驱动器整形后的干净信号,同时送给上升沿检测和下降沿检测两条路径:

image-20260728221410731

两个触发选择寄存器

EXTI 模块有两个触发选择寄存器,分别控制上升沿和下降沿的使能:

寄存器 全称 作用
EXTI_RTSR Rising Trigger Selection Register 对应位写 1 → 使能该线的上升沿触发
EXTI_FTSR Falling Trigger Selection Register 对应位写 1 → 使能该线的下降沿触发

每个寄存器都有 20 位(bit 0~19),每 1 位对应一条 EXTI 线。默认所有位都是 0(不使能),只有我们配置的那一位会被设为 1。

拿我们的配置举例——选择了下降沿触发,所以下降沿寄存器状态是:

1
2
3
4
5
6
7
EXTI_FTSR 寄存器(20位):
bit → 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
┌─────────────────────────────────────────────────────────────┐
│ 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 │
└─────────────────────────────────────────────────────────────┘
▲
└── bit12 = 1(使能 EXTI12 下降沿触发)

而 EXTI_RTSR 寄存器则保持全 0,因为我们没有使能上升沿触发。

我们的配置

上一篇我们在 CubeMX 中选择了 Falling edge trigger detection(下降沿触发),这意味着:

1
2
EXTI_FTSR 的 bit 12 = 1  (使能下降沿检测)
EXTI_RTSR 的 bit 12 = 0 (不检测上升沿)

image-20260728221955085

在生成的代码中,HAL_GPIO_Init() 内部通过位运算来操作这两个寄存器。拿我们的下降沿触发举例,等价于做了这样一件事:

1
2
3
4
// HAL_GPIO_Init 内部对 FTSR 的操作(以 PB12 为例)
// KEY1_Pin = GPIO_PIN_12 = 0x1000 = 二进制 1 0000 0000 0000
// 把第 12 位置 1,其他位保持不变
EXTI->FTSR |= GPIO_PIN_12;

这行代码的意思是:读取 FTSR 寄存器的当前值 → 把第 12 位改成 1 → 写回去。

操作前后的寄存器变化:

1
2
3
4
5
执行前:  FTSR = 0x0000 0000    (所有位都是 0,所有 EXTI 线都没使能)
│
▼ EXTI->FTSR |= GPIO_PIN_12;
│
执行后: FTSR = 0x0000 1000 (bit12 变成 1,其他位不变)

GPIO_PIN_12 的值是 0x1000(即 1 << 12),这个值与 FTSR 做按位或运算,结果就是只把第 12 位设成 1,不影响其他位。

同理,如果配置的是上升沿触发,操作的就是 RTSR 寄存器:

1
EXTI->RTSR |= GPIO_PIN_12;  // RTSR 的第 12 位置 1

边沿检测电路的工作原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
当前电平 → 延迟一个时钟周期 → 旧电平
│
▼
┌─────────────────────┐
│ 比较:当前 vs 旧 │
└─────────────────────┘
│ │
▼ ▼
上升沿检测 下降沿检测
(RTSR控制) (FTSR控制)
│ │
▼ ▼
┌──────┐ ┌──────┐
│与门 │ │与门 │
└──┬───┘ └──┬───┘
│ │
└────┬─────┘
▼
或门 → 输出高电平 → 到 EXTI_PR

具体过程:

  1. 每个时钟周期,电路比较当前电平和上一周期的电平
  2. 如果当前电平 > 上一周期电平 = 上升沿
  3. 如果当前电平 < 上一周期电平 = 下降沿
  4. 检测到的边沿再与 RTSR / FTSR 对应位做与运算
  5. 如果该位为 1(使能了对应边沿检测),与门输出 1
  6. 两个与门的输出经过或门合并

所以对于我们的场景:KEY1 按下(高→低),下降沿被检测到,FTSR bit12 = 1,与门输出 1,或门输出 1 → 向后传递一个高电平信号。


第三阶段:EXTI 寄存器组

第二阶段输出的高电平信号到达了 EXTI 的核心寄存器组:

image-20260728222413673

请求挂起寄存器 (EXTI_PR)

EXTI_PR(Pending Register),也叫挂起寄存器或待决寄存器。

当边沿检测电路传来高电平信号时,EXTI_PR 的对应位被置为 1:

位 含义
bit 12 = 1 EXTI12 线有中断请求正在等待处理

只要 PR 的某一位为 1,就表示该中断线有一个”未处理”的中断请求。

这个寄存器很关键——CPU 正是通过查询 PR 知道”谁触发了中断”的。

这就像外卖到了,门卫(PR)在本子上记下”12 号房有外卖”,然后通知你。

中断屏蔽寄存器 (EXTI_IMR)

EXTI_IMR(Interrupt Mask Register),决定是否允许某条中断线向 NVIC 发送请求:

  • 某位 = 1:允许该中断线继续向后传递(放行)
  • 某位 = 0:屏蔽该中断线(拦截)

CubeMX 在配置 PB12 为 GPIO_EXTI12 时,会自动把 IMR 的 bit 12 设为 1。

1
2
// HAL_GPIO_Init 内部使能 IMR 的底层代码
EXTI->IMR |= iocurrent; // IMR 对应位置 1,允许中断通过

PR 和 IMR 的与门组合

PR 的输出和 IMR 的输出经过一个与门:

1
2
3
EXTI_PR bit12 = 1  ───┐
├── 与门 ──→ 输出高电平 → 到 NVIC
EXTI_IMR bit12 = 1 ──┘

只有 两者都是 1 时,与门才输出 1,中断信号才能到达 NVIC。

这就是中断屏蔽的原理——把 IMR 的某位设为 0,即使 PR 被置 1,与门也会输出 0,相当于”关掉了”这条中断线。


第四阶段:NVIC(嵌套向量中断控制器)

第三阶段输出的信号终于到了中断的”最高层”——NVIC(Nested Vectored Interrupt Controller)。

image-20260728222459716

NVIC 的角色

先补充一个概念:Cortex-M3 是什么?

STM32F103 芯片内部可以分成两层:

  • 内核(Core)——ARM 公司设计的 CPU 核心,负责执行指令、处理运算。STM32F103 用的是 Cortex-M3 内核,属于 ARM 的”M 系列”(Microcontroller),专门为单片机设计的低功耗、高性能内核
  • 外设(Peripheral)——ST 公司在内核周围添加的功能模块,比如 GPIO、EXTI、定时器、UART 等。这些是 ST 自己设计的,不同型号的 STM32 外设各有不同

打个比方:Cortex-M3 内核就像汽车的发动机,外设就像车轮、方向盘、空调——发动机决定了这辆车的基础性能,而外设决定了这辆车能干什么。

NVIC 是 Cortex-M3 内核的一部分,负责:

  1. 管理所有中断的使能和屏蔽
  2. 管理中断优先级——当多个中断同时触发时决定先处理谁
  3. 找到中断向量——根据中断号找到对应的中断服务函数地址
  4. 支持中断嵌套——高优先级中断可以打断低优先级中断

中断向量

每个中断源都有一个唯一的中断号,对应中断向量表中的一项。EXTI12 的中断号是 EXTI15_10_IRQn(因为 EXTI 线 10~15 共享同一个中断入口)。

中断向量表存储在各个中断服务函数的地址:

中断号 中断名 函数地址
23 EXTI15_10_IRQn → EXTI15_10_IRQHandler()

当 NVIC 检测到 EXTI12 的中断信号时,它取出向量表中 EXTI15_10 对应的地址,然后 CPU 跳转到那里执行。

NVIC 相关的 CubeMX 配置代码

在 MX_GPIO_Init() 中,CubeMX 生成了两行关键代码:

1
2
3
/* EXTI interrupt init*/
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 15, 0); // 设置优先级
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn); // 使能中断

这两个函数的背后操作:

HAL_NVIC_EnableIRQ(EXTI15_10_IRQn) 内部操作的是 NVIC_ISER(Interrupt Set Enable Register)寄存器:

1
2
3
4
5
// 简化版实现
void HAL_NVIC_EnableIRQ(IRQn_Type IRQn)
{
NVIC->ISER[((uint32_t)IRQn) >> 5] = (uint32_t)(1 << (((uint32_t)IRQn) & 0x1F));
}

ISER 寄存器有 3 个(共 96 位),每一位对应一个中断号。EXTI15_10_IRQn 的中断号是 23,所以 ISER[0] 的第 23 位被置 1。

NVIC 总共有三个关键使能寄存器:

  • ISER:Interrupt Set Enable Register——置 1 使能中断
  • ICER:Interrupt Clear Enable Register——置 1 禁能中断
  • ISPR/ICPR:中断挂起/清除挂起

HAL_NVIC_SetPriority(EXTI15_10_IRQn, 15, 0) 设置优先级,后面会展开讲。


第五阶段:执行 ISR 与清标志位

NVIC 把中断信号送达 CPU 内核后,CPU 的响应流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
NVIC 发出中断请求
│
▼
CPU 完成当前指令
│
▼
CPU 压栈(保存现场) ← 自动保存 R0-R3、R12、LR、PC、xPSR
│
│ 注意:STM32 的中断响应延迟只有 12 个时钟周期
│ 这是 Cortex-M3 核心的优势之一
▼
CPU 从向量表取出 EXTI15_10_IRQHandler 的地址
│
▼
CPU 跳转到 EXTI15_10_IRQHandler() 执行
│
▼
执行用户编写的 ISR 代码
│
▼
执行 HAL_GPIO_EXTI_IRQHandler() → 清除 EXTI_PR 对应位
│
▼
CPU 出栈(恢复现场)
│
▼
回到 main loop 被中断的位置继续执行

为什么必须清 PR?

这是最关键的一步,也是最容易忘的一步。

EXTI_PR 寄存器的作用是”记住”中断请求。如果不主动清零:

  1. ISR 执行完毕后,PR bit12 仍然为 1
  2. 与门另一端的 IMR bit12 也为 1(没关中断)
  3. 与门持续输出高电平到 NVIC
  4. NVIC 认为又有新中断来了
  5. CPU 再次进入 ISR
  6. 死循环!

所以在 ISR 末尾必须调用:(当然,好心的cubemx会自动帮我们生成这行代码)

1
HAL_GPIO_EXTI_IRQHandler(KEY1_Pin);

这个函数内部会检查 PR 对应位,如果为 1 就将它清零:

1
2
3
4
5
6
7
8
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != RESET) // 检查 PR 对应位
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); // 清零 PR 对应位
HAL_GPIO_EXTI_Callback(GPIO_Pin); // 调用回调函数
}
}

清零的底层操作:

1
2
// __HAL_GPIO_EXTI_CLEAR_IT(KEY1_Pin) 展开后
EXTI->PR = GPIO_PIN_12; // 向 PR 寄存器的 bit12 写 1 来清零

⚠️ 规律:向 PR 写 1 来清除中断标志位。 这是 STM32 中常见的寄存器设计——读某位判断状态,写 1 清除该位。不要写成写 0,那不会生效。


深入中断优先级

4 位优先级的分配

上一篇博客只提了一句”配置优先级”,现在我们来看看背后发生了什么。

STM32F1 为每个中断向量分配了 4 个二进制位来存储优先级信息:

1
2
NVIC->IP[IRQn] = [bit7][bit6][bit5][bit4][bit3][bit2][bit1][bit0]
↑ 优先级位占高4位 ↑ 低4位不使用

CubeMX 中的 优先级分组(Priority Group) 决定了这 4 位如何分配到抢占优先级和子优先级:

分组 抢占优先级(bit) 子优先级(bit) 抢占级数量 子优先级数量
组 0 0 4 1 16
组 1 1 3 2 8
组 2 2 2 4 4
组 3 3 1 8 2
组 4 4 0 16 1

CubeMX 默认使用组 4,即全部 4 位用于抢占优先级,没有子优先级。

image-20260728222947246

优先级编码

CubeMX 中配的”Preemption Priority = 15”并不是直接写进寄存器的值,中间有一个编码过程。

以组 4 为例,4 位全部是抢占优先级:

中断 CubeMX 中配的优先级 编码到 IP 寄存器高 4 位 实际优先级值
SysTick 14 1110 14(最高)
EXTI15_10 15 1111 15(最低)

HAL_NVIC_SetPriority 内部做编码:

1
2
3
4
5
6
7
8
9
10
11
12
// HAL_NVIC_SetPriority 简化逻辑
void HAL_NVIC_SetPriority(IRQn_Type IRQn, uint32_t PreemptPriority, uint32_t SubPriority)
{
// 1. 根据优先级分组计算出编码值
uint32_t prioritygroup = NVIC_GetPriorityGrouping(); // 默认组 4

// 2. 将 PreemptPriority 和 SubPriority 编码成 4 位值
uint32_t priority = NVIC_EncodePriority(prioritygroup, PreemptPriority, SubPriority);

// 3. 写入 NVIC 的优先级寄存器(高 4 位)
NVIC->IP[IRQn] = (uint8_t)((priority << 4) & 0xFF);
}

中断嵌套

如果多个中断被触发,NVIC 根据优先级决定处理顺序:

  • 数值越小,优先级越高
  • 高优先级中断可以打断低优先级中断(嵌套)
  • 同优先级中断按中断号决定,号小的先执行

我们配置的优先级关系:

中断 优先级 说明
SysTick 14 最高,可以抢占其他所有中断
EXTI15_10 15 最低

所以上一篇中在 EXTI 的 ISR 里调用 HAL_Delay(10) 是安全的——SysTick(优先级 14)可以打断 EXTI(优先级 15)来更新 uwTick,不会死锁。


完整映射:CubeMX 配置 → 底层寄存器

用一张表把 CubeMX 配置和底层寄存器对应起来:

CubeMX 配置项 影响的寄存器 对应代码
PB12 → GPIO_EXTI12 AFIO_EXTICR3(选择 PB12 连接到 EXTI12) HAL_GPIO_Init() 内部
GPIO mode → IT Falling EXTI_FTSR bit12 = 1 EXTI->FTSR |= (1<<12)
EXTI line[15:10] NVIC Enabled NVIC_ISER[0] bit23 = 1 HAL_NVIC_EnableIRQ(EXTI15_10_IRQn)
Preemption Priority = 15 NVIC_IP[23] 高 4 位 = 1111 HAL_NVIC_SetPriority(EXTI15_10_IRQn, 15, 0)
(自动生成) EXTI_IMR bit12 = 1 EXTI->IMR |= (1<<12)
(自动生成) EXTI_PR bit12 清 0 EXTI->PR = (1<<12)

总结

完整信号路径回顾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
KEY1 按下 (PB12 从高到低)
│
▼
GPIO 输入驱动器(施密特触发器整形)
│
▼
下降沿检测电路 —— FTSR bit12 = 1 使能下降沿检测
│
▼
EXTI_PR bit12 置 1 ← 挂起寄存器"记录"了中断请求
│
▼
与门:PR bit12(1) AND IMR bit12(1) = 1 ← 中断已被使能
│
▼
NVIC 检测到 EXTI12 的中断信号
│
▼
查找向量表 → EXTI15_10_IRQn → 跳转到 EXTI15_10_IRQHandler()
│
▼
用户 ISR:消抖 → 确认 → 翻转绿灯
│
▼
HAL_GPIO_EXTI_IRQHandler() → 清除 EXTI_PR bit12
│
▼
CPU 恢复现场,继续执行 main loop

核心要点

要点 说明
EXTI_PR 记录中断请求,ISR 中必须清零,否则反复触发
EXTI_IMR 中断屏蔽开关,1 = 放行,0 = 屏蔽
EXTI_RTSR/FTSR 选择上升沿/下降沿触发
NVIC 总控制器,管理所有中断的使能和优先级
优先级组 决定 4 位中多少位给抢占、多少位给子优先级
中断嵌套 高优先级可打断低优先级,SysTick > EXTI

与其他中断的共性

虽然这篇以外部中断(EXTI)为例,但所有中断的流程都有类似的框架:

  1. 外设产生中断事件 → 对应挂起寄存器置位
  2. 中断使能/屏蔽寄存器决定是否放行
  3. 信号到达 NVIC,根据优先级排队
  4. NVIC 让 CPU 跳转到对应的中断服务函数
  5. ISR 中必须清除挂起标志

后续学习定时器、UART、DMA 等外设的中断时,它们的流程都遵循这个模式。


参考资源