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 函数调用流程全景图
实战技巧:逆向时如何快速识别调用约定
- 看函数入口:有
STP X29, X30就是非叶子函数,没有就是叶子函数。 - 看参数传递:函数体内如果频繁出现
LDR Xn, [SP, #offset],说明参数超过 8 个。 - 看返回值:整数/指针返回值在 X0,浮点在 D0。如果函数返回结构体,X8 会指向结构体内存。
- 看 callee-saved 寄存器:X19-X28 如果在函数开头被压栈,结尾被恢复,那它们就是跨调用保持的变量。
个人经验:我在逆向一个大型 C++ 项目时,发现很多函数入口没有 STP X29, X30,但内部却调用了其他函数。一开始我以为是反汇编器抽风,后来才发现是编译器开启了 -fomit-frame-pointer 优化。这种情况下,FP 被当作普通寄存器使用,栈帧全靠 SP 偏移来定位。逆向时得手动计算 SP 的变化,确实麻烦不少。
性能调优视角:调用约定的开销
函数调用不是免费的。每次调用都有以下开销:
- 参数传递:寄存器传递几乎零成本,但栈传递涉及内存访问,延迟高。
- 栈帧建立/销毁:
STP和LDP各一次内存访问,加上 SP 加减。 - 分支预测:
BL和RET会影响分支预测器,频繁调用小函数可能造成预测失败。
我建议在性能敏感代码中:
- 尽量用内联函数代替小函数调用。
- 参数控制在 8 个以内,避免栈传递。
- 叶子函数不要建立栈帧,直接用
RET返回。
警告:不要盲目优化。我曾经把一个大函数拆成十几个小函数,结果调用开销反而让性能下降了 15%。调优前先 profile,别靠猜。