ARM AACPS
本文系统介绍 ARMv7-A 的函数调用规范(AAPCS),深入理解寄存器分配、函数栈帧结构及其调用约定。
1. ABI 与 AAPCS:跨模块互操作的基础
C 编译器能将程序拆分为多个独立编译的模块——.c 文件分别编译为 .o 目标文件,最终由链接器合并为可执行映像。但要让 module_a.o 中的函数能成功调用 module_b.o 中的函数,双方必须对以下问题达成一致:
- 参数放在哪里?(寄存器还是栈?)
- 返回值放在哪里?
- 哪些寄存器调用后可以随意覆盖,哪些必须由被调函数恢复原值?
- 栈的增长方向是什么?栈指针如何对齐?
这就是 Application Binary Interface (ABI) 要解决的问题。ARM 架构的 ABI 规范定义了一套规则,确保不同编译器/汇编器生成的目标文件可以链接在一起。
The Application Binary Interface (ABI) for the ARM architecture specification describes a set of rules that an ARM executable must adhere to in order to execute in a specific environment. It specifies conventions for executables, including file formats and ensures that objects from different compilers or assemblers can be linked together successfully.
ABI 有多个变体:Linux ABI for ARM、Embedded ABI (EABI) 等。而这些变体的核心组成部分就是 AAPCS — ARM Architecture Procedure Call Standard。AAPCS 自身也分为多个变体:基础标准定义整数寄存器的使用规则,VFP 变体补充浮点寄存器的传参约定。Linux ARM EABI 采用 AAPCS 基础标准 + VFP 变体作为其调用约定。
AAPCS 的前身是 ATPCS (ARM-Thumb Procedure Call Standard)。它规定了编译器和子程序调用期间寄存器和栈的使用方式。理解 AAPCS 是 C 与汇编混编的基础,也是编写高性能 ARM 代码的前提。
2. 整数寄存器角色与分组
ARMv7-A 有 16 个 32 位通用寄存器,标记为 R0–R15。AAPCS 为每个寄存器分配了确定的 PCS 名称和角色。根据原文 Table 16-1:

