☰
【STM32】中断现场保护与恢复:从栈帧到 EXC_RETURN 全解析
2026/10/7 2:42:01 网站建设 项目流程


中断现场保护与恢复:从栈帧到 EXC_RETURN 全解析

一、为什么要"现场保护"?

中断是"随时插进来"的,主程序正在用一堆寄存器(R0~R12、PC、xPSR),CPU 不可能把这些寄存器都"冻结"住等中断回来再用。

现场保护的本质:

中断到来时,把当前 CPU 的所有寄存器值存到栈里,让 ISR 可以"放心地用这些寄存器"而不破坏主程序的状态;等 ISR 执行完,再把栈里的值恢复回去,主程序从断点继续跑,完全感觉不到中断发生过。

就像你看书看到第 53 页,被人叫去开门——你得夹个书签(保护现场),开完门回来翻到书签那页(恢复现场)接着看。书签就是栈里存的那些寄存器值。


二、Cortex-M 的"硬件自动压栈":8 个寄存器

Cortex-M 最大的优势之一:

进入中断时,8 个寄存器的入栈是硬件自动完成的,软件不用写任何 push 指令。

这就是为什么它中断响应快。

2.1 自动压栈的 8 个寄存器

寄存器含义为什么要压它
xPSR程序状态寄存器中断前的标志位(进位/零/负等)必须保留
PC程序计数器最关键——存的是断点地址,回来要靠它
LR (R14)链接寄存器中断前 LR 的值,ISR 里要用 LR 返回,得先存起来
R12通用寄存器ISR 可能用到,得保护
R3通用寄存器同上
R2通用寄存器同上
R1通用寄存器同上
R0通用寄存器同上

2.2 压栈后栈帧长什么样?

压栈是从高地址往低地址生长的。假设中断发生前 SP = 0x20002000:

压栈前: 0x20002000 ──→ SP 指向这里 压栈后(硬件自动 push 8 个字): 0x20002020 ┌─────────────┐ │ xPSR │ ← 先压入,SP 从这里往回退 0x2000201C │ PC │ ← 断点地址! 0x20002018 │ LR │ 0x20002014 │ R12 │ 0x20002010 │ R3 │ 0x2000200C │ R2 │ 0x20002008 │ R1 │ 0x20002004 │ R0 │ ← 最后压入 0x20002000 └─────────────┘ ↑ SP 现在指向这里

注意压栈顺序:

先压 xPSR,最后压 R0——和列表顺序正好相反,因为栈是向下生长的。

这 8 个字(32 字节)的结构叫 Stack Frame(栈帧)。


2.3 这一步耗时多少?

硬件自动压栈 8 个寄存器,只需要 2~3 个时钟周期(和内存总线宽度有关)。

对比 ARM7:压栈要软件手写多条 PUSH 指令,慢得多。


三、软件还要压什么?——剩下的寄存器

硬件只自动压了 8 个寄存器,但 ARM 还有 R4~R11 这 8 个通用寄存器没压。

为什么硬件不一起压了?

因为不是每个 ISR 都会用到 R4~R11。

硬件帮你压所有寄存器太浪费时间——反正编译器会帮你"按需压栈"。

3.1 编译器自动做的事

你写 C 语言 ISR 时,编译器会自动生成"看情况压栈"的代码:

// 你写的 ISR void USART1_IRQHandler(void) { uint8_t ch = USART1->DR; // 读数据 if (ch == 'a') { delay_ms(10); // 用了 delay,可能用到 R4-R11 } }

编译器自动生成的汇编大概长这样:

USART1_IRQHandler: ; 编译器看到这个函数会用到 R4-R7,先手动压栈 PUSH {R4, R5, R6, R7, LR} ; ← 软件压栈(保护剩下的寄存器) ; ... 真正的 C 代码编译出来的指令 ... POP {R4, R5, R6, R7, LR} ; ← 恢复 BX LR ; ← 中断返回

