ARM64 函数调用约定:参数传递、栈帧与返回地址

函数调用约定,说白了就是 caller 和 callee 之间的一纸契约。谁负责保存哪些寄存器?参数放哪里?返回值怎么拿?这些规则定死了,逆向分析和性能调优才有下手的地方。我刚开始接触 ARM64 时,总觉得这些条条框框记不住,后来踩了几次坑才明白——不理解调用约定,你连反汇编代码都看不懂。

参数传递规则:寄存器优先,栈兜底

ARM64 的参数传递非常直接。前 8 个参数用寄存器传,再多就压栈。具体规则如下:

参数位置 整数/指针类型 浮点类型
第 1 个参数 X0 D0 / S0
第 2 个参数 X1 D1 / S1
第 3 个参数 X2 D2 / S2
第 4 个参数 X3 D3 / S3
第 5 个参数 X4 D4 / S4
第 6 个参数 X5 D5 / S5
第 7 个参数 X6 D6 / S6
第 8 个参数 X7 D7 / S7
第 9 个参数及以后 栈(SP 向上增长) 栈(SP 向上增长)

关键点:ARM64 的栈是满递减栈(Full Descending Stack)。压栈时 SP 先减,再存数据。参数超过 8 个时,第 9 个参数放在 SP+0 的位置,第 10 个在 SP+8,以此类推。

举个例子,一个函数 void func(int a, int b, int c, int d, int e, int f, int g, int h, int i),反汇编你会看到:

// 前 8 个参数在寄存器
// a -> X0, b -> X1, c -> X2, d -> X3
// e -> X4, f -> X5, g -> X6, h -> X7

// 第 9 个参数 i 在栈上
// 调用前,caller 把 i 压栈
STR X8, [SP, #-16]!   // 先减 SP,再存 i
// 或者用 STP 一次压两个
STP X8, X9, [SP, #-16]!

// 然后 BL func
BL func

我个人习惯在逆向时,先看函数头部的寄存器使用情况。如果 X0~X7 都被用了,那大概率参数没超 8 个。一旦看到函数内部有 LDR Xn, [SP, #offset] 这种指令,基本可以断定参数超过 8 个了。

栈帧布局:FP 与 SP 的舞蹈

ARM64 的栈帧布局,核心是两个寄存器:FP(X29)和 SP。FP 指向当前函数的栈帧底部,SP 指向栈顶。一个典型的栈帧长这样:

高地址
+------------------+
|  caller 的栈帧    |
+------------------+  ← 调用前的 SP
|  返回地址 (LR)    |  ← 由 BL 指令自动压入
+------------------+
|  保存的 FP        |  ← 由 callee 压入
+------------------+  ← 当前 FP (X29)
|  局部变量区域      |
|  (向下增长)       |
+------------------+  ← 当前 SP
低地址

函数入口和出口的典型代码模式:

// 函数入口:建立栈帧
func:
    STP X29, X30, [SP, #-16]!   // 保存 FP 和 LR,SP 减 16
    MOV X29, SP                  // 设置新的 FP
    SUB SP, SP, #局部变量大小     // 分配局部变量空间

    // ... 函数体 ...

    // 函数出口:恢复栈帧
    MOV SP, X29                  // 恢复 SP
    LDP X29, X30, [SP], #16     // 恢复 FP 和 LR,SP 加 16
    RET                          // 返回

避坑指南:我曾经在逆向一个优化过的二进制时,发现函数入口没有 STP X29, X30,也没有 MOV X29, SP。别慌,这通常是叶子函数(Leaf Function)——它不调用其他函数,所以不需要保存 LR。这种函数连栈帧都省了,性能更好。

返回地址处理:LR 的生死时速

ARM64 用 X30(LR)保存返回地址。BL func 指令会自动把下一条指令的地址写入 LR,然后跳转到 func。如果 func 是叶子函数,它可以直接 RET(等价于 BR X30)。

但如果 func 还要调用其他函数,就必须先保存 LR。否则第二次 BL 会把 LR 覆盖掉,你就回不来了。这就是为什么非叶子函数入口一定要 STP X29, X30。

注意:有些编译器优化会使用 BR X30 代替 RET,两者效果一样。但 RET 在分支预测上更友好,性能略优。逆向时看到 BR X30 别奇怪,它也是返回。

寄存器保存约定:谁负责谁

ARM64 把寄存器分为 caller-saved 和 callee-saved 两类。这个划分直接影响逆向分析时的数据流追踪。

寄存器 类型 谁负责保存 典型用途
X0 - X7 参数/返回值寄存器 Caller 传参、返回值
X8 间接结果寄存器 Caller 返回大结构体指针
X9 - X15 临时寄存器 Caller 局部计算,随时可覆盖
X16 - X17 IP0 / IP1 Caller 过程内调用,PLT 跳板
X18 平台寄存器 — 保留给平台,别乱动
X19 - X28 被调用者保存 Callee 跨函数调用保持值
X29 (FP) 帧指针 Callee 栈帧基址
X30 (LR) 链接寄存器 Callee 返回地址

逆向时怎么用这个表?举个例子:如果你在函数 A 中看到 X19 被赋值,然后调用了函数 B,回来后又用 X19,那 X19 的值一定没变——因为 B 作为 callee,必须保存和恢复 X19。这就是数据流追踪的捷径。

SVG:ARM64 函数调用流程全景图

ARM64 函数调用流程与栈帧布局 ARM64 函数调用流程与栈帧布局 Caller 函数 参数放入 X0-X7 BL func (LR = 返回地址) Callee 函数 STP X29, X30, [SP,#-16]! SUB SP, SP, #局部大小 函数体执行 LDP X29, X30, [SP], #16 RET (跳转到 LR) 栈帧布局(高→低) 高地址 Caller 栈帧 返回地址 (LR) 保存的 FP ← 当前 FP (X29) 局部变量区域 (向下增长) ← 当前 SP ... 空闲栈空间 ... 低地址

实战技巧:逆向时如何快速识别调用约定

  1. 看函数入口:有 STP X29, X30 就是非叶子函数,没有就是叶子函数。
  2. 看参数传递:函数体内如果频繁出现 LDR Xn, [SP, #offset],说明参数超过 8 个。
  3. 看返回值:整数/指针返回值在 X0,浮点在 D0。如果函数返回结构体,X8 会指向结构体内存。
  4. 看 callee-saved 寄存器:X19-X28 如果在函数开头被压栈,结尾被恢复,那它们就是跨调用保持的变量。

个人经验:我在逆向一个大型 C++ 项目时,发现很多函数入口没有 STP X29, X30,但内部却调用了其他函数。一开始我以为是反汇编器抽风,后来才发现是编译器开启了 -fomit-frame-pointer 优化。这种情况下,FP 被当作普通寄存器使用,栈帧全靠 SP 偏移来定位。逆向时得手动计算 SP 的变化,确实麻烦不少。

性能调优视角:调用约定的开销

函数调用不是免费的。每次调用都有以下开销:

  • 参数传递:寄存器传递几乎零成本,但栈传递涉及内存访问,延迟高。
  • 栈帧建立/销毁:STP 和 LDP 各一次内存访问,加上 SP 加减。
  • 分支预测:BL 和 RET 会影响分支预测器,频繁调用小函数可能造成预测失败。

我建议在性能敏感代码中:

  • 尽量用内联函数代替小函数调用。
  • 参数控制在 8 个以内,避免栈传递。
  • 叶子函数不要建立栈帧,直接用 RET 返回。

警告:不要盲目优化。我曾经把一个大函数拆成十几个小函数,结果调用开销反而让性能下降了 15%。调优前先 profile,别靠猜。