R13/R14/R15 是 dedicated 专用的,表示这个寄存器的用途是CPU硬件/架构强制规定的,不能随便拿它当普通变量用,也不能在函数里随意修改它的值(除非你知道自己在干什么)。
2.1 寄存器职责表
1 | +----------+----------+-------------------------------------------+ |
对于函数调用而言,这 16 个寄存器可分为三类:
- 参数/返回值寄存器(Argument / Result registers):R0–R3(PCS 名 a1–a4)。用于传递前四个函数参数,同时承担函数返回值(32 位返回值使用 R0,64 位返回值使用 R0-R1)。在函数内部也可作为临时寄存器(scratch register)使用。这些寄存器属于调用者保存(caller-saved),若调用者希望跨函数调用保留其值,需要自行保存。
- 被调用者保存寄存器(Callee-saved registers):R4–R8、R10、R11(v1–v5、v7–v8)。若被调函数需要使用这些寄存器,必须先保存其原值,并在返回前恢复,因此调用者可以假定它们在函数调用后保持不变。
- 特殊用途寄存器(Special-purpose registers):R13(SP)为栈指针,R14(LR)保存函数返回地址,R15(PC)为程序计数器;R12(IP)为过程内调用暂存寄存器(caller-saved),供编译器使用,也可能被链接器在生成跨模块调用时作为临时寄存器;R9 的用途由平台 ABI 决定,可作为静态基址寄存器(SB)、线程寄存器(TR),或普通的被调用者保存寄存器(v6)。
2.2 R12 (IP) 的深层含义:为什么链接器需要一个暂存寄存器?
ARM 的 BL (Branch with Link) 指令只能寻址 ±32MB 的范围——它使用 24 位有符号偏移。当调用者和被调用者之间的地址差超过这个范围时,链接器无法直接生成 BL 指令,必须插入一段中间代码,称为 veneer:
1 | Caller |
类似于 RISC-V 中 AUIPC + JALR 的长跳转组合,或 x86-64 中 CALL *%rax 的间接调用,ARM 借助 IP 寄存器避免了专用长跳转指令。veneer 还会用于 ARM–Thumb 状态切换和动态链接。因此 AAPCS 规定:调用者不能假设 R12 在函数调用后保持不变。
2.3 R9 的特殊地位
R9 是唯一可在不同平台上有不同语义的寄存器:
| 角色 | 缩写 | 用途 |
|---|---|---|
| Static Base | SB | 指向全局数据区基址,便于高效访问全局/静态变量(部分平台使用) |
| Thread Register | TR | 指向当前线程的数据结构(TCB/TLS),便于快速访问线程局部数据 |
| Variable v6 | v6 | 无特殊用途时,作为普通的被调用者保存寄存器(callee-saved) |
R9 是 AAPCS 中唯一由平台 ABI 决定具体用途的通用寄存器。 某些平台将其作为 SB(Static Base) 保存全局数据区基址,或作为 TR(Thread Register) 保存当前线程的线程控制块(TCB)或线程局部存储(TLS)指针;若平台没有这些需求,则 R9 作为普通的 v6 使用,遵守与其他被调用者保存寄存器(callee-saved)相同的规则。
Linux ARM EABI 上基本把r9当做v6来使用,也就是普通的 callee-saved 寄存器。线程指针通常使用 CP15(后来又演进为专门的线程指针机制),而不是 r9。
3. 函数调用的参数传递
3.1 基本规则
AAPCS 参数传递策略的核心思想是:优先使用寄存器,溢出才走栈。
- 前 4 个字 (word) 大小的参数 → R0–R3
- 第 5 个及之后的参数 → 压入栈中传递
- 子字参数(如
char,short)仍然占用整个寄存器 - 大于一个字(32-bit)的参数拆分到多个寄存器中
这意味着一个 void f(int a, char b, short c, int d) 调用中,a→R0, b→R1, c→R2, d→R3——虽然 b 和 c 只有 8 位和 16 位,但仍各自占据一个完整的 32 位寄存器。
3.2 C++ 的 this 指针惩罚
C++ 非静态成员函数会隐式传递一个 this 指针,并按照 AAPCS 放入 R0。因此,可用于传递显式参数的寄存器只剩 R1–R3。当成员函数有 4 个显式参数时,第 4 个参数(也是整个调用序列中的第 5 个参数)将按照 AAPCS 的规则通过栈传递。
这意味着在性能敏感的 C++ 代码中,参数数量应该控制在 3 个以内——比 C 函数少一个。
3.3 64 位参数的寄存器对齐规则
这是 AAPCS 中最容易踩坑的规则。64 位类型的参数(如 long long、double)必须满足两个约束:
- 在内存中按 8 字节边界对齐,这是 AAPCS 对 64 位数据类型的 ABI 要求,也便于编译器生成高效的双字访问指令。
- 在寄存器中传递时,必须从偶数编号寄存器开始连续占用两个寄存器,即只能使用 R0+R1 或 R2+R3,不能使用 R1+R2。
实例:参数顺序错误的代价
以原文 Figure 16-1 为例。假设函数签名为:
1 | void func1(int a, double b, int c); |
参数分配过程:
1 | a (int, 32-bit) → R0 [4 bytes used] |
R1 因对齐约束被跳过,c 被迫溢出到栈。每次调用都需要额外的栈操作指令。
如果将参数重新排序:
1 | void func2(int a, int c, double b); |
1 | a (int, 32-bit) → R0 [4 bytes used] |
全部 3 个参数都在寄存器中传递,零栈溢出。原文的结论是:参数列表的顺序直接影响指令条数和内存访问次数。
Sub-optimal listing of arguments can cause unnecessary spilling of variables to the stack.
— ARM Cortex-A Series Programmer’s Guide, Section 16.1
对比 x86-64 System V ABI:64 位参数同样对齐,但有 6 个整数参数寄存器 (RDI, RSI, RDX, RCX, R8, R9),溢出概率低得多。ARMv7-A 的 4 寄存器窗口使得参数排序优化在 ARM 上更为关键。
3.4 调用者与被调用者的代码模板
下面是原文提供的汇编模板。调用者侧:
1 | @ 调用者代码 |
被调用者侧:
1 | func: |
需要注意的是,实际编译器通常只会保存函数中真正使用到的被调用者保存寄存器,而不会固定保存 r4-r11。例如,一个函数若只使用了 r4,生成的序言和尾声通常为:
1 | PUSH {r4, lr} |
AAPCS 还要求公共接口(public interface)处的栈保持 8 字节对齐。因此,编译器通常会让函数入口、出口以及每次执行 BL 调用其他函数之前,都满足这一要求。实践中最常见的做法是一次保存偶数个寄存器(如 {r4, lr} 或 {r4-r8, lr}),这样 PUSH/POP 后栈仍保持 8 字节对齐。
如果保存的寄存器数量为奇数个,编译器通常会采用以下策略保持栈对齐:
- 调整栈指针(最常见)。例如在保存
{r4, r5, lr}后,再通过SUB sp, sp, #4额外预留 4 字节空间,使栈重新恢复到 8 字节对齐;函数返回前再执行ADD sp, sp, #4恢复。 - 额外保存一个占位寄存器。少数情况下,编译器也可能多保存一个实际上不会使用的寄存器(如
r6),将{r4, r5, lr}扩展为{r4, r5, r6, lr},从而凑成偶数个寄存器。不过现代 GCC、Clang 更倾向于采用第一种方式。
对于叶子函数(Leaf Function)——即不会再调用其他函数的函数——由于不存在新的函数调用,它们在函数执行期间不一定始终保持栈的 8 字节对齐。只有当函数需要继续调用其他函数时,编译器才会确保在执行 BL 前恢复到 8 字节对齐。
4. VFP 与 NEON 寄存器约定
ARMv7-A 的浮点/向量寄存器和整数寄存器是两套独立的寄存器文件。当硬件浮点单元 (VFP) 存在时,AAPCS 定义了两套参数传递策略。
4.1 VFPv3 寄存器布局
VFPv3 提供:
1 | s0-s31: 32 个单精度寄存器 (32-bit each) |
保存约定:
| 寄存器范围 | 浮点视图 | NEON 视图 | 保存约定 |
|---|---|---|---|
| s0–s15 | d0–d7 | q0–q3 | 调用者保存(参数/返回值) |
| s16–s31 | d8–d15 | q4–q7 | 被调用者保存 |
| — | d16–d31 | q8–q15 | 调用者保存 |
注意:d16-d31 (q8–q15) 虽然在整数寄存器数量之外,但它们同样是调用者保存的。只有 s16–s31 (d8–d15, q4–q7) 需要被调函数保留。
4.2 软件浮点链路 vs 硬件浮点链路
GCC 提供三种浮点 ABI 选项,区分为参数传递方式和浮点运算实现两个维度:
| 选项 | 参数传递 | 浮点运算 | 二进制兼容于 |
|---|---|---|---|
-mfloat-abi=soft |
整数寄存器 R0–R3 + 栈 | 库函数软实现 (不需要 FPU) | — |
-mfloat-abi=softfp |
整数寄存器 R0–R3 + 栈 | VFP 协处理器指令 | soft |
-mfloat-abi=hard |
VFP 寄存器 d0–d7 (s0–s15) + 栈 | VFP 协处理器指令 | — |
关键区别:softfp 使用 VFP 硬件做浮点计算,但参数仍通过整数寄存器传递。这使得 softfp 编译的二进制与 soft 编译的库二进制兼容——两者都用整数寄存器传参。hard 则完全不同:浮点参数直接进入 VFP 寄存器,与 soft/softfp 编译的目标文件不兼容。
同一可执行镜像中不能混用 hard 和 soft/softfp ——链接器层面就会拒绝混合 ABI 的目标文件。具体选择标准:
- soft:目标是可能没有 FPU 的处理器,不需要浮点性能
- softfp:目标有 VFP,但需要与现有的 soft ABI 库保持二进制兼容
- hard:目标有 VFP,全工具链统一编译,追求最高浮点调用性能
硬浮点链路中,整数参数和浮点参数使用独立的寄存器序列和计数器——整数参数用 R0–R3 溢出到栈,浮点参数用 d0–d7 溢出到栈,互不干涉。
1 | void f(uint32_t a, uint64_t b) |
这说明即使引入了浮点寄存器,整数参数的对齐约束仍然独立生效。
4.3 浮点寄存器的 back-fill 机制
浮点参数分配有一个整数参数不具备的特性:back-fill(回填)。当一个大尺寸浮点参数跳过了小寄存器的空隙后,后续小参数可以回填到那个空隙中。
下面的时序图展示了 float + double + float 参数混合传递时 back-fill 的完整过程——注意 double b 跳过 s1 后,float c 回填到被跳过的 s1:

1 | void f(float a, double b, float c) |
类似 RISC-V F/D 扩展中 FPR 的压缩分配策略一致——浮点寄存器天然支持变长打包,减少了寄存器浪费。
实现上,这通过三个独立的计数器(s 计数器、d 计数器、q 计数器)来管理。每个计数器指向对应尺寸的”下一个可用槽位”。
4.4 栈溢出的不可逆约束
当浮点参数数量超过 8 个(d0–d7 全满),后续参数溢出到栈。一旦有浮点参数溢出到栈,back-fill 就停止了——后续参数不能再回填到寄存器中可能剩余的空位。
以原文档中的极端例子为例:
1 | void f(double a, double b, double c, double d, |
分配过程:
1 | d0: a d1: b d2: c d3: d |
尽管 d6 中 s13 仍然空闲,但 j 不能回填到 s13——因为 i 已经溢出到栈了。规则是:”一旦 FP 参数触及栈,就不能回填到寄存器”。

4.5 变参函数的特殊处理
带有可变参数(如 printf)的函数采用特殊的参数传递规则:所有可变参数都通过整数寄存器(R0–R3)和栈传递,而不会使用 VFP 寄存器。此外,根据 C 语言的默认参数提升(Default Argument Promotions),float 会自动提升为 double,char、short 会提升为 int。这样,va_arg 就可以按照统一的规则依次读取参数,而无需同时处理整数寄存器和 VFP 寄存器中的数据。
5. 栈、堆与返回值
5.1 栈实现
AAPCS 规定栈为 full-descending(满递减) 类型:
1 | HIGH (0xBEEFFFFC) |
关键规则:
- Full:SP 指向栈顶最后一个已使用的元素(而非下一个空闲位置)。
PUSH {r4}等价于SP := SP - 4; [SP] := R4 - Descending:栈向低地址方向增长,每次 PUSH 使 SP 减小
对齐要求:
- 内部调用:字对齐(4 字节)
- 外部接口必须双字对齐(8 字节)
由于 ARM 的 LDRD/STRD 指令要求 8 字节对齐的地址,在函数边界保持 8 字节对齐可以避免非对齐访问异常。AArch64 延续了这一约束(SP 必须 16 字节对齐,比 ARMv7 更严格)。
堆则由进程自行管理(典型的是 C malloc()),ABI 不做特殊规定。
5.2 返回值约定
| 返回类型 | 软件浮点链路 | 硬件浮点链路 |
|---|---|---|
char / short / int |
R0 | R0 |
float (32-bit) |
R0 | s0 |
long long / double |
R0 + R1 | d0 |
| NEON 向量 | — | q0 |
硬件浮点链路中,整数和浮点返回值分属不同寄存器,不存在冲突。
6. C 与汇编混编实践
理解 AAPCS 最直接的应用场景就是将汇编嵌入 C 代码。ARM 原文给出了从简单到复杂的三个层次。
6.1 GCC 基本内联汇编
最简单的形式——单条指令:
1 | asm("nop"); |
但这是一个陷阱:编译器优化器可能直接删除这条 asm 语句(因为没有输入输出的副作用),即使不删除,处理器硬件也可以从指令流中过滤掉 NOP 使其不进入执行阶段。原文提醒:inline assembly 代码仍然受 C 编译器优化控制和处理器微架构影响。
6.2 扩展内联汇编语法
GCC 扩展内联汇编的完整格式为:
1 | asm volatile ( |
以原文中 USAD8(无符号字节绝对差求和)为例:
1 | asm volatile ("usad8 %0, %1, %2" |
三个冒号将模板分成了四个部分。%0、%1、%2 按位置引用操作数。volatile 告诉 GCC 不要优化掉这条汇编。
6.3 约束字符
这些单个字母是 GCC 和汇编器之间的”类型约定”:
| 约束 | 含义 | 修饰符 | 含义 |
|---|---|---|---|
"r" |
ARM 通用寄存器 R0–R15 | "=" |
只写输出 |
"m" |
内存地址 | "+" |
读写(既是输入也是输出) |
"w" |
VFP 单精度寄存器 | "&" |
输出不与任何输入共享寄存器 |
"I" |
ARM 数据处理指令的立即数范围 |
修饰符 "&" (early-clobber) 特别重要——它告诉编译器为输出操作数选择一个不与任何输入操作数重叠的寄存器,防止汇编指令在读取完所有输入之前就覆盖了某个输入寄存器。
6.4 Clobber 列表
clobber 列表(破坏列表)告诉编译器哪些资源在汇编语句中被修改了。格式:
1 | : "r0", "r1", "cc", "memory" |
"r0","r1"— 具体寄存器被修改"cc"— 条件码标志 (CPSR NZCV) 被修改"memory"— 告诉编译器汇编语句可能读取或写入任意内存位置。编译器因此必须将寄存器中缓存的任何内存值在 asm 前写回、在 asm 后重新加载,且不能跨 asm 重排内存访问。这相当于一个编译器级别的内存屏障,不影响硬件内存排序。
如果省略 clobber 列表,编译器会假设汇编代码只通过输入输出操作数影响程序状态,可能做出错误的优化。
6.5 来自 Linux 内核的实例
原文档给出了一个来自 Linux 内核的真实内联汇编案例——从 SVC 模式切换到 FIQ 模式读取 FIQ 寄存器状态:
1 | void __naked get_fiq_regs(struct pt_regs *regs) |
逐行解读:
mrs %0, cpsr— 将当前程序状态寄存器读入tmp(%0)msr cpsr_c, %2— 将PSR_I_BIT | PSR_F_BIT | FIQ_MODE写入 CPSR 控制字段,切换到 FIQ 模式并将 IRQ/FIQ 屏蔽stmia %1, {r8-r14}— 在 FIQ 模式下,R8–R14 是 FIQ 的 banked 寄存器(来自 FIQ 异常时的处理器状态,而非 SVC 模式下的值),将它们保存到regs->ARM_r8起始的内存msr cpsr_c, %0— 恢复原始 CPSR,回到 SVC 模式__naked属性告诉编译器不生成函数序言/尾声代码(不自动 PUSH/POP),因为此函数自行管理了栈帧
这个例子展示了 AAPCS 内联汇编的全部要素:多指令模板、输入输出约束、clobber 隐式管理、系统寄存器操作(mrs/msr)。理解这个例子,就掌握了 ARM GCC 内联汇编的核心。
7. 关键要点
AAPCS 是 ARM 函数调用的”合约” —— 调用者和被调用者必须就寄存器使用达成一致,否则函数调用将产生未定义行为。
4 寄存器窗口是性能瓶颈 —— 只有 R0–R3 四个参数寄存器(C++ 只有三个),超出即溢出到栈。参数排序直接影响代码效率:将 64 位类型放在参数列表末尾可避免寄存器空洞。
被调用者保存 R4–R11 —— 函数可以自由使用 R4–R8、R10、R11,但使用前必须保存原值到栈,返回前恢复。
硬浮点链路 ≠ 软件浮点链路 —— 同一可执行镜像中不能混用。硬浮点性能更高但不可移植,软浮点可移植但参数需要搬移到浮点寄存器计算。
浮点寄存器有 back-fill 但有时效限制 —— 一旦有参数溢出到栈,回填立即停止。变参函数完全不使用浮点寄存器。
GCC 内联汇编的关键是”告诉编译器真相” —— 约束字符
"=r"/"&"和 clobber 列表是编译器优化的边界标记,遗漏会导致难以追踪的运行时错误。与 AArch64 的对比 —— ARMv7-A 的 AAPCS 是 AArch64 AAPCS64 的前身。64 位模式将参数寄存器扩展到 8 个 (X0–X7),SP 对齐从 8 字节提升到 16 字节,但基本的分组逻辑(参数/被调用者保存/专用)保持一致。