💡 本文是《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 本篇核心要点

  1. AAPCS 寄存器保存规则:R0~R3、R12、LR 为调用者保存;R4~R11 为被调用者保存。异常自动压栈的寄存器包括 R0~R3、R12、LR、PCxPSR,其中 PC 和 xPSR 不属于 AAPCS 分类。
  2. 函数调用栈帧:参数传递、返回地址保存、局部变量分配。遵守 AAPCS 可以保证代码可移植性。
  3. 异常自动压栈:硬件以非阻塞的连续操作压入 8 个寄存器。精确异常压入故障指令地址,非精确异常压入的 PC 值不可靠,需结合 BFARCFSR 分析。栈帧布局为 R0 在低地址(SP 指向)、xPSR 在高地址(SP+28)。
  4. 堆栈对齐:公共接口处强制 8 字节对齐,任何时候至少 4 字节对齐。异常时会自动插入填充以保证对齐,该行为由 CCR.STKALIGN 控制。
  5. 浮点惰性压栈:使用 LSPACT 标志管理,FPCA 仅表示活跃,不代表已压栈。扩展栈帧大小为 26 字(基于单精度 FPU)。

7.2 下篇预告:《复位序列与启动文件》

从下篇开始,我们将解读复位后的内核行为,分析启动文件(.s)中的初始化代码,包括堆栈设置、向量表映射、__main 跳转等。你将彻底明白从上电到 main() 的完整历程。


💬 读者问题专栏 · 问题征集

你在调试 HardFault 或阅读反汇编代码时,是否遇到过:

  • 为什么异常堆栈中的 PC 值看起来比预期多或少?
  • 如何从 HardFault 中手工恢复出 R4~R11 的值?
  • 在 RTOS 中如何正确处理浮点上下文?
  • 编译器的 -mfloat-abi=hardsoft 有什么区别?

欢迎留言,我会在 《Cortex‑M 有问必答》 中专题解答。


📢 关于作者与更多内容

我是 BackCatK Chen,长期关注嵌入式底层、国产半导体与 AI 算力芯片。

如果你对芯片架构、行业趋势感兴趣,欢迎关注我的公众号,获取更多宏观技术观察。


文章标签Cortex-M AAPCS 函数调用 栈帧 异常入栈 堆栈对齐 浮点惰性压栈

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