ARMv7-A小系统实践(三):AAPCS32、栈与上下文汇编

本章记录什么

这一章讨论 C 函数与汇编函数如何彼此信任。处理器不会自动知道哪几个寄存器应该由调用者保存,也不会自动保证栈满足编译器要求;这些规则来自 AAPCS32。只有把参数、返回值、caller-saved、callee-saved、栈对齐和 lr 的含义连起来,才能安全地从启动汇编调用 C、从 IRQ 入口调用分发器,或者实现一个与编译器兼容的上下文切换函数。

在这一章之前做了什么

上一章已经说明模式切换如何改变 splr 和 SPSR 的可见版本。那属于处理器架构。普通函数调用则通常不改变处理器模式,它依赖的是软件合同。两者都在操作寄存器,但保存集合、返回方式和错误症状完全不同。本章先把这条边界画清楚,再进入多寄存器访存和实际函数边界。

文章中的示例会从一个普通五参数函数开始,逐步走到汇编调用 C、C 调用汇编以及结构体偏移校验。读完后,即使没有工程源码,也应能独立判断一段汇编是否破坏了 ABI。

系列目录 · 上一篇:处理器模式、寄存器组与 CPSR

本章的阅读顺序

先读基础概念,确认每个术语解决什么问题;再看设计推导,理解实现为什么采用这种保存集合、初始化顺序或状态机;随后逐段阅读代码和执行轨迹;最后用验证方法与错误案例检查理解。文中保留寄存器名、指令名和标准缩写,叙述部分统一使用简体中文。

动手前的基础:AAPCS32 的完整调用合同

适用平台:Zynq-7000 / Cortex-A9 / AArch32 / -mfloat-abi=soft
学习目的:能在 C、汇编、异常入口、调度器之间安全传参和保存现场
阅读方式:正文替代本节 AAPCS32 英文阅读;本章没有必须对照的原图
编写日期:2026-07-30

0. AAPCS 不是“编译器习惯”,而是模块之间的合同

AAPCS32 是 Arm 32 位过程调用标准。只要两个模块都遵守同一 ABI,它们不需要知道对方内部怎样实现,也能正确协作:

1
2
3
4
5
6
C 调 C
C 调汇编
汇编调 C
不同源文件互调
静态库与内核互调
异常入口建立好调用环境后调 C handler

它主要约定:

  • 参数放在哪些寄存器或栈位置;
  • 返回值放在哪里;
  • 哪些寄存器由调用者保护,哪些由被调用者保护;
  • 栈向哪里增长、在公共接口必须怎样对齐;
  • BL/LR 如何表达调用和返回;
  • ARM/Thumb 代码怎样互操作。

如果双方对这份合同理解不同,代码可能在 -O0 正常、-O2 崩溃,也可能只在增加一个日志函数后才暴露。这种“玄学”通常不是编译器随机出错,而是手写汇编破坏了 ABI。


1. 本文覆盖的原始资料和准确范围

原始资料 准确范围 页数 本文用途
本地 42_ARM_C过程调用标准_AAPCS32.pdf 第 6.1–6.6 节 PDF 页 17–25 9 页 Base PCS 的寄存器、栈、调用、返回、传参和互操作
Programmer’s Guide 第 5 章 PDF/手册页 57–68 12 页 汇编文件、GNU/Arm 语法和 interworking 背景
Programmer’s Guide 第 6.4 节 第 82 页附近 选读 B/BL/BX/BLX 与函数边界

本地 AAPCS32 文档的相关小节:

1
2
3
4
5
6
6.1 Machine Registers
6.2 Processes, Memory and the Stack
6.3 Subroutine Calls
6.4 Result Return
6.5 Parameter Passing
6.6 Interworking

原文没有需要单独打开查看的架构图。若想核对寄存器表,直接看:

  • AAPCS32 第 17 页,Core registers and AAPCS usage 表。
  • AAPCS32 第 20–21 页,The Stack
  • AAPCS32 第 23–24 页,参数分配 Stage A/B/C。

2. 先建立一个函数调用的全流程

假设:

1
2
3
4
5
6
uint32_t f(uint32_t a, uint32_t b);

uint32_t g(void)
{
return f(10, 20) + 1;
}

调用点附近的逻辑可理解为:

1
2
3
4
mov  r0, #10       @ 参数 a
mov r1, #20 @ 参数 b
bl f @ LR = 返回地址,PC = f
add r0, r0, #1 @ f 的返回值在 r0

这里已经包含 AAPCS 的三类规则:

  1. 前四个整型参数优先使用 r0-r3
  2. BL 把返回地址放入 LR
  3. 一个 32 位返回值使用 r0

但调用者不能假定调用后 r1-r3/r12/LR 仍保持原值,因为它们属于可破坏寄存器。


3. 核心寄存器的 ABI 角色

3.1 一张真正应该记住的表

寄存器 AAPCS 角色 谁负责跨函数调用保持
r0-r3 参数、结果、过程内临时量 caller
r4-r8 局部变量寄存器 callee
r9 平台寄存器或额外 callee-saved 由平台 ABI 明确
r10 局部变量寄存器 callee
r11 局部变量/常用帧指针 callee
r12 / ip intra-procedure-call scratch,链接器也可使用 caller
r13 / sp 栈指针 callee 必须恢复
r14 / lr 链接寄存器 caller 不能假定调用后保留
r15 / pc 程序计数器 控制流

压缩记忆:

1
2
3
4
r0-r3、r12:调用者别指望它们跨 BL 不变
r4-r11:被调用者用了就要保存并恢复
sp:返回时必须恢复
lr:BL 会改它,叶函数和非叶函数处理不同