关键:

R4~R11 这 8 个寄存器的保护,由编译器自动完成,你写 C 代码不用管。

ARMCC/GCC 都遵守 AAPCS 调用约定,自动处理。


四、完整的进入中断流程

把硬件和软件的事串起来:

时间线 → 主程序正在执行指令 │ ↓ ① 中断请求到来(外设拉高中断线) │ ↓ ② CPU 执行完当前指令,开始响应 │ ↓ ③ 【硬件自动】把 xPSR/PC/LR/R12/R0-R3 压入栈(栈帧) │ ↓ ④ 【硬件自动】从向量表取出 ISR 地址,写入 PC │ (同时把 LR 设为 EXC_RETURN 值) │ ↓ ⑤ 开始执行 ISR 第一条指令 │ ↓ ⑥ 【编译器自动】PUSH {R4-R11, LR}(如果用到了的话) │ ↓ ⑦ 执行你写的 C 中断处理代码 │ ↓ ⑧ 【编译器自动】POP {R4-R11, LR}(恢复软件压的) │ ↓ ⑨ 执行 BX LR(此时 LR = EXC_RETURN) │ ↓ ⑩ 【硬件自动】从栈里弹出 xPSR/PC/LR/R12/R0-R3 │ ↓ ⑪ PC 恢复成断点地址,继续执行主程序 │ 主程序感觉什么都没发生过

五、恢复现场的核心:EXC_RETURN

5.1 LR 什么时候变成了 EXC_RETURN?

进入中断时,硬件自动把 LR 寄存器改成一个特殊值,叫EXC_RETURN。常见的几个值:

EXC_RETURN 值二进制低 5 位含义
0xFFFFFFF11 0001返回 Handler 模式(不会发生,基本不用)
0xFFFFFFF91 1001返回线程模式,用 MSP
0xFFFFFFFD1 1101返回线程模式,用 PSP(RTOS 任务切换常用这个)
0xFFFFFFE11 00001返回 Handler 模式,带 FPU 懒保存

5.2 为什么要有 EXC_RETURN?

因为 ARM 的BX LR指令本来是"跳转到 LR 指向的地址"。

如果 LR 是普通地址,就是函数返回;

如果 LR 是 EXC_RETURN(以 0xFFFFFF 开头,根本不是合法代码地址),

CPU 就知道:这是中断返回,要走硬件出栈流程。

设计很巧妙:

复用了 BX 指令,靠 LR 的值区分"普通函数返回"还是"中断返回"。


5.3 恢复时硬件做了什么?

CPU 看到BX LR且 LR = EXC_RETURN,开始执行出栈流程:

出栈顺序(从低地址往高地址弹): SP 指向 R0 → 弹出 R0 SP += 4 → 指向 R1 弹出 R1 ... 弹出 xPSR → 写回 xPSR 寄存器 弹出 PC → 写回 PC(就是断点地址!)

8 个寄存器全部恢复,CPU 从 PC 指向的断点继续执行。

整个出栈过程也是硬件自动的,2~3 个周期完成。


六、MSP 和 PSP:双栈指针

Cortex-M 有两个栈指针寄存器:

栈指针用途谁用
MSP(主栈)启动时默认用,中断处理用内核 + 中断 ISR
PSP(进程栈)用户任务用RTOS 任务

6.1 进入中断时用哪个栈?

进入异常/中断后,硬件自动切换到 MSP。

也就是说:

  • 主程序/任务跑在 PSP 上
  • 中断来了 → 自动切到 MSP
  • 硬件压栈到 MSP 指向的内存
  • ISR 执行期间一直用 MSP
  • 中断返回时 → 根据 EXC_RETURN 的 bit2 决定回到 MSP 还是 PSP
RTOS 任务 A 跑在 PSP │ ↓ 中断来了 │ 硬件自动切到 MSP │ 压栈到 MSP ↓ ISR 执行(用 MSP) │ ↓ 中断返回(EXC_RETURN = 0xFFFFFFFD) │ 硬件自动切回 PSP │ ↓ 任务 B 继续跑(用 PSP)

