ARM64函数调用栈寄存器分配

在 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)通常包含:

  1. 返回地址 (LR)
  2. 上一个栈帧的 FP (Previous FP)
  3. 被调用者保存的寄存器 (Callee-saved registers, X19-X28)
  4. 局部变量
  5. 传递给子函数的参数(如果超过 8 个)

3. 函数调用的全过程

假设函数 A 调用函数 B

A. 调用前(Caller 准备)

  1. A 将参数放入 X0-X7
  2. 执行 BL B 指令:
    • BL 的下一条指令地址存入 LR (X30)
    • 跳转到 B 的入口。

B. 函数入口(B 的 Prologue/序言)

函数 B 启动后,第一件事是保护现场,尤其是因为 B 可能还会调用函数 C(这会覆盖 LR)。

1
2
stp x29, x30, [sp, #-32]!  // 将 FP 和 LR 存入栈中,同时 SP 向下移动 32 字节 (Pre-index)
mov x29, sp // 将当前的 SP 存入 FP,建立新的帧指针
  • stp (Store Pair): 一次性存储两个寄存器。
  • [sp, #-32]!: 申请 32 字节空间,! 表示更新 SP 的值。
  • 注意: 此时栈底存的是旧的 FP,接着是返回地址 LR。

C. 函数执行(Body)

  • 局部变量存放在栈上 [sp, #offset]
  • 如果使用到 X19-X28,需要先压栈保护。

D. 函数退出(B 的 Epilogue/尾声)

  1. 设置返回值: 将结果写入 X0
  2. 恢复现场:
    1
    2
    ldp x29, x30, [sp], #32    // 从栈中恢复 FP 和 LR,同时 SP 向上移动 32 字节 (Post-index)
    ret // 跳转到 LR 所指向的地址
  • ldp (Load Pair): 从栈中加载回两个寄存器。
  • ret: 实际上就是 br x30

4. 返回值如何保存

  • 基本类型: 整数、指针存放在 X0。如果是 128 位数据(如 __int128),使用 X0X1
  • 浮点数: 存放在浮点寄存器 V0D0
  • 大型结构体:
    • 如果结构体很大(超过 16 字节),调用者会在栈上预留空间。
    • 调用者将该空间的地址放在 X8 寄存器中(Indirect Result Location)。
    • 子函数执行完后,将结果写入 X8 指向的内存。

5. 图解栈帧状态

假设正在执行子函数,栈的情况如下:

1
2
3
4
5
6
7
8
地址      内容
---- ------------------------- <-- 调用者的 SP (对齐 16 字节)
高地址 ... 局部变量 ...
[X30] (返回地址 LR) <-- FP (X29) 指向这里
[X29] (上级 FP 地址)
... 局部变量 ...
低地址 ... 临时数据 ... <-- 当前 SP 指向这里
---- -------------------------

6. 关键特性总结

  1. FP 链 (Frame Chain): ARM64 强制(或强烈建议)维护 FP。通过当前 FP,你可以找到旧的 FP,旧的 FP 又指向更旧的 FP。这构成了回溯 (Backtrace) 的基础。
  2. 叶子函数 (Leaf Function): 如果一个函数不再调用其他函数,它就不需要保存 LR 到栈上,直接在最后 ret 即可。这节省了内存访问。
  3. 16 字节对齐: ARM64 硬件强制要求 SP 在进行内存访问时必须对齐到 16 字节,否则会触发异常(Alignment Fault)。

示例代码 (C 对应汇编)

C 代码:

1
2
3
int add(int a, int b) {
return a + b;
}

ARM64 汇编 (简化版):

1
2
3
add:
add w0, w0, w1 // 参数在 w0, w1,结果存入 w0
ret // 直接返回,无需操作栈(叶子函数)

如果有复杂的调用:

1
2
3
void caller() {
callee(1);
}

汇编:

1
2
3
4
5
6
7
caller:
stp x29, x30, [sp, #-16]! // 保护 FP 和 LR
mov x29, sp // 设置新 FP
mov w0, #1 // 参数传入 X0
bl callee // 跳转并保存返回地址到 LR
ldp x29, x30, [sp], #16 // 恢复
ret