r9 必须看本平台约定。它可能是静态基址、线程寄存器,也可能被当作 callee-saved。写可移植汇编时不要擅自拿它当普通 scratch。

3.2 caller-saved 的真正含义

不是说“caller 每次都必须保存 r0-r3”,而是:

如果调用者在 BL 之后还需要某个 caller-saved 寄存器里的旧值,调用者必须在调用前把这个活跃值移到安全位置。

例如:

1
2
3
mov  r4, r0        @ 之后还需要原 r0,放到 callee-saved r4
bl helper
add r0, r0, r4

当前函数既然改写了 r4,它自己作为 callee 又必须在入口保存调用者原来的 r4

1
2
3
push {r4, lr}
...
pop {r4, pc}

保存责任是相对调用边界而言的,不是某个寄存器永远只能由固定一方使用。

3.3 callee-saved 的真正含义

被调用函数可以使用 r4-r11,但返回时必须让调用者看到进入前的值。典型非叶函数:

1
2
3
4
5
6
my_func:
push {r4-r6, lr}
...
bl helper
...
pop {r4-r6, pc}

若函数根本没改 r5-r11,就没有必要机械保存全部寄存器。编译器会根据实际活跃集合生成最小保存集。


4. BLLR 与叶函数/非叶函数

4.1 BL target

BL 完成:

1
2
LR ← 返回位置
PC ← target

被调用函数通常通过:

1
bx lr

返回。BX 还会根据目标地址最低位处理 ARM/Thumb 指令状态。

4.2 为什么非叶函数通常保存 LR

叶函数不再调用其他函数,可以直接保留入口时的 LR

1
2
3
leaf:
add r0, r0, #1
bx lr

非叶函数执行第二次 BL 时,自己的 LR 会被新的返回地址覆盖:

1
2
3
4
non_leaf:
push {lr}
bl helper
pop {pc}

因此“LR 是返回地址”还不够准确。更准确的是:

LR 是最近一次链接操作写入的返回信息;需要跨下一次 BL 保留时,必须先保存。

4.3 BBLBXBLX

指令 主要用途
B target 跳转,不建立新返回链
BL target 调用,写 LR
BX Rm 跳到寄存器地址,可切换 ARM/Thumb 状态
BLX target/Rm 调用并支持状态切换

函数指针调用通常不能简单替换成固定标签 BL,要考虑地址最低位携带的指令状态信息。


5. 栈模型与对齐

5.1 满递减栈

AAPCS32 通常使用向低地址增长的 full-descending stack:

1
2
3
4
较高地址:较早的数据
...
当前 SP → 当前栈顶已用位置
较低地址:继续分配的方向

典型压栈:

1
push {r4, lr}

STMDB sp!, {r4, lr} 的常用别名。先降低 SP,再保存。

5.2 两级对齐要求

AAPCS 的核心约束:

1
2
任意时刻:SP mod 4 = 0
公共接口:SP mod 8 = 0

“公共接口”包括正常的 ABI 函数调用边界。手写汇编在执行 BL c_function 前,必须确保 SP 8 字节对齐。

错误例:

1
2
push {lr}             @ 只压 4 字节,若入口 8 对齐,此时变为 4 mod 8
bl c_handler @ 违反公共接口要求

可采用:

1
2
3
push {r4, lr}         @ 共 8 字节
bl c_handler
pop {r4, lr}

或者显式补齐,但保存与恢复布局必须一致。

5.3 为什么“现在没用 64 位值”也要对齐

被调用 C 函数内部可能:

  • 保存双字数据;
  • 调用另一个使用双字对齐的函数;
  • 使用编译器生成的 LDRD/STRD
  • 在优化后改变栈布局。

调用者不能根据当前反汇编碰巧没出问题,就违反 ABI。

5.4 异常栈也要履行 AAPCS

硬件进入 IRQ 模式后并不会自动把 SP_irq 调整为 C ABI 所需布局。入口汇编若要调用 C dispatcher,必须主动保证:

1
2
3
4
SP 有效
SP 8 字节对齐
需要保留的异常现场已经入栈
返回所需的 SPSR/PC 不会被 C 函数覆盖

“异常入口不属于普通 C 调用”不能成为调用 C 时破坏 AAPCS 的理由。


6. 参数分配:先寄存器,后栈

6.1 最常用规则

对 32 位整数、指针等一个字大小的参数:

1
2
3
4
5
第 1 个 → r0
第 2 个 → r1
第 3 个 → r2
第 4 个 → r3
第 5 个及以后 → 调用者栈

例:

1
2
void f(uint32_t a, uint32_t b, uint32_t c,
uint32_t d, uint32_t e);

入口时:

1
2
r0=a, r1=b, r2=c, r3=d
e 位于调用者为参数准备的栈区域

被调函数若先 push 了寄存器,e 相对于当前 SP 的偏移会变化。汇编作者必须根据自己的序言重新计算,不能永远写 [sp]

6.2 64 位参数的偶数寄存器对齐

需要双字对齐的参数进入核心寄存器时,起始寄存器号要为偶数。例如:

1
void f(uint32_t a, uint64_t b);

典型分配:

1
2
3
a → r0
r1 跳过
b 的低/高字 → r2/r3(具体字序还受端序规则约束)

不能把 b 想当然放在 r1/r2

6.3 参数可能被寄存器和栈拆分

