在 ARM64(AArch64)架构下,理解函数调用栈、寄存器分配以及参数传递是底层开发和调试的核心。ARM64 的函数调用遵循 AAPCS64(Procedure Call Standard for the ARM 64-bit Architecture)。
以下是关于函数调用栈、指针保存和返回值的详细解析:
1. 核心寄存器角色
在 ARM64 中,有 31 个通用寄存器(X0-X30),其中几个在函数调用中起关键作用:
- X0 - X7: 用于传递函数参数。如果函数有返回值,通常也存放在 X0 中(如果返回值很大,则使用 X8)。
- X29 (FP, Frame Pointer): 帧指针。指向当前栈帧的底部(高地址端),用于追踪函数调用链。
- X30 (LR, Link Register): 链接寄存器。保存函数返回时的地址。执行
BL(Branch with Link)指令时,硬件会自动将下一条指令地址存入 LR。 - SP (Stack Pointer): 栈指针。指向当前栈的顶部(低地址端)。ARM64 要求 SP 必须 16 字节对齐。
- PC (Program Counter): 程序计数器,指向当前执行的指令(不可直接通过指令修改,需通过跳转指令)。
2. 函数调用栈的结构
ARM64 的栈是向下增长的(从高地址向低地址)。一个标准的栈帧(Stack Frame)通常包含:
- 返回地址 (LR)
- 上一个栈帧的 FP (Previous FP)
- 被调用者保存的寄存器 (Callee-saved registers, X19-X28)
- 局部变量
- 传递给子函数的参数(如果超过 8 个)
3. 函数调用的全过程
假设函数 A 调用函数 B:
A. 调用前(Caller 准备)
-
A将参数放入X0-X7。 - 执行
BL B指令:- 将
BL的下一条指令地址存入LR (X30)。 - 跳转到
B的入口。
- 将
B. 函数入口(B 的 Prologue/序言)
函数 B 启动后,第一件事是保护现场,尤其是因为 B 可能还会调用函数 C(这会覆盖 LR)。
1 | stp x29, x30, [sp, #-32]! // 将 FP 和 LR 存入栈中,同时 SP 向下移动 32 字节 (Pre-index) |
-
stp(Store Pair): 一次性存储两个寄存器。 -
[sp, #-32]!: 申请 32 字节空间,!表示更新 SP 的值。 - 注意: 此时栈底存的是旧的 FP,接着是返回地址 LR。
C. 函数执行(Body)
- 局部变量存放在栈上
[sp, #offset]。 - 如果使用到
X19-X28,需要先压栈保护。
D. 函数退出(B 的 Epilogue/尾声)
- 设置返回值: 将结果写入
X0。 - 恢复现场:
1
2ldp x29, x30, [sp], #32 // 从栈中恢复 FP 和 LR,同时 SP 向上移动 32 字节 (Post-index)
ret // 跳转到 LR 所指向的地址
-
ldp(Load Pair): 从栈中加载回两个寄存器。 -
ret: 实际上就是br x30。
4. 返回值如何保存
- 基本类型: 整数、指针存放在
X0。如果是 128 位数据(如__int128),使用X0和X1。 - 浮点数: 存放在浮点寄存器
V0或D0。 - 大型结构体:
- 如果结构体很大(超过 16 字节),调用者会在栈上预留空间。
- 调用者将该空间的地址放在 X8 寄存器中(Indirect Result Location)。
- 子函数执行完后,将结果写入 X8 指向的内存。
5. 图解栈帧状态
假设正在执行子函数,栈的情况如下:
1 | 地址 内容 |
6. 关键特性总结
- FP 链 (Frame Chain): ARM64 强制(或强烈建议)维护 FP。通过当前 FP,你可以找到旧的 FP,旧的 FP 又指向更旧的 FP。这构成了回溯 (Backtrace) 的基础。
- 叶子函数 (Leaf Function): 如果一个函数不再调用其他函数,它就不需要保存
LR到栈上,直接在最后ret即可。这节省了内存访问。 - 16 字节对齐: ARM64 硬件强制要求 SP 在进行内存访问时必须对齐到 16 字节,否则会触发异常(Alignment Fault)。
示例代码 (C 对应汇编)
C 代码:
1 | int add(int a, int b) { |
ARM64 汇编 (简化版):
1 | add: |
如果有复杂的调用:
1 | void caller() { |
汇编:
1 | caller: |