RTOS 的核心原理之一:

中断里把当前任务的栈帧存在 PSP 上,换另一个任务的栈帧到 PSP,再用 EXC_RETURN=0xFFFFFFFD 返回——就完成了一次任务切换。


七、带 FPU 的情况:浮点寄存器怎么办?

如果芯片有 FPU(如 Cortex-M4F/M7),还有 S0~S31 这 32 个浮点寄存器。

7.1 懒保存(Lazy Stacking)

硬件默认不自动压浮点寄存器,而是做了一个优化叫 懒保存:

  1. 进中断时,如果当前在跑的代码没用 FPU,ISR 也没用 FPU → 完全不存浮点寄存器,省时间
  2. 如果 ISR 里确实要用 FPU → 第一次执行浮点指令时,硬件才把 S0~S15 压入栈帧后面的额外空间
普通栈帧(32 字节): R0-R3, R12, LR, PC, xPSR 带 FPU 懒保存的栈帧(额外 108 字节): 上面 32 字节 + S0~S15(16 个浮点寄存器) + FPSCR(浮点状态寄存器) + 一个额外的一字节(预留)

这样设计的好处:

大量中断根本不碰 FPU,就不用花时间压 32 个浮点寄存器(光压栈就要几十周期)。


八、整个流程的可视化

┌─────────────────────────────────────────────────┐ │ 主程序运行中 │ │ SP = PSP = 0x20003000 │ │ 正在执行某条指令... │ └───────────────────┬─────────────────────────────┘ │ 中断请求! ▼ ┌─────────────────────────────────────────────────┐ │ 【硬件】等当前指令执行完 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【硬件】切换到 MSP │ │ MSP = 0x20001000(主栈顶) │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【硬件】自动压栈 8 个寄存器到 MSP: │ │ [MSP-32] xPSR │ │ [MSP-28] PC = 0x08001234(断点) │ │ [MSP-24] LR = 0x08005678(主程序里的 LR) │ │ [MSP-20] R12 │ │ [MSP-16] R3 │ │ [MSP-12] R2 │ │ [MSP-8] R1 │ │ [MSP-4] R0 │ │ SP = MSP - 32 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【硬件】LR = EXC_RETURN (0xFFFFFFFD) │ │ PC = 向量表[中断号] = USART1_IRQHandler 地址 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【编译器】PUSH {R4-R11, LR}(如果用到了) │ │ SP 再往下减 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 执行你的中断处理代码 │ │ USART1->DR = ch; │ │ flag = 1; │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【编译器】POP {R4-R11, LR}(恢复软件压的) │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ BX LR → LR = 0xFFFFFFFD,识别为中断返回 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【硬件】自动出栈 8 个寄存器: │ │ R0 ← [SP] │ │ R1 ← [SP+4] │ │ ... │ │ xPSR ← [SP+28] │ │ PC ← [SP+24] = 0x08001234(回到断点!) │ │ SP += 32 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 【硬件】根据 EXC_RETURN bit2,切回 PSP │ │ SP = PSP = 0x20003000 │ └───────────────────┬─────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────┐ │ 从 PC = 0x08001234 继续执行主程序 │ │ 主程序完全不知道刚才发生了中断 │ └─────────────────────────────────────────────────┘

九、关键数字总结

项目数值
硬件自动压栈寄存器数8 个(xPSR/PC/LR/R12/R0-R3)
每个寄存器大小4 字节
硬件栈帧大小8 × 4 = 32 字节
软件额外压栈寄存器R4~R11(编译器自动,按需)
硬件压栈耗时约 2~3 个时钟周期
中断总延迟(含取向量)约 12 个时钟周期
EXC_RETURN 标志LR 以 0xFFFFFF 开头
双栈指针MSP(内核/中断)+ PSP(任务)