如果核心寄存器还有空间、而某个多字参数装不完,标准在满足条件时允许前半放剩余寄存器,后半放栈。这就是为什么复杂原型最好:

  1. 先用 C 写一个最小函数;
  2. 用目标编译选项生成 .s 或反汇编;
  3. 再写手工汇编边界;
  4. 最后用 AAPCS 规则裁决,而不是凭印象猜。

6.4 结构体按值传参

复合类型传参时,标准按其内存内容复制,大小向 4 字节倍数处理;需要时调用者先生成临时副本。小结构体可能占多个核心寄存器,也可能部分或全部放栈。

接口设计上,内核汇编边界优先使用:

1
void handler(struct frame *frame);

而不是把大型结构体按值传递。一个指针只占 r0,布局也更容易稳定和检查。

6.5 soft-float 对当前项目的意义

当前项目使用 -mfloat-abi=soft,基础理解是:

  • 浮点参数不采用 hard-float 的 VFP 参数寄存器 ABI。
  • 它们按 Base PCS 的核心寄存器/栈规则传递。
  • 所有目标文件和库必须使用兼容浮点 ABI。

若一部分按 soft-float、一部分按 hard-float 编译,即使函数名和 C 原型相同,调用边界也可能完全不兼容。


7. 返回值规则

常见返回位置:

返回类型 位置
小于等于 32 位的基础类型 r0
64 位基础类型 r0-r1
可按四个字表达的基础结果 最多 r0-r3
不大于 4 字节的小复合类型 r0 中按内存装载效果返回
大于 4 字节或大小不能静态确定的复合类型 调用者提供结果内存

7.1 隐藏的结构体返回指针

例如:

1
2
3
4
5
6
struct Pair {
uint32_t x;
uint32_t y;
};

struct Pair make_pair(uint32_t a, uint32_t b);

Pair 大于 4 字节。Base PCS 通常让调用者分配结果空间,并把其地址作为隐藏参数放入 r0。于是显式参数向后顺延:

1
2
3
r0 = result_buffer
r1 = a
r2 = b

这就是为什么只看 C 源码会误以为 ar0。写汇编实现复合返回函数时必须知道隐藏参数。

7.2 返回后哪些寄存器可信

若函数返回 32 位结果,只有约定的 r0 承载结果。不能因为某次反汇编中 r1 恰好还有中间值,就把它当第二返回值使用。

多返回信息更适合:

  • 用结构体指针输出;
  • 明确返回一个 ABI 支持的复合类型;
  • 或将次要信息放调用者提供的对象中。

8. AAPCS Stage A/B/C 的工程化理解

原文第 23–24 页用 Stage A、B、C 严格定义参数分配。第一次不需要背规则编号,但要理解算法顺序。

Stage A:初始化分配状态

1
2
NCRN = r0          下一可用核心寄存器
NSAA = 当前 SP 下一栈参数地址

如果函数用内存返回大结构体:

1
2
r0 = 结果缓冲区地址
NCRN 从 r1 开始

Stage B:预处理参数

对每个参数先确定:

  • 实际传递大小;
  • 是否需要符号/零扩展到一个字;
  • 复合对象是否需要调用者副本;
  • 大小是否向 4 字节倍数取整;
  • 是否要求 8 字节对齐。

Stage C:分配到寄存器或栈

依次尝试:

  1. 满足双字对齐要求时,把 NCRN 调到偶数寄存器。
  2. 参数能完全放入剩余核心寄存器,就放寄存器。
  3. 满足标准条件时,可在寄存器和栈之间拆分。
  4. 核心寄存器耗尽后,后续参数放栈。
  5. 栈地址按参数需要对齐,再复制参数。

对移植者而言,最重要的不是手算任意复杂结构体,而是知道:

参数位置由完整原型、类型大小、对齐、返回方式和 ABI 共同决定,不能只按“第几个参数”机械判断。


9. 汇编函数怎样正确调用 C

假设 IRQ 汇编已经建立:

1
2
3
4
5
6
7
8
struct irq_frame {
uint32_t r[13];
uint32_t lr_irq;
uint32_t pc;
uint32_t cpsr;
};

void irq_dispatch(struct irq_frame *frame);

调用边界至少应满足:

1
2
3
4
5
6
@ 先把异常所需寄存器、SPSR、返回 PC 保存到稳定内存
mov r0, sp @ 第一个参数:frame 指针
@ 此处确认 sp % 8 == 0
bl irq_dispatch
@ 不假定 r0-r3、r12、lr 仍是调用前的值
@ 从 frame 恢复,而不是依赖被调 C 保留 caller-saved 状态

关键点:

  1. r0 是地址,不是 frame 的第一项内容。
  2. C handler 可以合法改写 r0-r3/r12/LR
  3. 被中断任务的完整现场必须在调用 C 之前保存。
  4. SPSR 不属于 AAPCS 普通寄存器保存合同,异常入口必须自己管理。
  5. 从 C 返回后,异常恢复代码依赖的是内存中的 frame。

10. 普通任务上下文为什么常保存 r4-r11

如果上下文切换函数在一个正常 ABI 调用边界发生:

1
void context_switch(Context *run, Context *heir);

调用者本来就必须认为:

1
r0-r3、r12、lr 可被这次调用破坏

调用者若有跨调用活跃值,编译器已经把它们放到 callee-saved 寄存器或栈。切换函数要让任务未来“像这次调用正常返回一样继续”,核心是保持:

1
2
3
r4-r11
sp
返回控制流

这就是普通上下文可利用 AAPCS 缩小保存集的原因。

但异常可发生在任意指令之间,不能假定当前正处于函数调用边界。因此异常现场通常必须保护更完整的 r0-r12、返回 PC 和 CPSR。

