STM32内核精讲 | 第九章:堆栈与函数调用(AAPCS)
💡 本文是《STM32内核精讲》栏目的第九篇。前几篇我们学习了 SysTick、NVIC 异常处理等。从本篇开始,我们将深入 ARM 架构过程调用标准(AAPCS),分析函数调用时栈帧是如何建立的,异常发生时硬件如何自动压栈,以及为什么堆栈需要保持对齐。理解这些内容,你将能读懂反汇编代码、分析 HardFault 时的栈回溯、正确编写中断服务程序。
📌 一、引言:为什么需要 AAPCS?
在 C 语言中,我们随意编写函数:
int add(int a, int b) {
return a + b;
}
编译器会将其翻译为汇编代码。但是,谁来保证调用者和被调用者对于寄存器的使用互不冲突? 例如,add 函数可以使用 R0~R3,但它会不会破坏调用者保存在 R4 中的值?当函数嵌套调用时,返回地址保存在哪里?当发生异常时,硬件会自动保存哪些寄存器到堆栈?
这些问题的答案都写在 AAPCS(ARM Architecture Procedure Call Standard) 中。它是一套约定,规定了:
- 函数调用时参数如何传递(R0~R3,多余的用堆栈)。
- 返回值如何传递(R0,或 R0+R1 返回 64 位)。
- 哪些寄存器是 调用者保存(Caller-saved),哪些是 被调用者保存(Callee-saved)。
- 堆栈的使用规则和对齐要求(仅在公共接口处强制 8 字节对齐)。
对于 Cortex‑M 开发者,理解 AAPCS 不仅是调试的基础,也是编写裸机中断服务程序、手工切换任务上下文的关键。
📌 二、ARM 架构过程调用标准 —— 寄存器保存规则
AAPCS 将寄存器分为两类:调用者保存(也称为易失寄存器) 和 被调用者保存(也称为非易失寄存器)。
- 调用者保存(Caller-saved):在调用函数之前,如果调用者还需要使用这些寄存器的值,它必须自己把它们保存到堆栈中。被调用的函数可以随意修改这些寄存器而不需要恢复。
- 被调用者保存(Callee-saved):如果被调用的函数想要使用这些寄存器,它必须先保存原来的值(通常压入堆栈),在函数返回之前再恢复。这样调用者可以认为这些寄存器的值在调用前后保持不变。
2.1 寄存器分类(ARMv7-M 为例)
| 寄存器 | 别名 | 保存责任 | 主要用途 |
|---|---|---|---|
| R0 | a1 | 调用者 | 参数 1 / 返回值(低 32 位) |
| R1 | a2 | 调用者 | 参数 2 / 返回值(高 32 位) |
| R2 | a3 | 调用者 | 参数 3 |
| R3 | a4 | 调用者 | 参数 4 |
| R12 | ip | 调用者 | 内部过程调用暂存 |
| R14 / LR | lr | 调用者 | 链接寄存器(返回地址) |
| R4~R11 | v1~v8 | 被调用者 | 局部变量 |
| R13 / SP | sp | — | 堆栈指针,具有特殊规则 |
| R15 / PC | pc | — | 程序计数器 |
R9 在某些 ABI 变体中用作静态基址寄存器(SB),但在裸机嵌入式程序中通常也可作为被调用者保存的通用寄存器。为简化,本文忽略 R9 的特殊性。
2.2 函数调用与返回的典型汇编模式
; 调用者(caller)
MOV R0, #5 ; 准备参数 a = 5
MOV R1, #10 ; 准备参数 b = 10
BL add ; 跳转到 add,同时 LR = 返回地址
; 函数返回后,R0 中即为返回值
...
; 被调用者(add)
add:
ADD R0, R0, R1 ; R0 = a + b
BX LR ; 返回到调用者
这里,BL 指令会自动将下一条指令的地址保存到 LR。BX LR 实现返回。
2.3 为什么需要区分保存责任?
- 如果每个函数都把 所有 寄存器保存一遍,会浪费大量堆栈空间和执行时间。
- 通过约定,可以最小化保存开销:调用者保存它自己未来还要用到的易失寄存器;被调用者只保存它自己将要修改的非易失寄存器。
- 当发生异常(中断)时,硬件强制保存 R0~R3、R12、LR、PC、xPSR 这 8 个寄存器。其中 R0~R3、R12、LR 属于 AAPCS 中“调用者保存”的寄存器,保存它们是为了保护现场;而 PC 和 xPSR 不属于 AAPCS 的寄存器分类,硬件保存它们是为了能够正确恢复程序执行状态。
📌 三、普通函数调用栈帧布局
当函数调用深度超过 4 个参数,或者函数内部有局部变量、嵌套调用时,就需要使用堆栈。一个典型的 栈帧(Stack Frame) 包含:
- 返回地址(LR 的值,但通常由
BL自动保存,不需要手动压入,除非有嵌套调用)。 - 调用者保存的寄存器(如果需要)。
- 局部变量。
- 函数参数(当参数个数超过 4 时,额外的参数在调用者栈帧中预留位置,并在调用前压入)。
3.1 一个正确的栈帧示例
假设我们有这样一个 C 函数:
int func(int a, int b, int c, int d, int e) {
int local = a + b;
return local + e;
}
参数 a~d 分别通过 R0~R3 传递,第 5 个参数 e 通过堆栈传递。在调用者中,会先压入 e,然后调用 func。在 func 内部,它可能会将 R4(被调用者保存)用于局部变量,因此需要先压栈保存 R4 的原值。
正确的调用者代码(遵守 AAPCS):
; 调用者(caller)—— 遵循 AAPCS 标准参数传递方式
PUSH {R4, LR} ; 保存 R4(被调用者保存)和 LR
MOV R0, #1
MOV R1, #2
MOV R2, #3
MOV R3, #4
MOV R4, #5
SUB SP, #4 ; 为第 5 个参数预分配栈空间
STR R4, [SP] ; 将参数 e 存入预分配的空间
BL func
ADD SP, #4 ; 清理栈上的参数 e
POP {R4, PC}
正确的被调函数 func(使用 SP 相对寻址获取参数 e):
func:
PUSH {R4, R5, LR} ; 保存 R4、R5 和 LR
ADD R4, R0, R1 ; R4 = a + b
; 当前栈布局(调用者已预分配空间并存入 e):
; SP (进入 func 时) 指向 [已保存的 R4][已保存的 R5][已保存的 LR][...]
; 参数 e 位于调用者栈帧中,相对当前 SP 的偏移为 12 字节(经过压栈后)
; 具体计算:调用者执行 SUB SP,#4 后,e 在 [SP_caller];func 入口压入 3 个寄存器(12 字节),
; 因此 e 的地址为 [SP_func + 12]。
LDR R5, [SP, #12] ; 加载参数 e
ADD R0, R4, R5 ; R0 = (a + b) + e
POP {R4, R5, PC}
⚠️ 重要警示:此处的偏移量
#12仅适用于当前示例的特定栈布局。实际开发中,栈帧偏移由编译器和 ABI 决定,不同优化等级、不同编译器都会导致偏移变化。切勿硬编码此类偏移。推荐直接使用 C 语言,让编译器处理参数传递。以上汇编仅为示意 AAPCS 规则,切勿作为生产代码。
3.2 帧指针(FP)的缺席
在 Cortex‑M 的默认编译选项中(-fomit-frame-pointer),一般不使用帧指针(R7 或 R11 作为 FP),而是直接用 SP 加偏移访问局部变量和参数。这使得代码更紧凑,但调试时回溯调用栈变得困难(需要依赖 .debug_frame 信息)。在异常处理中,如果我们要手动回溯,通常只能从 SP 出发,根据 AAPCS 推断出返回地址和寄存器。
📌 四、异常入栈:8 个寄存器自动压栈
当异常(中断)发生时,Cortex‑M 硬件会自动将 8 个寄存器 压入当前使用的堆栈(MSP 或 PSP)。压栈操作是硬件自动完成的连续序列,在架构上保证不会被其他异常中断(即非阻塞)。在总线层面,处理器可以将其实现为单次突发传输以提高效率。需要特别注意:若堆栈地址非法,压栈过程中可能发生总线错误(BusFault),此时堆栈中的数据可能不完整。
这 8 个寄存器在内存中的最终布局(从低地址到高地址)为:
| 内存偏移 (相对 SP) | 寄存器 | 说明 |
|---|---|---|
[SP + 0] |
R0 | 通用寄存器 0 |
[SP + 4] |
R1 | 通用寄存器 1 |
[SP + 8] |
R2 | 通用寄存器 2 |
[SP + 12] |
R3 | 通用寄存器 3 |
[SP + 16] |
R12 | 通用寄存器 12(内部调用暂存) |
[SP + 20] |
LR (R14) | 链接寄存器(异常发生前的返回地址) |
[SP + 24] |
PC (R15) | 程序计数器(见下方说明) |
[SP + 28] |
xPSR | 程序状态寄存器 |
关于 PC 的压栈值:
- 对于 精确异常(precise exceptions),硬件自动压入的 PC 值指向导致故障的指令地址,便于直接定位故障源。
- 对于 非精确异常(imprecise exceptions)(例如数据总线错误经过了写缓冲器),PC 值可能指向故障指令之后的某条指令,甚至与故障本身无关。此时需要结合
BFAR寄存器(总线故障地址寄存器)及其他状态标志进行综合分析。
如何区分精确与非精确异常?
可通过可配置故障状态寄存器 (CFSR) 中的标志位判断:PRECISERR位为 1 时是精确异常;IMPRECISERR位为 1 时是非精确异常。
调试 HardFault 时,若发现IMPRECISERR置位,堆栈中的 PC 值不可直接信任,应优先检查BFAR获取故障地址。
异常返回时,硬件自动出栈这些寄存器,恢复现场。R4~R11 不在硬件自动保存的范围内。按照 AAPCS,R4~R11 属于被调用者保存寄存器,如果 ISR 使用了它们,必须在入口处保存、出口前恢复(在 C 语言中编译器会自动处理;在手工编写汇编 ISR 或裸函数时需显式压栈/出栈)。
特例:若 ISR 使用
__attribute__((naked))属性,编译器不会生成函数序言和尾声,此时所有使用的寄存器(包括 R4~R11,甚至 R0~R3)都需由程序员手动压栈/出栈。该属性仅用于极底层代码,普通 ISR 不应使用。
这正是我们在第五章(双堆栈切换)中手工保存 R4~R11 的原因。
📌 五、堆栈对齐要求(AAPCS 与异常调整)
5.1 AAPCS 的对齐规则
AAPCS 规定:
- 在 公共接口(public interface) 处,堆栈指针(SP)必须 8 字节对齐。
- 在函数内部,SP 必须始终保持 4 字节对齐(这是 ARM 架构的基本要求),但对于 8 字节对齐,仅在函数入口处(调用边界)强制。
之所以对公共接口有 8 字节对齐要求,是因为某些指令(如 LDRD/STRD)需要访问 8 字节对齐的有效地址。注意:对齐检查是针对 有效地址 的,而不是 SP 本身。只要有效地址对齐,即使 SP 本身不是 8 字节对齐,也可能不会出错,但这依赖于具体指令和内存系统。为安全起见,遵循 AAPCS 总是正确的。
5.2 异常时的栈对齐调整
在异常发生时,硬件自动压栈 8 个寄存器(共 32 字节)。为了确保在异常处理函数中 SP 保持 8 字节对齐,硬件会检查进入异常前的 SP 值。如果它已经是 8 字节对齐,则直接压栈;如果它不是 8 字节对齐,则硬件会先额外压入 4 字节的填充(dummy word),然后再压入 8 个寄存器,使得压栈后的 SP 成为 8 字节对齐。
填充状态的记录:该对齐调整信息不是记录在 EXC_RETURN 中,而是记录在入栈的 xPSR 寄存器的第 9 位。当硬件插入填充时,会将入栈 xPSR 的 bit 9 置位;异常返回时,硬件根据该位的值自动丢弃填充。此机制由 CCR(配置与控制寄存器)的 STKALIGN 位控制。
注意:xPSR 的 bit 9 在功能上常被称为
STKALIGN位,具体名称和读写属性取决于处理器实现。在大多数 Cortex‑M 处理器上,CCR.STKALIGN默认为 1 且只读,确保异常入口的 8 字节对齐。若STKALIGN被清零(仅在支持写入的处理器上),硬件不会插入填充,此时需由软件保证 SP 对齐,否则可能引发对齐故障。
对于开发者,这个机制是透明的,但了解它有助于理解为什么有时异常栈帧会多出 4 字节。
5.3 如何保证堆栈对齐?
- 在启动文件中,初始化 MSP 时,确保栈顶地址是 8 字节对齐的。
- 在 RTOS 中,为每个任务分配的栈空间应当从 8 字节对齐的地址开始(通常使用
__attribute__((aligned(8)))保证)。 - 编写裸机代码时,避免手工修改 SP 导致不对齐。
📌 六、浮点寄存器的惰性压栈(M4/M7)
6.1 浮点单元(FPU)的寄存器
Cortex‑M4、M7、M33(可选)等支持单精度或双精度 FPU。浮点寄存器组为 S0~S31(32 个单精度寄存器,可成对使用为 D0~D15)。还有 FPSCR(浮点状态和控制寄存器)。
6.2 惰性压栈(Lazy Stacking)机制
如果上下文使用了浮点寄存器,那么异常时保存它们会占用大量栈空间和时钟。但很多中断服务程序并不使用浮点运算,为此,Cortex‑M 的 FPU 实现了 惰性压栈(Lazy Stacking)。
核心状态位:
CONTROL.FPCA(bit 2):仅表示浮点上下文是否活跃(即当前任务是否使用过 FPU),由硬件在首次执行 FPU 指令时自动置 1。它不代表浮点寄存器已经物理压栈。FPCCR.LSPACT(bit 0):表示惰性压栈是否活跃。当 LSPACT = 1 时,表示硬件已为浮点寄存器预留了栈空间,但尚未实际压入;当 LSPACT = 0 时,表示浮点寄存器已物理压栈。
状态机:
| 场景 | FPCA | LSPACT | 说明 |
|---|---|---|---|
| 未使用 FPU | 0 | 0 | 浮点上下文从未激活 |
| 任务使用过 FPU(未发生异常) | 1 | 0 | 浮点活跃,但无异常 |
| 异常入口(惰性激活) | 1 | 1 | 已预留空间,尚未物理压栈 |
| ISR 执行浮点指令后 | 1 | 0 | 触发物理压栈 |
| 异常返回(未使用 FPU) | 1 | 0 | 直接返回,不物理压栈 |
工作机制:
- 在异常入口,硬件仅预留 S0~S15 和 FPSCR 的栈空间,但不实际压入数据,同时将
LSPACT置 1。 - 当异常处理程序中第一次执行浮点指令时,硬件自动将浮点寄存器数据压入之前预留的空间,并清零
LSPACT。 - 如果整个异常处理过程从未使用浮点指令,则返回时直接丢弃预留空间,无需恢复浮点上下文,从而节省时间。
扩展栈帧标识:
EXC_RETURN的 bit 4(FType)指示栈帧类型:FType = 1表示标准帧(无浮点扩展);FType = 0表示扩展帧(包含浮点寄存器预留空间)。
RTOS 注意事项:在 RTOS 任务切换中,如果启用了 FPU,必须正确处理浮点上下文。FreeRTOS 等 RTOS 提供了configUSE_TASK_FPU_SUPPORT选项。常见做法:在任务切换时主动触发浮点上下文的保存(例如通过执行一条空浮点指令),或完全禁止 FPU。
6.3 异常栈帧大小
基础异常栈帧(不含浮点)为 8 个字(32 字节)。如果惰性压栈被触发,硬件会额外压入 18 个字:S0~S15(16 个字)、FPSCR(1 个字)以及 1 个保留的栈空间(用于保证对齐)。因此扩展栈帧总大小固定为 26 个字(基础 8 字 + 额外 18 字 = 26 字,即 104 字节)。具体布局请参考 ARM 手册。
注意:以上 26 字栈帧基于单精度 FPU(Cortex‑M4/M33 等)。双精度 FPU(Cortex‑M7)或 Armv8.1‑M 的 MVE 扩展可能导致栈帧大小不同,请以具体处理器的参考手册为准。
📌 七、总结
7.1 本篇核心要点
- AAPCS 寄存器保存规则:R0~R3、R12、LR 为调用者保存;R4~R11 为被调用者保存。异常自动压栈的寄存器包括 R0~R3、R12、LR、PC 和 xPSR,其中 PC 和 xPSR 不属于 AAPCS 分类。
- 函数调用栈帧:参数传递、返回地址保存、局部变量分配。遵守 AAPCS 可以保证代码可移植性。
- 异常自动压栈:硬件以非阻塞的连续操作压入 8 个寄存器。精确异常压入故障指令地址,非精确异常压入的 PC 值不可靠,需结合
BFAR和CFSR分析。栈帧布局为 R0 在低地址(SP 指向)、xPSR 在高地址(SP+28)。 - 堆栈对齐:公共接口处强制 8 字节对齐,任何时候至少 4 字节对齐。异常时会自动插入填充以保证对齐,该行为由
CCR.STKALIGN控制。 - 浮点惰性压栈:使用 LSPACT 标志管理,FPCA 仅表示活跃,不代表已压栈。扩展栈帧大小为 26 字(基于单精度 FPU)。
7.2 下篇预告:《复位序列与启动文件》
从下篇开始,我们将解读复位后的内核行为,分析启动文件(.s)中的初始化代码,包括堆栈设置、向量表映射、__main 跳转等。你将彻底明白从上电到 main() 的完整历程。
💬 读者问题专栏 · 问题征集
你在调试 HardFault 或阅读反汇编代码时,是否遇到过:
- 为什么异常堆栈中的 PC 值看起来比预期多或少?
- 如何从 HardFault 中手工恢复出 R4~R11 的值?
- 在 RTOS 中如何正确处理浮点上下文?
- 编译器的
-mfloat-abi=hard和soft有什么区别?
欢迎留言,我会在 《Cortex‑M 有问必答》 中专题解答。
📢 关于作者与更多内容
我是 BackCatK Chen,长期关注嵌入式底层、国产半导体与 AI 算力芯片。
如果你对芯片架构、行业趋势感兴趣,欢迎关注我的公众号,获取更多宏观技术观察。
文章标签:Cortex-M AAPCS 函数调用 栈帧 异常入栈 堆栈对齐 浮点惰性压栈
更多推荐




所有评论(0)