十、常见面试题

Q1:进中断时哪些寄存器是硬件自动压栈的?

答:xPSR、PC、LR、R12、R0、R1、R2、R3,共 8 个,32 字节栈帧。

R4~R11 由编译器按需软件压栈。


Q2:为什么 R4~R11 硬件不自动压?

答:因为不是每个 ISR 都会用到 R4~R11。

硬件全压太浪费时间(多压 8 个寄存器 = 多 32 字节内存访问)。

编译器根据 ISR 实际用到的寄存器按需压栈,更高效。


Q3:EXC_RETURN 是干嘛的?

答:进中断时硬件自动把 LR 设为 EXC_RETURN(如 0xFFFFFFFD)。

执行 BX LR 时,CPU 看到这个特殊值就知道是中断返回,触发硬件自动出栈恢复现场。


Q4:MSP 和 PSP 有什么区别?

答:MSP 是主栈,中断/内核用;PSP 是进程栈,用户任务用。

进中断自动切 MSP,中断返回时根据 EXC_RETURN 决定切回哪个。

RTOS 靠这个机制做任务切换。


Q5:中断嵌套时栈帧怎么变化?

答:每嵌套一层,就压一个新的 32 字节栈帧。

嵌套越深,栈用得越多。设计系统时要算好最大嵌套层数,栈空间要留够。

主程序栈帧 ↓ 嵌套中断 A 中断 A 的栈帧(在主程序栈帧上面) ↓ 嵌套中断 B 中断 B 的栈帧(在中断 A 的栈帧上面)

中断过程中的内存地址:从 Flash 到 RAM 全追踪

一、STM32 内存地址总览

地址范围 大小 用途 ───────────────────── ──────── ────────────────────── 0xFFFFFFFF 512MB 系统级空间(内核私有) 0xE0000000 512MB 片上外设寄存器区 0xA0000000 512MB 外部 RAM 区(FSMC 扩展) 0x60000000 512MB 外部设备区(FSMC 扩展) 0x40000000 512MB 片上外设寄存器区 ← GPIOA/USART1/TIM2 在这 0x3FFFFFFF 512KB 备份 SRAM 0x2001C000 64KB 主 SRAM ← 栈、堆、全局变量在这 0x20000000 512KB 内核私有 SRAM(ETM 用) 0x1FFFF800 128KB 系统存储器(ISP bootloader) 0x1FFFF000 512MB 片上 Flash ← 向量表 + 代码在这 0x08000000 128MB 保留 0x00000000

两个核心区域:

  • Flash:0x08000000 → 向量表 + 你的代码
  • SRAM:0x20000000 → 栈(Stack)、堆(Heap)、全局变量

二、Flash 里的向量表:0x08000000 起

向量表放在 Flash 最开头,每个项占 4 字节(一个字):

绝对地址偏移内容说明
0x080000000x000x2000A000初始栈顶 MSP(不是中断!)
0x080000040x04Reset_Handler 地址中断号 1,复位
0x080000080x08NMI_Handler 地址中断号 2
0x0800000C0x0CHardFault_Handler 地址中断号 3
0x080000100x10MemManage_Handler 地址中断号 4
0x080000140x14BusFault_Handler 地址中断号 5
0x080000180x18UsageFault_Handler 地址中断号 6
............
0x080000400x40WWDG_IRQHandler 地址中断号 16(IRQ0)
0x080000440x44PVD_IRQHandler 地址中断号 17(IRQ1)
............
0x080000D40xD4USART1_IRQHandler 地址中断号 53(IRQ37)

计算公式:

向量表项地址 = 0x08000000 + 中断号 × 4

验证:USART1 中断号 53 → 0x08000000 + 53×4 = 0x080000D4 ✅


三、上电时 CPU 干了什么?

CPU 上电复位,做的第一件事:

