3. ARM64指令集入门

好,咱们进入第三章。这一章讲的是 ARM64 下的函数调用规矩——AAPCS64。说白了,就是编译器和你写的汇编代码之间怎么约定「你递东西我接住」的规则。

我记得刚接触 ARM64 时,总觉得寄存器那么多,随便用呗。结果第一次写混合汇编就被坑了——函数返回时寄存器值全乱了。嗯,从那以后我老老实实把 AAPCS64 啃了一遍。

3.1 参数传递规则:寄存器传参 vs 栈传参

ARM64 传参有个核心原则:能放寄存器就不上栈。为什么?因为寄存器访问延迟是纳秒级,栈在 L1 cache 里好歹也是几十纳秒,要是溢出到主存……你想想看,性能差距能到 10 倍以上。

3.1.1 整数/指针参数

前 8 个整数参数(包括指针)依次放入 x0-x7。第 9 个开始压栈。就这么简单。

规则速查表:

参数位置传递方式寄存器/栈
第 1-8 个寄存器x0 - x7
第 9 个及以上栈SP+0, SP+8, ...
// C 代码示例
void func(int a1, int a2, int a3, int a4, int a5,
          int a6, int a7, int a8, int a9) {
    // a1-a8 → x0-x7
    // a9   → [SP]
}

我个人习惯在写汇编调用 C 函数前,先数一遍参数个数。超过 8 个?那就得手动调整栈指针了。

3.1.2 浮点参数

浮点数走 v0-v7,规则和整数一样——前 8 个浮点参数用寄存器,多的上栈。但有个坑:浮点和整数寄存器是独立的。如果一个函数既有整数又有浮点参数,它们各自占用自己的前 8 个槽位。

我曾经踩过的坑:

混合参数时,x0-x7 和 v0-v7 是并行使用的。比如 void f(int, double, int, double),实际占用 x0, v0, x1, v1。不是 x0, x1, x2, x3!

3.1.3 大结构体传参

结构体超过 16 字节?对不起,编译器会偷偷改成传指针。你写的是传值,实际走的是地址。我调试过一个网络协议栈,结构体 64 字节,每次调用 memcpy 拷贝,性能惨不忍睹。改成指针传递后,延迟直接降了 70%。

3.2 返回值传递

返回值的规则更简单:

  • 整数/指针:放在 x0
  • 浮点数:放在 v0
  • 小结构体(≤16 字节):拆到 x0 和 x1 返回
  • 大结构体(>16 字节):调用者分配空间,把地址作为「隐藏参数」传进去

小技巧:如果你在写性能敏感的代码,尽量让返回值 ≤16 字节。这样能省掉一次内存分配和指针解引用的开销。

3.3 被调用者保存 vs 调用者保存寄存器

这是 AAPCS64 里最容易搞混的部分。我当年面试时就被问倒过。咱们用一张表说清楚:

类别寄存器谁负责保存典型用途
调用者保存 (Caller-saved)x0-x15, v0-v15调用者参数传递、临时变量
被调用者保存 (Callee-saved)x19-x29, v16-v31被调用者局部变量、帧指针
特殊用途x16-x18, x29, x30, SP, PC—IP0/IP1, 帧指针, 链接寄存器

为什么这么分? 说白了就是「谁用谁负责」。调用者保存的寄存器,函数内部随便改,但调用前你得自己备份。被调用者保存的寄存器,函数要用就必须先 push 到栈上,返回前 restore。

我见过不少新手写汇编时,在函数里直接改 x19 却不保存,结果返回后调用者的循环变量全乱了。这种 bug 极难排查,因为不是每次都崩,只在特定寄存器值被使用时才暴露。

3.4 栈帧布局

栈帧是函数调用的「现场记录」。每次调用,栈上会压入一块区域,包含:

  • 返回地址(LR,即 x30)
  • 上一帧的帧指针(FP,即 x29)
  • 被调用者保存的寄存器
  • 局部变量
  • 参数溢出区(参数超过 8 个时)

标准的栈帧布局长这样:

高地址
+------------------+
| 调用者的栈帧      |
+------------------+ ← 调用者的 SP
| 参数溢出区        |  (第 9 个参数开始)
+------------------+
| 局部变量          |
+------------------+
| 被调用者保存寄存器 |  (x19-x29, v16-v31)
+------------------+
| 返回地址 (x30)    |
+------------------+
| 上一帧 FP (x29)   | ← 当前 FP
+------------------+ ← 当前 SP
低地址

我个人习惯在函数入口处统一做栈帧分配:

// 汇编模板
func_name:
    stp     x29, x30, [sp, #-16]!   // 保存 FP 和 LR
    mov     x29, sp                  // 设置当前帧指针
    sub     sp, sp, #64              // 分配局部变量空间 (64 字节)
    
    // ... 函数体 ...
    
    ldp     x29, x30, [sp], #16      // 恢复 FP 和 LR
    ret

注意:栈指针 SP 必须保持 16 字节对齐。这是 AAPCS64 的硬性要求。违反的话,某些指令(比如 ldp/stp)会触发对齐异常。我曾在一次代码评审中发现同事的栈分配是 #20 字节,当场就指出来了。

3.5 知识结构图

下面这张 SVG 图把 AAPCS64 的核心脉络画出来了,方便你对照记忆:

AAPCS64 参数传递与栈帧布局知识结构 AAPCS64 核心知识结构 AAPCS64 调用约定 参数传递规则 返回值传递 栈帧布局 整数参数 x0-x7 浮点参数 v0-v7 大结构体→指针 整数 x0 浮点 v0 小结构体 x0:x1 FP/LR 保存 Callee-saved regs 局部变量/参数溢出 核心原则:能放寄存器就不上栈,SP 必须 16 字节对齐 调用者保存 vs 被调用者保存:谁用谁负责 寄存器分类速记 Caller-saved: x0-x15 Callee-saved: x19-x29 Special: x16-x18, x30

3.6 实战避坑指南

最后,我把自己这些年踩过的坑总结一下:

  1. 混合参数顺序:整数和浮点寄存器是并行槽位,不是串行。写汇编时一定要对照函数原型数清楚。
  2. 大结构体传值:编译器会转成指针,但如果你在 C 里传值,它可能还会做一次拷贝。性能敏感处用指针显式传递。
  3. SP 对齐:每次分配栈空间时,确保是 16 的倍数。我习惯在函数入口用 stp 一次性压入两个寄存器,天然对齐。
  4. 被调用者保存寄存器:x19-x29 和 v16-v31,用之前必须 push,返回前 pop。少一个都不行。
  5. 帧指针省略:开启 -fomit-frame-pointer 优化后,x29 可能被用作通用寄存器。调试时建议关掉这个选项,否则栈回溯会乱。

嗯,这一章的内容就到这里。AAPCS64 的规则其实不多,但每条都是硬约束。写汇编时多对照这张知识结构图,慢慢就形成肌肉记忆了。