1
普通任务切换最小集 ≠ 异常现场完整集

11. 反汇编阅读方法:从 C 原型向寄存器映射

看到一个汇编调用时,按固定顺序分析:

第一步:找到完整 C 原型

包括:

  • 返回类型;
  • 所有参数的精确类型;
  • 是否变参;
  • 结构体是否按值;
  • 编译单元的 float ABI。

第二步:判断有没有隐藏结果指针

返回大型复合类型时,先把 r0 留给结果地址。

第三步:按类型大小和对齐分配 r0-r3

遇到 64 位或复合类型,不要只数参数个数。

第四步:在调用前向上追踪寄存器定义

例如:

1
2
3
mov r1, r5
ldr r0, [r4, #8]
bl handler

结合原型解释为:

1
2
参数 1 = *(uint32_t *)(r4 + 8) 或对应类型值
参数 2 = r5 当前值

r0[r0] 必须区分:前者是寄存器中的值,后者才是访问该值指向的内存。

第五步:调用后只信 ABI 保证

检查调用者是否错误地继续使用未保存的 r0-r3/r12/LR


12. 最常见的 ABI Bug 与定位方式

Bug 1:只在优化版本崩溃

检查:

  • 汇编函数是否改写 r4-r11 却没有恢复;
  • 是否把 caller-saved 值跨 BL 使用;
  • inline asm 是否缺少 clobber 声明;
  • C 与汇编声明是否一致。

Bug 2:加一条日志后异常返回就坏

日志函数是一次新的 BL,会改写 LR 和 caller-saved 寄存器。检查:

  • 异常返回地址是否在调用日志前存入内存;
  • SPSR 是否安全保存;
  • 栈是否仍 8 字节对齐;
  • 是否发生 IRQ 栈溢出。

Bug 3:第五个参数值错误

检查:

  • 被调函数序言压栈后,栈参数偏移是否重算;
  • 调用点 SP 是否对齐;
  • 前面是否存在 64 位参数造成寄存器跳位;
  • 是否存在隐藏结构体返回指针。

Bug 4:某函数返回后 r4 被污染

这是典型 callee 违约。对照函数入口/出口的保存集合,确认所有返回路径都恢复了相同寄存器和 SP。

Bug 5:链接成功但浮点参数全错

检查所有目标文件与库的:

1
2
3
-mfloat-abi
-mfpu
ELF attributes

函数名匹配不代表 ABI 匹配。


13. 为 Zynq 移植制定统一 ABI 基线

第一版应把 ABI 固定到构建系统,而不是靠个人记忆:

1
2
3
4
5
6
7
CPU:Cortex-A9
架构:ARMv7-A
指令状态:当前工程配置为 -marm
浮点 ABI:soft
字节序:小端
栈公共接口:8 字节对齐
r9 用法:由整个工程统一

汇编文件要使用与 C 一致的选项,并明确:

  • 函数符号类型与可见性;
  • ARM/Thumb 状态;
  • 必要的 .align
  • 不可执行栈等元数据(按工具链需要);
  • 保存集合和栈帧布局注释。

每个 C/汇编边界建议写一小段“ABI 合同”:

1
2
3
4
5
输入:r0=run, r1=heir
输出:不按普通方式返回到原任务,恢复 heir 的返回点
破坏:r0-r3,r12,flags
保持:由上下文对象恢复 r4-r11,sp,pc
前置:IRQ 状态和锁状态由调用者满足

14. 本目标的掌握标准

完成本章后,应能不看资料回答:

  • r0-r3r4-r11 的保存责任分别是什么。
  • 为什么 caller-saved 不等于每次都保存。
  • 为什么非叶函数通常要保护 LR。
  • 5 个 32 位参数放在哪里。
  • 64 位参数为什么可能跳过奇数号寄存器。
  • 大结构体返回为什么会占用隐藏的 r0
  • 为什么汇编调用 C 前要让 SP 8 字节对齐。
  • 为什么异常入口调用 C 前必须先把完整现场放入内存。
  • 为什么普通任务切换可以主要保存 callee-saved 状态,而 IRQ 现场不行。
  • 为什么当前 soft-float 必须在整个链接单元中保持一致。

30 秒复述版

AAPCS32 是 C 与汇编之间的调用合同。r0-r3 用于参数、返回和临时量,由调用者保护活跃值;r4-r11 由被调用者保持,r12 是 scratch,BL 会改 LR。普通 32 位参数先放 r0-r3,再放栈;64 位参数注意偶数寄存器和 8 字节对齐;大结构体返回会通过 r0 传入隐藏结果指针。SP 平时至少 4 字节对齐,在公共函数接口必须 8 字节对齐。异常汇编若要调用 C,也必须先完整保存异常现场并满足这份合同。


15. 下一主题的接口

有了模式 bank 和 AAPCS,下一步可以真正解释上下文:

1
2
3
4
保存任务 A 的 r4-r11、sp、返回控制流
恢复任务 B 的对应值
恢复 B 的 sp 后切换任务栈
恢复 B 的 pc 后切换执行流

同时还要学习 LDR/STRSTM/LDMMRS/MSRSRS/RFE 等指令怎样把这套合同落实到内存。

用常见错误和函数边界检验 ABI

四、第一张必须掌握的表:寄存器调用角色

寄存器 AAPCS 名称 普通用途 谁负责保证调用后仍有效
r0 a1 参数1、返回值、临时值 调用者
r1 a2 参数2、返回值一部分、临时值 调用者
r2 a3 参数3、临时值 调用者
r3 a4 参数4、临时值 调用者
r4-r8 v1-v5 局部变量寄存器 被调用者
r9 v6/SB/TR 平台寄存器或局部变量寄存器 由平台 ABI 决定
r10 v7 局部变量寄存器 被调用者
r11 v8/FP 局部变量或帧指针 被调用者
r12 IP 函数内部/链接器临时寄存器 调用者
r13 SP 栈指针 被调用者必须维护
r14 LR 返回地址 调用者不能假设调用后仍保留原值
r15 PC 程序计数器 控制流寄存器

把它压缩成两组:

1
2
3
4
5
6
caller-saved / call-clobbered:
r0-r3, r12, LR, CPSR 条件标志

callee-saved / call-preserved:
r4-r8, r10-r11, SP
r9 由平台规则决定

Caller-saved 到底是什么意思

Caller-saved 不是“调用者每次必须保存”。

准确含义是:

被调用函数可以破坏这些寄存器。如果调用者在函数返回后还需要原值,调用者必须在调用前自己保存。

例如:

1
2
mov r0, #123
bl some_c_function

执行完 BL 后,不能假设 r0 仍然等于 123,因为 r0 是 caller-saved。

Callee-saved 到底是什么意思

准确含义是:

如果被调用函数需要使用这些寄存器,就必须在返回前恢复成进入函数时的值。

例如:

1
2
3
4
5
6
my_function:
push {r4, lr}
mov r4, r0
bl another_function
mov r0, r4
pop {r4, pc}

my_function 使用了 r4,因此进入时保存、返回时恢复。它使用 r4 跨越 another_function 的调用,是因为 another_function 也必须保持 r4


五、为什么 r12 不能用来跨函数保存重要数据

r12 又叫 IP,是 Intra-Procedure-call scratch register。

除了被调用函数可以修改它,链接器还可能在调用点插入 veneer:

1
2
3
caller
└── BL veneer
└── 跳到真正的 callee

veneer 用于:

  • 目标地址超过 BL 的直接跳转范围。
  • ARM/Thumb interworking。
  • 某些动态链接场景。

AAPCS 允许 veneer 修改:

  • r12
  • CPSR 条件标志

因此:

即使读者查看当前反汇编发现一次 BL 没有修改 r12,也不能把需要跨调用保存的值放在 r12 中。重新链接、地址变化或加入新代码后,链接器可能插入 veneer。


六、r9 为什么不能简单死记

r9 的角色由平台 ABI 决定,常见选择:

  • SB:Static Base,静态基址。
  • TR:Thread Register,线程寄存器。
  • v6:普通 callee-saved 变量寄存器。

所以通用 AAPCS32 不能无条件说“r9 永远是普通 callee-saved 寄存器”。

对一套现有 ARMv7-A 工程进行核对时,正确做法是:

  1. 检查工具链、平台 ABI 和 ELF attributes。
  2. 搜索项目是否固定使用 r9。
  3. 分析编译器实际生成的代码。

在没有完成这三步前,手写汇编应保守处理 r9,不要擅自把它作为随意破坏的临时寄存器。


七、栈规则

AAPCS32 使用 full-descending stack:

  • 栈向低地址增长。
  • SP 指向当前已分配栈区域的最低地址。
  • PUSH 通常等价于 STMDB sp!, {...}
  • POP 通常等价于 LDMIA sp!, {...}

任何时刻的基本约束

1
SP mod 4 == 0

即 SP 至少保持 4 字节对齐。

公共调用边界的额外约束

1
SP mod 8 == 0

也就是当代码进入一个符合 AAPCS 的公开函数接口时,SP 必须为 8 字节对齐。

这里最实用的判断方法是:

1
执行 BL 调用普通 C 函数之前,检查 SP 是否 8 字节对齐。

常见错误

假设进入汇编函数时 SP 已经 8 字节对齐:

1
2
push {r4}        // SP -= 4,现在只有 4 字节对齐
bl c_function // 错误:调用边界不再满足 8 字节对齐

可以改为:

1
2
3
push {r4, lr}    // SP -= 8,仍保持 8 字节对齐
bl c_function
pop {r4, lr}

八、子程序调用与返回

BL

BL target 完成:

1
2
下一条指令的返回信息 -> LR
target -> PC

在 ARM 状态下,LR[0] == 0;从 Thumb 状态调用时,返回信息中 LR[0] == 1,用于指示返回后的指令集状态。

BLX

BLX 可以调用寄存器中的目标地址,并正确处理 ARM/Thumb 状态切换和返回状态。

BX LR

叶子函数没有再次调用其他函数、也没有把 LR 用作临时寄存器时,通常可以:

1
bx lr

如果函数内部还要执行 BL,新的 BL 会覆盖 LR,因此通常先保存 LR:

1
2
3
4
function:
push {r4, lr}
bl another
pop {r4, pc}

九、参数传递:第一轮先掌握常用规则

1~4 个 32 位参数

1
int f(int a, int b, int c, int d);

进入 f 时:

1
2
3
4
r0 = a
r1 = b
r2 = c
r3 = d

第 5 个及后续参数

1
int f(int a, int b, int c, int d, int e, int f);

进入函数时:

1
2
3
4
5
6
r0 = a
r1 = b
r2 = c
r3 = d
[sp] = e
[sp + 4] = f

这里的 [sp] 是函数入口时的 SP。函数自己执行 prologue 改变 SP 后,参数相对当前 SP 的偏移也会变化。

小于 32 位的整数

charshort 等整数参数在传递前扩展到 32 位:

  • 有符号类型进行符号扩展。
  • 无符号类型进行零扩展。

64 位参数

64 位参数占用两个连续核心寄存器,并且必须从偶数编号寄存器开始:

1
2
3
r0:r1
或者
r2:r3

例如:

1
void f(int a, long long b, int c);

概念分配结果:

1
2
3
4
r0    = a
r1 = 跳过,用于满足 b 的 8 字节对齐规则
r2:r3 = b
[sp] = c

64 位值在两个寄存器中的具体字顺序按它从内存中通过一次 LDM 加载后的表现确定,并受数据端序影响。

结构体参数

结构体属于 Composite Type。第一轮只需知道:

  • 小结构体可能拆分到多个 r0-r3。
  • 寄存器不够时,可能一部分在寄存器、一部分在栈。
  • 需要 8 字节对齐的结构体会触发偶数寄存器/栈对齐规则。
  • 尺寸不能由调用双方静态确定时,通常改为传递指向副本的指针。

结构体的完整 Stage A/B/C 分配算法先做到“会查”,暂时不要求背诵。


十、返回值

base PCS 常用返回规则:

C 返回类型 返回位置
char/short/int/enum/pointer r0
long long、64 位基础类型 r0-r1
128 位 containerized vector r0-r3
不超过 4 字节的小结构体 r0
较大结构体 调用者提供内存,隐藏地址通常通过 r0 传入

较大结构体返回可以理解为编译器在概念上改写:

1
struct big make_value(int x);

类似:

1
void make_value(struct big *hidden_result, int x);

此时:

1
2
r0 = hidden_result
r1 = x

十一、CPSR 在普通调用边界上的规则

普通函数返回后,调用者不能依赖以下 CPSR 标志仍保持调用前的值:

1
2
N Z C V Q
GE[3:0]

也就是说,不能写出依赖跨函数调用条件标志的逻辑:

1
2
3
cmp r0, #0
bl some_function
beq label // 错误:BL 之后条件标志可能已经改变

应该在调用后重新比较:

1
2
3
bl  some_function
cmp r0, #0
beq label

A/I/F/M 属于特权状态,不是普通应用函数随意修改的临时状态。特权系统代码如果确实修改这些位,必须根据自己的接口合同和异常/并发要求进行恢复。


十二、现有 ARMv7-A 工程中最值得看的 C/汇编边界

边界 1:C 调用汇编上下文切换

C 文件:

1
目标工程/kernel/arch/armv7/cortex-a/interface/context/armv7a_context.c

调用:

1
srh_arch_context_switch(prev_ctx, next_ctx);

根据 AAPCS32:

1
2
r0 = prev_ctx
r1 = next_ctx

汇编入口:

1
2
3
4
5
srh_arch_context_switch:
...
str sp, [r0] // 保存到 prev_ctx
...
ldr sp, [r1] // 从 next_ctx 取新 SP

这说明 C 与汇编在入口参数上的合同完全可由 AAPCS32解释。

但是它不是普通“调用后回到同一执行流”的函数:它切换 SP 并恢复另一个任务,最终通过 RFE 进入新上下文。因此:

  • 进入汇编函数的参数边界遵循 AAPCS。
  • 内部上下文帧和离开方式由操作系统自己定义。

这正是 AAPCS 与上下文切换的边界。

边界 2:IRQ 汇编调用 C

文件:

1
目标工程/kernel/arch/armv7/cortex-a/arm_context.S

IRQ 入口先分配 64 字节:

1
2
sub   sp, sp, #64
stmia sp, {r0-r12, lr}

64 是 8 的倍数。如果异常栈初始值按 8 字节对齐,这些 BL 的 SP 仍满足公共调用边界要求:

1
2
3
4
bl srh_irq_handler
bl srh_context_get_local_isr_tail_ctx
bl sched_check_is_need_isr_tail
bl srh_context_isr_tail_prepare

其中:

1
2
3
bl  srh_context_get_local_isr_tail_ctx
mov r4, r0
bl sched_check_is_need_isr_tail

解释:

  1. C 函数把返回值放在 r0
  2. 下一次 C 调用可以破坏 r0
  3. 汇编把这个需要跨调用保存的值移到 r4
  4. r4 是 callee-saved,因此 sched_check_is_need_isr_tail 必须保持它。

源代码注释“r4 不是 caller-saved”表达的就是这一规则,更标准的说法是:

r4 是 callee-saved / call-preserved register。

边界 3:IRQ tail 主动对齐 SP

同一文件中:

1
bic sp, sp, #7

它把 SP 向下对齐到 8 字节边界,然后再调用 C:

1
2
3
bl srh_context_get_local_isr_tail_ctx
bl srh_context_isr_tail_normalize_current
bl sched_isr_exit_schedule

源码注释已经明确指出原因:被中断线程的 SP 在任意时刻只保证 word alignment,而执行 BL 进入 C 的公共调用边界要求 8-byte alignment。

这段代码是项目中学习 AAPCS 栈对齐的最佳实例。

边界 4:启动汇编调用 C

arm_boot.S 中:

1
bl mmu_early_init

链接脚本把临时启动栈顶按 16 字节对齐,因此也满足 AAPCS 的 8 字节调用边界要求。

arm_startup.S 中:

1
2
3
bl setup_percpu_stacks
bl _cpu_init
b srh_kernel_init

最后的 b srh_kernel_init 是尾调用式转移:它不需要返回到 kernel_entry。进入 C 函数前仍然需要准备合法 SP 和正确参数。


十三、第一轮阅读范围

不要从头读完整个 AAPCS32。第一轮只看第 6 章以下部分:

章节 目的 第一轮要求
6.1.1 Core registers 寄存器角色 必须掌握
6.1.1.1 Values larger than 32 bits 64/128 位寄存器占用 理解
6.2.1 The Stack 栈模型 必须掌握
6.2.1.1 Universal constraints 4 字节对齐 必须掌握
6.2.1.2 Public interface 8 字节对齐 必须掌握
6.3 Subroutine Calls BL/LR/返回 必须掌握
6.3.1 Use of IP r12/veneer 理解
6.4 Result Return 返回值 掌握常用类型
6.5 Parameter Passing 参数分配 掌握常用规则,复杂结构会查即可

当前项目是 soft-float,第一轮跳过:

  • 6.1.2.1 VFP register usage conventions 的细节。
  • 第 7 章 VFP 参数传递变体。
  • 语言专用的复杂接口。

从规则走到实际实现

下面回到本章对应的小系统实现。这里不再假定读者已经打开源码,而是把关键地址、数据结构、指令序列和返回路径直接放在文章中解释。

调用规范解决的是模块之间的约定

编译器可以把局部变量放在寄存器,也可以放在栈里。调用另一个函数时,如果双方没有一致规则,就无法知道哪些值仍然有效。

AAPCS32 规定这种边界约定。本工程采用普通 32 位参数传递、ARM 状态和 -mfloat-abi=soft。先限定这组条件,再讨论最小寄存器现场;不能一边不保存浮点寄存器,一边允许任务任意执行 VFP/NEON。

寄存器 常见作用 跨普通调用的责任
r0-r3 参数、结果、临时值 调用者需要时自行保存
r4-r8 局部值 被调用者保持
r9 平台寄存器或额外保存寄存器 必须按平台 ABI 处理
r10-r11 局部值、可能的帧指针 被调用者保持
r12/ip 临时值,链接器 veneer 也可能使用 不保证保持
r13/sp 当前栈位置 函数返回时恢复约定值
r14/lr 链接返回地址 发起嵌套调用时必须保护原返回点
r15/pc 当前执行位置 由分支与返回改变

本工程直接保存 r4-r11,包含 r9,避免遗漏平台保留寄存器。不能把“r9 一定是普通局部变量”写成通用规则。具体定义见 AAPCS32 的 Machine Registers 与 The Stack

参数并不总是“第一个到第四个进寄存器”

对四个普通 32 位整数,规则很直观:

1
uint32_t add4(uint32_t a, uint32_t b, uint32_t c, uint32_t d);

入口时 a/b/c/d 分别位于 r0/r1/r2/r3,32 位整数结果通常放 r0。增加第五个 32 位参数,在这个简单例子中就需要从调用者提供的栈参数区取。

64 位参数还涉及寄存器配对和对齐。以如下原型为例:

1
void example(uint32_t a, uint64_t b, uint32_t c);

在这里讨论的基本 PCS 中,a 使用 r0;b 需要双字对齐的寄存器对,放 r2:r3,r1 可能空出来;c 落到栈上。不能按“还有一个空闲 r1,所以 c 就放 r1”理解。

结构体返回更不能一概按 r0 处理:某些结果需要调用者提供内存,并通过隐藏参数传入结果地址。浮点变体、复合类型、可变参数有额外规则。调试陌生汇编时先看完整函数原型与编译选项,再推断寄存器。

BL 保存的是返回地址,不是整个函数现场

下面是解释调用关系的完整小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.syntax unified
.arm

.global call_and_add_one
.type call_and_add_one, %function
call_and_add_one:
push {r4, lr} @ 保持 8 字节栈对齐,也保护自己的返回点
mov r0, #10
mov r1, #20
bl add_two
add r0, r0, #1
pop {r4, pc}

.type add_two, %function
add_two:
add r0, r0, r1
bx lr

BL 会改 LR。因此 call_and_add_one 在执行 BL 前必须把来自其调用者的 LR 保存起来;否则最后只能找到内部调用之后的位置。

叶子函数 add_two 没有继续发起调用,又没有修改需要保持的寄存器,可以直接 BX LR 返回。r0 作为结果寄存器被改变是正常的。

r12 也不能被当成跨 BL 的保险箱:链接器为远跳转或 ARM/Thumb 互操作插入的 veneer 可能使用它。只有检查源代码而不看最终反汇编,很容易漏掉这种情况。

栈为什么有两个对齐要求

AAPCS 的基本栈约束要求 SP 至少按 4 字节对齐;在公共调用接口处要求 8 字节对齐。栈通常向低地址增长,SP 指向当前已分配栈区的边界。

对齐要求的区别会直接影响 IRQ:

1
2
3
4
函数入口:SP % 8 == 0
函数内部:可能临时只满足 SP % 4 == 0
↑ IRQ 可以恰好发生在这里
调用 C handler:必须重新保证 SP % 8 == 0

因此不能把“异常发生时任务 SP 不是 8 字节对齐”直接判成损坏。基础版把 C handler 放到链接脚本保证 8 字节对齐的独立栈上,解决的是 C 调用边界对齐;它不要求被打断任务的 SP 在任意时刻都 8 字节对齐。

在普通汇编函数中,单独 push {lr} 会减去 4 字节。如果紧接着 BL 到 C,而原 SP 为 8 字节对齐,那么这个调用就破坏了规则。可以成对保存,或显式补齐,再在返回前精确恢复。

地址和值要分开读

1
2
3
ldr r0, =task_a_context
ldr r1, [r0, #32]
str sp, [r0, #32]

三条指令依次表示:

  1. 把 task_a_context 的地址装入 r0;
  2. 从该地址加 32 的内存读取一个 32 位值;
  3. 把当前 SP 的值写入该地址加 32 的内存。

第一条的 = 是汇编伪指令语法,最终可能变成 literal load 或其他适合的装常量方式;它不是“取 task_a_context 的第一个字段”。

C 中对应的思路是:

1
2
3
Context_Control *p = &task_a_context;
uint32_t saved_sp = p->sp;
p->sp = current_sp;

一个上下文对象保存的是地址和数值;真正把这些数装回寄存器的动作仍由汇编完成。

STM/LDM 怎样决定内存布局

STMIA 是从基址向高地址存储多个寄存器,LDMIA 反向执行加载。寄存器按编号递增排列,不按花括号里的视觉书写顺序任意排序。

1
stmia r0, {r4-r11}

对应:

1
2
3
4
[r0 +  0] = r4
[r0 + 4] = r5
...
[r0 + 28] = r11

没有 !,r0 自身不做写回。若写成 stmia r0!, {...},还会更新基址。上下文保存通常不希望无意改变保存对象指针,必须看清这个符号。

栈常用的是另一对:

1
2
stmdb sp!, {r4, lr}   @ PUSH 的常见等价写法
ldmia sp!, {r4, lr} @ POP 的常见等价写法

假设原 SP 为 0x2000,第一条完成后 SP 为 0x1ff8,r4 位于 0x1ff8,LR 位于 0x1ffc。恢复后 SP 回到 0x2000。FD 是 full descending 的栈命名方式,必须结合存储或加载操作理解,不能把不同指令中的后缀机械替换。

为什么普通 context 不保存 r0-r3

基础版切换函数本身就是一次正常调用:

1
_CPU_Context_switch(&task_a_context, &task_b_context);

对编译器来说,这个调用可以破坏 r0-r3、r12 和条件标志。若任务在调用后还需要某个值,编译器会提前把它放进 callee-saved 寄存器或栈里。

于是底层只需要维持普通调用所承诺的继续执行状态:

1
2
3
4
r4-r11:跨调用保持的通用寄存器
sp: 任务自己的调用栈
lr: 这次切换调用结束后的继续点
控制位:工程额外约定的模式与中断屏蔽状态

r0/r1 此时已经是两个 context 指针,早已不再是任务在函数调用前随意使用的临时值。保存它们并不能还原“调用前的一切”,也没有必要。

IRQ 没有这个编译器配合过程。它可以打断一个尚未执行完的表达式,r0 中可能就有不可丢的中间结果。因此异步现场必须另外处理这些寄存器。

C 结构体与汇编偏移必须共同维护

基础版的布局为:

1
2
3
4
5
6
typedef struct {
uint32_t r4, r5, r6, r7, r8, r9, r10, r11;
uint32_t sp;
uint32_t lr;
uint32_t cpsr;
} Context_Control;

对应的关键偏移:

1
2
3
4
#define ARM_CONTEXT_SP   32
#define ARM_CONTEXT_LR 36
#define ARM_CONTEXT_CPSR 40
#define ARM_CONTEXT_SIZE 44

44 字节大小并不违背 SP 8 字节对齐要求:结构体不是函数栈指针。真正需要向 8 字节对齐的是任务入口和调用边界的 SP。

工程使用 _Static_assert(offsetof(...)) 检查布局。这个检查比注释重要,因为给结构体插入字段以后,手写汇编不会自动知道所有偏移已移动。

少数指令就能读通主路径

指令 在工程里的职责
MOV、ADD、SUB 搬移指针,分配栈,修正返回 PC
LDR、STR 读写 context 字段、设备寄存器
LDM、STM 批量保存和恢复寄存器
CMP 与条件执行 分 CPU、比较状态、循环清 BSS
B、BL、BX、BLX 跳转、调用与指令状态互操作
MRS、MSR、CPS 状态寄存器和模式控制
MRC、MCR 访问 CP15 系统控制接口
SRS、RFE 异常返回状态转存与恢复
DMB、DSB、ISB 数据顺序、完成与指令上下文同步
WFI、WFE、SEV 等待中断、等待事件、发送事件

理解这些指令时最好总带一个具体地址、一组寄存器值和一个栈图。只记助记符的中文翻译,遇到写回、模式切换或 LR 的两种含义时仍容易混淆。

下一篇:启动、链接与 UART

重点回看

AAPCS32 是 C 与汇编共享的合同。调用者保护易失寄存器,被调用者保护 r4-r11sp,公共调用边界保持 8 字节对齐。普通 context 的最小保存集合由这个合同推导,而 IRQ 现场不能照搬它。结构体偏移必须由静态断言与汇编常量共同约束。

本章总结

AAPCS32 是 C 与汇编共享的合同。调用者保护易失寄存器,被调用者保护 r4-r11sp,公共调用边界保持 8 字节对齐。普通 context 的最小保存集合由这个合同推导,而 IRQ 现场不能照搬它。结构体偏移必须由静态断言与汇编常量共同约束。

下一章把模式与 ABI 放进启动路径,用链接脚本和启动汇编建立第一个可观察的 C 环境。

ARMv7-A小系统实践(三):AAPCS32、栈与上下文汇编

https://goko-son626.github.io/post/armv7a-02-aapcs-assembly.html

作者

GoKo Mell

发布于

2026-06-14

更新于

2026-06-14

许可协议

评论

:D 一言句子获取中...