第1步:从 0x08000000 读 4 字节 → 0x2000A000 → 写入 MSP 第2步:从 0x08000004 读 4 字节 → Reset_Handler 地址 → 写入 PC 第3步:从 PC 指向的地址开始执行 → Reset_Handler → 进入 main()

也就是说:

栈顶地址 0x2000A000 存在 Flash 的第一个位置,CPU 上电自动把它装进栈指针寄存器。


四、SRAM 里的栈:0x20000000 起

4.1 栈在 RAM 中的位置

SRAM 布局(STM32F103,64KB RAM = 0x10000 字节): 0x20010000 ┌─────────────────────┐ ← RAM 最高地址 │ (保留) │ │ │ 0x2000A000 ├─────────────────────┤ ← 初始 MSP 指向这里(栈顶) │ 栈(向下生长)↓ │ │ │ │ │ │ 堆(向上生长)↑ │ │ │ │ .data / .bss │ ← 全局变量 0x20000000 └─────────────────────┘ ← RAM 最低地址

栈顶初始在 0x2000A000,栈从高地址往低地址"长",每压一个数据 SP 减 4。


五、压栈过程:SP 一步步怎么变?

假设中断发生前,MSP = 0x2000A000,主程序正在跑。

硬件自动压栈 8 个寄存器,我们一步步追踪 SP 的变化:

初始状态:MSP = 0x2000A000 栈是空的。 第1步:压 xPSR(硬件先压最高编号的) MSP = 0x2000A000 - 4 = 0x20009FFC [0x20009FFC] = xPSR 的值 内存: 0x2000A000 ┌──────────┐ │ (空) │ 0x20009FFC ├──────────┤ │ xPSR │ ← MSP 指向这里 0x20009FF8 └──────────┘ 第2步:压 PC(断点地址) MSP = 0x20009FFC - 4 = 0x20009FF8 [0x20009FF8] = PC 的值(比如 0x08001234) 内存: 0x20009FFC ┌──────────┤ │ xPSR │ 0x20009FF8 ├──────────┤ │ PC=0x08001234 │ ← MSP 指向这里 0x20009FF4 └──────────┘ 第3步:压 LR MSP = 0x20009FF8 - 4 = 0x20009FF4 [0x20009FF4] = LR 的值 第4步:压 R12 MSP = 0x20009FF4 - 4 = 0x20009FF0 [0x20009FF0] = R12 第5步:压 R3 MSP = 0x20009FF0 - 4 = 0x20009FEC [0x20009FEC] = R3 第6步:压 R2 MSP = 0x20009FEC - 4 = 0x20009FE8 [0x20009FE8] = R2 第7步:压 R1 MSP = 0x20009FE8 - 4 = 0x20009FE4 [0x20009FE4] = R1 第8步:压 R0(最后压最低编号的) MSP = 0x20009FE4 - 4 = 0x20009FE0 [0x20009FE0] = R0 压栈完成!MSP = 0x20009FE0

六、压栈完成后的栈帧:绝对地址全列出来

高地址 0x20009FFC ┌──────────────┐ │ xPSR │ ← [MSP + 0x1C] 0x20009FF8 ├──────────────┤ │ PC=0x08001234│ ← [MSP + 0x18](断点地址!) 0x20009FF4 ├──────────────┤ │ LR │ ← [MSP + 0x14] 0x20009FF0 ├──────────────┤ │ R12 │ ← [MSP + 0x10] 0x20009FEC ├──────────────┤ │ R3 │ ← [MSP + 0x0C] 0x20009FE8 ├──────────────┤ │ R2 │ ← [MSP + 0x08] 0x20009FE4 ├──────────────┤ │ R1 │ ← [MSP + 0x04] 0x20009FE0 ├──────────────┤ │ R0 │ ← [MSP + 0x00] ← MSP 指向这里(栈顶) 0x2000FDDC └──────────────┘ 低地址

关键:SP 指向 R0(最低地址),xPSR 在最高地址。

从 SP 偏移的角度看:

[SP + 0x00] = R0 [SP + 0x04] = R1 [SP + 0x08] = R2 [SP + 0x0C] = R3 [SP + 0x10] = R12 [SP + 0x14] = LR [SP + 0x18] = PC(断点) [SP + 0x1C] = xPSR

七、出栈过程:SP 怎么回去的?

ISR 执行完,执行BX LR,此时 LR = EXC_RETURN。

硬件自动出栈,顺序和压栈相反:

初始:MSP = 0x20009FE0(指向 R0) 第1步:弹 R0 R0 = [MSP] → 读 0x20009FE0 MSP = 0x20009FE0 + 4 = 0x20009FE4 第2步:弹 R1 R1 = [MSP] → 读 0x20009FE4 MSP = 0x20009FE4 + 4 = 0x20009FE8 第3步:弹 R2 → MSP = 0x20009FEC 第4步:弹 R3 → MSP = 0x20009FF0 第5步:弹 R12 → MSP = 0x20009FF4 第6步:弹 LR → MSP = 0x20009FF8 第7步:弹 PC → MSP = 0x20009FFC (PC 恢复成 0x08001234,断点地址!) 第8步:弹 xPSR → MSP = 0x2000A000 (xPSR 恢复成中断前的状态) 出栈完成!MSP = 0x2000A000(回到中断前的值) CPU 从 PC = 0x08001234 继续执行主程序

八、MSP 和 PSP:两个栈指针的地址

Cortex-M 有两个栈指针,用不同的栈内存:

SRAM 布局: 0x2000A000 ┌─────────────────────┐ ← MSP 栈顶(内核/中断用) │ MSP 栈 │ │ (中断时压栈到这) │ │ │ │ │ │ PSP 栈 │ ← 主程序/任务用 0x20008000 ├─────────────────────┤ │ (主程序跑在这) │ │ │ │ │ 0x20000000 └─────────────────────┘
栈指针什么时候用栈在哪
MSP进中断时自动切到 MSP;复位后默认用 MSP通常在 RAM 高地址区
PSP主程序/用户任务用通常在 MSP 下面

进中断自动切 MSP,出中断自动切回 PSP——这就是 RTOS 任务切换的基础。


九、一张图总结所有关键地址

Flash(0x08000000 起) ┌─────────────────────────────────────┐ │ 0x08000000: 初始 MSP = 0x2000A000 │ ← CPU 上电先读这里 │ 0x08000004: Reset_Handler 地址 │ ← 然后读这里开始执行 │ 0x08000008: NMI_Handler 地址 │ │ ... │ │ 0x080000D4: USART1_IRQHandler 地址 │ ← USART1 中断发生时读这里 └─────────────────────────────────────┘ SRAM(0x20000000 起) ┌─────────────────────────────────────┐ │ 0x2000A000: MSP 初始栈顶 │ ← 上电时从 Flash 第一项读入 │ │ │ 压栈后: │ │ 0x20009FFC: xPSR │ ← [MSP+0x1C] │ 0x20009FF8: PC = 断点 0x08001234 │ ← [MSP+0x18] │ 0x20009FF4: LR │ ← [MSP+0x14] │ 0x20009FF0: R12 │ ← [MSP+0x10] │ 0x20009FEC: R3 │ ← [MSP+0x0C] │ 0x20009FE8: R2 │ ← [MSP+0x08] │ 0x20009FE4: R1 │ ← [MSP+0x04] │ 0x20009FE0: R0 │ ← [MSP+0x00] ← MSP 指向这里 │ │ │ ... PSP 栈(主程序用)... │ │ │ │ 0x20000000: .data / .bss(全局变量)│ └─────────────────────────────────────┘

核心公式:

  • 向量表项地址 = 0x08000000 + 中断号 × 4
  • 栈帧大小 = 8 × 4 = 32 字节
  • 压栈后 SP = 原 SP - 32
  • 出栈后 SP = 新 SP + 32(回到原位)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询