X29 X30与函数调用栈

X29 和 X30:两个决定栈命运的寄存器

在 ARM64 的 31 个通用寄存器 (X0–X30) 中,有两个被架构约定赋予了特殊职责:

寄存器 别名 职责
X29 FP (Frame Pointer) 帧指针,指向当前函数栈帧的底部
X30 LR (Link Register) 链接寄存器,保存函数的返回地址

X30 (LR) —— 函数怎么"记得"回哪去

当 CPU 执行 bl (Branch with Link) 指令调用一个函数时,硬件自动做两件事:

1
2
bl  <target>   ⟹   ① 将下一条指令的地址写入 X30 (LR)
                   ② 跳转到 <target> 执行

对应 ret 指令则做一件事:

1
ret            ⟹   跳转到 X30 (LR) 中的地址继续执行

核心矛盾:如果函数 A 调用了函数 B,bl B 会把 A 的返回地址写入 X30。 然后 B 再调用 C 时,bl C 又会覆盖 X30。这样 A 的返回地址就丢了。

解决方案:在函数入口,先将 X30 (LR) 保存到栈上,返回前再从栈上恢复。

X29 (FP) —— 怎么找到"我"的栈帧

X29 指向当前函数栈帧的一个固定位置。它的核心作用有两个:

  1. 为局部变量和临时数据提供基地址 —— 通过 [x29, #offset] 寻址
  2. 形成链表,支持栈回溯 (Stack backtrace) —— 每个栈帧中保存了上一个函数的 X29 (即上一个 FP),从而形成一条"帧指针链"

arm64 函数调用栈解析

CPU 执行指令的基本原理

在深入函数调用栈之前,有必要先了解 CPU 执行指令的基本机制。CPU 内部有多种不同功能的寄存器,其中与指令执行直接相关的有三个:

寄存器 全称 作用
PC Program Counter(程序计数器) 存放下一条指令的内存地址。在 ARM64 中也称为 PC
IR Instruction Register(指令寄存器) 存放当前正在执行的指令
SR Status Register(状态寄存器) 存储 CPU 当前的状态,如条件标志位(零标志 ZF、进位标志 CF 等)、中断禁止位、处理器模式标志等。在 ARM64 中称为 NZCV(Negative, Zero, Carry, Overflow)

此外,CPU 还拥有大量用于存储数据和地址的寄存器,根据用途可分为整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等。其中,有些寄存器既可以存放数据,又可以存放地址,被称为通用寄存器(GR,General Register)——ARM64 中的 x0x30 均属此类,w0w30 则用于访问它们的低 32 位。

程序执行的基本循环(经典五级流水线 RISC 模型)如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
             ┌─────────────────────────┐
             │  PC 指向下一条指令        │
             └──────────┬──────────────┘
  ① IF      ┌─────────────────────────┐
  取指      │  从指令存储器 / Cache 取指令 │
            └──────────┬──────────────┘
  ② ID      ┌─────────────────────────┐
  译码/读寄存器 │  操作控制器译码指令      │
            │  + 从寄存器堆读取操作数    │
            └──────────┬──────────────┘
  ③ EX      ┌─────────────────────────┐
  执行/计算地址│  ALU 执行运算 / 计算地址  │
            └──────────┬──────────────┘
  ④ MEM     ┌─────────────────────────┐
  访存      │  读/写 存储器(数据 Cache)│
            └──────────┬──────────────┘
  ⑤ WB      ┌─────────────────────────┐
  写回      │  将结果写回寄存器堆        │
            └──────────┬──────────────┘
                        └──→ PC 自增 → 回到 ①
  1. 取指 IF(Instruction Fetch):CPU 根据 PC 寄存器中的地址,从指令存储器或 Cache 中读取指令到 IR 寄存器。
  2. 译码/读寄存器 ID(Instruction Decode):操作控制器对指令进行译码,同时从寄存器堆中读取操作数。
  3. 执行/计算地址 EX(Execute):ALU 执行运算操作或计算内存地址。
  4. 访存 MEM(Memory Access):对存储器(数据 Cache)进行读写操作。
  5. 写回 WB(Write Back):将指令执行结果写回寄存器堆。然后 PC 自增,进入下一轮循环。

ARM64 特点:ARM64 是定长 32 位指令编码(所有指令均为 4 字节),因此 PC 每次自增 4。这与 x86 的变长编码不同,反汇编时的地址总是间隔 4——从后面的代码中可以清晰看到。

这个"取指 → 译码/读寄存器 → 执行/计算地址 → 访存 → 写回 → PC 自增"的循环,是整个程序运行的底层根基。而函数调用栈正是在这个循环之上,通过 bl(Branch and Link,修改 PC + 保存返回地址到 x30)和 ret(跳转到 x30)等指令,实现控制流的跳转与回归。接下来的章节,我们将从进程的宏观地址空间开始,逐步深入到每一行汇编指令的微观世界。

进程地址空间布局

在 Linux 中,一个运行中的进程拥有独立的虚拟地址空间(以 ARM64 为例,典型布局如下):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
高地址  ┌──────────────────────────────┐
        │         内核空间              │
        │    (Kernel Space)             │
        ├──────────────────────────────┤
        │                              │
        │    栈 (Stack)                │ ← SP 指向栈顶,向低地址增长
        │         ↓                    │
        │         ↑                    │
        │    共享库 / 映射区            │
        │                              │
        │    堆 (Heap)                 │ ← 向高地址增长
        │         ↑                    │
        ├──────────────────────────────┤
        │    数据段 (Data / BSS)        │
        ├──────────────────────────────┤
        │    代码段 (Text)              │
低地址  └──────────────────────────────┘ 0x0
  • 栈在高地址端,向低地址增长:每次函数调用(stp / sub sp, sp, #N)使得 SP(栈指针)减小。
  • 堆向高地址增长malloc/new 分配的内存从低往高走。

栈帧的结构

ARM64 调用约定 (AAPCS64) 下的典型栈帧布局如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
                        ← 高地址 (栈底方向)
      +---------------------------+
      |   调用者 (Caller) 的栈帧    |
      +---------------------------+
      |   调用者保存的 X29 (FP)    |  ← 当前栈帧的起始处
      |   调用者保存的 X30 (LR)    |
      +---------------------------+
      |   被调用者保存的寄存器      |  (如有: X19–X28)
      +---------------------------+
      |   局部变量区               |
      +---------------------------+
      |   参数构造区 (可选)         |  (用于传参给下一级)
      +---------------------------+  ← SP (栈指针)
                        ↓ 低地址 (栈顶方向)

关键约束:ARM64 的栈是满递减 (full-descending) 的 —— SP 指向栈上最后一个已占用的位置,栈向低地址增长。分配空间时 SP 减小,释放时 SP 增大。SP 必须始终保持 16 字节对齐

函数序言 (Prologue) 与 尾声 (Epilogue)

标准模式(非叶子函数 — 会调用其他函数的函数)

序言 (Prologue)

1
2
stp    x29, x30, [sp, #-N]!    // ① 分配 N 字节栈空间,同时将 X29、X30 压栈
mov    x29, sp                  // ② 设置当前帧指针 FP = SP
  • stp (Store Pair) 是 ARM64 的成对存储指令
  • [sp, #-N]!! 表示先修改 SP (SP -= N),再用新 SP 作为地址
  • 这两条指令合起来:分配栈空间 + 保存 FP/LR + 建立新的帧指针,一步到位

尾声 (Epilogue)

1
2
ldp    x29, x30, [sp], #N      // ① 恢复 X29、X30,同时释放栈空间
ret                             // ② 跳回 X30 (LR) 中的返回地址
  • ldp (Load Pair) 的 [sp], #N 表示先从 SP 加载 X29/X30,再 SP += N
  • 这两条指令合起来:恢复 FP/LR + 释放栈空间 + 返回

叶子函数模式(不调用其他函数的函数)

如果函数不调用其他函数(称为叶子函数 leaf function),它不需要保存 X29/X30:

1
2
3
4
sub    sp, sp, #0x20            // 只分配栈空间(给局部变量用)
...                             // 函数体
add    sp, sp, #0x20            // 释放栈空间
ret                             // 返回 — X30 未被覆盖,仍有效

逐函数反汇编分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

static int add(int a, int b)
{
    int sum = a + b;
    return sum;
}

static int calc(int x, int y, int z)
{
    int tmp = x * y;
    int result = add(tmp, z);
    return result;
}

static int process(int n)
{
    int a = n * 2;
    int b = n + 3;
    int c = calc(a, b, n);
    return c;
}

static void print_result(int val)
{
    /* 空函数体,仅用于展示多级调用链 */
    (void)val;
}

int main(void)
{
    int v = 42;
    int r = process(v);
    print_result(r);
    return 0;
}
1
2
3
aarch64-linux-gnu-gcc -O0 -g -fno-omit-frame-pointer demo.c 
aarch64-linux-gnu-objdump -d 
aarch64-linux-gnu-objdump --dwarf=frames

main —— 调用链的起点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
00000000000007d8 <main>:
;===== 序言 =====
 7d8: a9be7bfd    stp    x29, x30, [sp, #-32]!    // SP -= 32, 保存[X29, X30]到栈上
 7dc: 910003fd    mov    x29, sp                   // FP = SP (新栈帧基址)

;===== 函数体 =====
 7e0: 52800540    mov    w0, #0x2a                 // w0 = 42 (参数 v)
 7e4: b9001be0    str    w0, [sp, #24]             // 局部变量 v = 42 存到栈上
 7e8: b9401be0    ldr    w0, [sp, #24]             // w0 = v (加载参数)
 7ec: 97ffffe5    bl      780 <process>            // LR = 0x7f0, 跳转到 process

 7f0: b9001fe0    str    w0, [sp, #28]             // 局部变量 r = process(v) 的返回值
 7f4: b9401fe0    ldr    w0, [sp, #28]             // w0 = r
 7f8: 97fffff3    bl      7c4 <print_result>       // LR = 0x7fc, 调用 print_result

 7fc: 52800000    mov    w0, #0x0                  // return 0

;===== 尾声 =====
 800: a8c27bfd    ldp    x29, x30, [sp], #32       // 恢复 X29/X30, 然后 SP += 32
 804: d65f03c0    ret                               // 跳回 X30 (返回 _start)

关键观察

  • stp x29, x30, [sp, #-32]! 分配了 32 字节栈空间,并在底部保存了上一帧的 FP 和 LR
  • mov x29, sp 让 X29 指向刚保存的 (旧 FP, 旧 LR) 这对数据
  • 调用 process 前将参数放在 w0 中(ARM64 调用约定:前 8 个整数参数通过 X0–X7 传递)
  • bl 将返回地址 0x7f0 存入 X30 后跳转
  • 尾声的 ldp x29, x30, [sp], #32 对称地恢复并释放

process —— 中间层函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
0000000000000780 <process>:
;===== 序言 =====
 780: a9bd7bfd    stp    x29, x30, [sp, #-48]!    // SP -= 48, 保存 [X29, X30]
 784: 910003fd    mov    x29, sp                   // FP = SP

;===== 函数体 =====
 788: b9001fe0    str    w0, [sp, #28]             // 存储参数 n
 78c: b9401fe0    ldr    w0, [sp, #28]
 790: 531f7800    lsl    w0, w0, #1                // a = n * 2 (左移1位)
 794: b90027e0    str    w0, [sp, #36]             // 存储局部变量 a
 798: b9401fe0    ldr    w0, [sp, #28]
 79c: 11000c00    add    w0, w0, #0x3              // b = n + 3
 7a0: b9002be0    str    w0, [sp, #40]             // 存储局部变量 b
 7a4: b9401fe2    ldr    w2, [sp, #28]             // w2 = n  (第三个参数)
 7a8: b9402be1    ldr    w1, [sp, #40]             // w1 = b  (第二个参数)
 7ac: b94027e0    ldr    w0, [sp, #36]             // w0 = a  (第一个参数)
 7b0: 97ffffe4    bl      740 <calc>               // LR = 0x7b4, 调用 calc(a, b, n)

 7b4: b9002fe0    str    w0, [sp, #44]             // c = calc(...) 的返回值
 7b8: b9402fe0    ldr    w0, [sp, #44]             // return c

;===== 尾声 =====
 7bc: a8c37bfd    ldp    x29, x30, [sp], #48       // 恢复 X29/X30, SP += 48
 7c0: d65f03c0    ret                               // 跳回 LR

关键观察

  • 分配了 48 字节栈空间(比 main 的 32 字节大),因为局部变量更多
  • 它的序言覆盖了 X30(保存了返回 main 的地址),所以当它再调用 calc 时,X30 被 bl 覆盖不会造成问题
  • 调用 calc 时传三个参数 (a, b, n),分别放在 w0、w1、w2

calc —— 中间层函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
0000000000000740 <calc>:
;===== 序言 =====
 740: a9bd7bfd    stp    x29, x30, [sp, #-48]!    // SP -= 48, 保存 [X29, X30]
 744: 910003fd    mov    x29, sp                   // FP = SP

;===== 函数体 =====
 748: b9001fe0    str    w0, [sp, #28]             // 存储参数 x
 74c: b9001be1    str    w1, [sp, #24]             // 存储参数 y
 750: b90017e2    str    w2, [sp, #20]             // 存储参数 z
 754: b9401fe1    ldr    w1, [sp, #28]
 758: b9401be0    ldr    w0, [sp, #24]
 75c: 1b007c20    mul    w0, w1, w0                // tmp = x * y
 760: b9002be0    str    w0, [sp, #40]             // 存储 tmp
 764: b94017e1    ldr    w1, [sp, #20]             // w1 = z
 768: b9402be0    ldr    w0, [sp, #40]             // w0 = tmp
 76c: 97ffffeb    bl      718 <add>                // LR = 0x770, 调用 add(tmp, z)

 770: b9002fe0    str    w0, [sp, #44]             // result = add(...)
 774: b9402fe0    ldr    w0, [sp, #44]             // return result

;===== 尾声 =====
 778: a8c37bfd    ldp    x29, x30, [sp], #48       // 恢复 X29/X30, SP += 48
 77c: d65f03c0    ret                               // 跳回 LR

add —— 叶子函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
0000000000000718 <add>:
;===== 没有序言(叶子函数)=====
 718: d10083ff    sub    sp, sp, #0x20             // 仅分配 32 字节(给局部变量)
 71c: b9000fe0    str    w0, [sp, #12]             // 存储参数 a
 720: b9000be1    str    w1, [sp, #8]              // 存储参数 b
 724: b9400fe1    ldr    w1, [sp, #12]
 728: b9400be0    ldr    w0, [sp, #8]
 72c: 0b000020    add    w0, w1, w0                // sum = a + b
 730: b9001fe0    str    w0, [sp, #28]             // 存储 sum
 734: b9401fe0    ldr    w0, [sp, #28]             // return sum

;===== 没有尾声(仅释放栈)=====
 738: 910083ff    add    sp, sp, #0x20             // SP += 32
 73c: d65f03c0    ret                               // 跳回 LR (仍然是 calc 中的 0x770)

关键区别

  • 没有 stp x29, x30 —— 因为 add 不会再调用其他函数,X30 不会被覆盖
  • 没有 mov x29, sp —— 不需要帧指针
  • 只分配了局部变量所需的空间 (32 字节),用完即释放
  • ret 时 X30 仍然完好地保存着 calcbl 写入的返回地址 0x770
1
2
3
4
5
6
00000000000007c4 <print_result>:
 7c4: d10043ff    sub    sp, sp, #0x10             // 分配 16 字节
 7c8: b9000fe0    str    w0, [sp, #12]             // 存参数
 7cc: d503201f    nop
 7d0: 910043ff    add    sp, sp, #0x10             // 释放 16 字节
 7d4: d65f03c0    ret                               // 跳回 LR (main 中的 0x7fc)

动手实验:用 GDB 验证调用栈

1
2
3
4
5
# 交叉编译时保留调试符号(已加 -g)
aarch64-linux-gnu-gcc -O0 -g -o demo demo.c

# 在 ARM64 环境(或 QEMU)中启动 GDB
gdb demo

用 GDB 单步跟踪 demo.c 的调用过程,实地观察 X29/FP、X30/LR 和 SP 的变化。

1
2
3
4
5
6
# 在 add 入口处断点
break add
run

# 查看完整调用链
backtrace
1
2
3
4
5
6
# 预期输出(从当前 add 开始向上追溯 4 层):
# #0  add (a=3780, b=42) at demo.c:7
# #1  0x0000aaaaaaaa0770 in calc (x=84, y=45, z=42) at demo.c:13
# #2  0x0000aaaaaaaa07b4 in process (n=42) at demo.c:20
# #3  0x0000aaaaaaaa07f0 in main (v=42) at demo.c:30
# #4  0x0000fffff7f3dxxx in __libc_start_main ...
1
2
# 查看当前帧的详细信息(含帧地址、返回地址、栈分配)
info frame
1
2
3
4
5
6
# 预期输出关键行:
#  Frame at 0x........:     ← 当前帧地址(即 add 的 SP + 32 后的值)
#   pc = 0x... in add
#   fp = 0x...         ← X29 的值
#   sp = 0x...         ← 当前 SP
#   in call to function add
1
2
# 查看寄存器:FP (X29)、LR (X30)、SP、PC
info registers x29 x30 sp pc
1
2
3
4
5
# 预期输出示例:
# x29            0x........     ← 指向 add 栈帧底部(保存的 calc 的 FP/LR)
# x30            0x........     ← 返回 calc 中 bl add 的下一条指令地址
# sp             0x........     ← 栈顶
# pc             0x........     ← 当前指令地址
1
2
# 以 8 字节为单位 dump 栈内存(从 SP 往上 48 字节——覆盖 add 整个帧)
x/6gx $sp
1
2
3
4
5
# 预期输出(地址仅供参考):
# 0x........:    0x0000....  0x0000....   ← add 的局部变量区
# 0x........:    0x0000....  0x0000....   ← add 的局部变量区
# 0x........:    0x0000....  0x0000....   ← [保存的 calc 的 X29]  [保存的 calc 的 X30]
#              ↑ 这个 X29 指向 calc 的帧      ↑ 这个 X30 就是 add 的返回地址
1
2
3
4
5
# 单步执行一条指令(从 add 的第一条指令 sub sp, sp, #0x20 开始追踪)
stepi

# 每步都观察 SP / x29 / x30 的变化
info registers x29 x30 sp

关键观察点

执行到哪个位置 SP 变化 X29 (FP) X30 (LR)
add 入口(刚 bl add 后) SP 指向调用者(calc)的栈内 指向 calc 的帧底 ==返回 calc 的地址==
执行 sub sp, sp, #0x20 SP -= 32,栈帧建立 不变 不变
执行 ret 不变 不变 仍为 calc 返回地址
ret 后回到 calc SP 回到 calc 帧内 不变 bl 下一指令覆盖

核心验证点:在 add 内部全程 X30 不变(因为 add 是叶子函数,不调用其他函数),这验证了 ARM64 叶子函数无需保存 LR

调用过程中的栈变化全景

假设执行到 add 函数内部(刚执行完 sub sp, sp, #0x20),此时栈的完整布局如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
                  ┌────────────────────────────┐  ← 高地址
                  │        ... (其他数据)        │
                  ├────────────────────────────┤
                  │   main 的局部变量区域        │
                  │   v  = [SP+24]  (值 42)     │
                  │   r  = [SP+28]  (返回值)     │
                  ├────────────────────────────┤
                  │   main 保存的 X29 (旧 FP)    │  ← main 的 FP (X29)
                  │   main 保存的 X30 (返回 LR)  │
       main 帧    ├────────────────────────────┤
                  │   process 的局部变量区域      │
                  │   n  = [SP+28]  (值 42)     │
                  │   a  = [SP+36]  (值 84)     │
                  │   b  = [SP+40]  (值 45)     │
                  │   c  = [SP+44]  (返回值)     │
                  ├────────────────────────────┤
                  │   process 保存的 X29 (main FP)│  ← process 的 FP (X29)
                  │   process 保存的 X30 (main LR)│
    process 帧    ├────────────────────────────┤
                  │   calc 的局部变量区域         │
                  │   z  = [SP+20]  (值 42)     │
                  │   y  = [SP+24]  (值 45)     │
                  │   x  = [SP+28]  (值 84)     │
                  │   tmp= [SP+40]  (值 3780)   │  ← 84 × 45
                  │   res= [SP+44]  (值 3822)   │  ← 3780 + 42
                  ├────────────────────────────┤
                  │   calc 保存的 X29 (proc FP)  │  ← calc 的 FP (X29)
                  │   calc 保存的 X30 (proc LR)  │
     calc 帧      ├────────────────────────────┤
                  │   add 的局部变量区域          │
                  │   a  = [SP+12]  (值 3780)   │
                  │   b  = [SP+8]   (值 42)     │
                  │   sum= [SP+28]  (值 3822)   │
     add 帧       ├────────────────────────────┤  ← SP (当前栈顶)
                  └────────────────────────────┘  ← 低地址

帧指针链 (FP Chain) 从当前 add 向上追溯:

1
2
3
add 的 X29 ──→ [calc 的帧中保存的 X29] ──→ [process 的帧中保存的 X29] ──→ [main 的帧中保存的 X29] ──→ 0
     ↓                     ↓                         ↓                         ↓
  指向 calc 的 FP      指向 process 的 FP         指向 main 的 FP           指向初始值 0

这正是栈回溯 (Stack Unwinding) 的物理基础。

栈展开 (Stack Unwinding) 原理

栈展开(也叫栈回溯 / Stack Backtrace)就是从当前函数出发,沿着帧指针链逆向走回调用链的全过程。

算法

对于一个非叶子函数的栈帧(已保存了 X29/X30 成对数据):

1
2
3
4
5
6
7
                     ┌─────────────────┐
  FP (X29) ────────→ │  旧 X29 (上一级 FP) │  ← 读出后赋值给 FP,向上跳一级
                     │  旧 X30 (返回地址)  │  ← 读出即得上一级的返回地址
                     ├─────────────────┤
                     │    局部变量区      │
                     │        ...        │
                     └─────────────────┘

伪代码

1
2
3
4
5
6
7
8
9
void unwind(uint64_t fp, uint64_t lr) {
    while (fp != 0) {
        // 从栈帧中取出返回地址和上一级 FP
        uint64_t prev_fp = *(uint64_t*)(fp + 0);   // 帧底 +0 处是旧 FP
        uint64_t prev_lr = *(uint64_t*)(fp + 8);   // 帧底 +8 处是旧 LR
        printf("LR = 0x%lx\n", prev_lr);           // 打印返回地址
        fp = prev_fp;                               // 上移一级
    }
}

库函数支持

  • __builtin_frame_address(level) — GCC/Clang 内建,获取第 N 级函数的 FP
  • __builtin_return_address(level) — 获取第 N 级函数的返回地址
  • backtrace() / backtrace_symbols() — glibc 提供的高层栈回溯接口

实际调用链回溯

从 add 内部触发回溯,得到的地址链:

级别 返回地址 (X30) 函数
0 0x770 addcalc 中的 bl add 之后
1 0x7b4 calcprocess 中的 bl calc 之后
2 0x7f0 processmain 中的 bl process 之后
3 0x620+? main_start / __libc_start_main

叶子函数与非叶子函数

特征 叶子函数 (Leaf) 非叶子函数 (Non-leaf)
保存 X29/X30? ❌ 不保存 stp x29, x30, ...
设置帧指针? mov x29, sp
栈帧固定大小? 是(仅局部变量) 是(含保存的寄存器)
栈展开支持? 通过调用者的帧 通过自己的 FP/LR 保存对
X30 是否被覆盖风险? 无风险(不调用) 有风险(调用前已保存)

以上是 -O0(无优化)的输出。开启优化后:

1
2
-O0:   sub sp, sp, #0x20     // 即使叶子函数也分配栈空间(给所有局部变量)
-O2:   // 可能完全不用栈,直接在寄存器中完成计算

例如 -O2 下 add 可能被内联 (inline) 到 calc 中,根本不会产生函数调用指令。

总结

函数调用栈 = 由 FP (X29) 链串联起来的多个栈帧,每个栈帧保存了返回地址 (X30/LR) 和上一级 FP,形成一个可从当前函数回溯到 _start 的单向链表。

# 要点
1 X30 (LR) 由硬件在 bl 时自动写入返回地址,ret 时自动跳回
2 X29 (FP) 由软件在函数入口设置为当前栈帧的基址,形成回溯链表
3 序言 (Prologue) 分配空间 + 保存 FP/LR + 建立新 FP
4 尾声 (Epilogue) 恢复 FP/LR + 释放空间 + ret
5 叶子函数不保存 FP/LR,因为 X30 安全
6 栈回溯 = 沿着 FP 链遍历,从每个帧中提取 LR 即得调用历史
7 ARM64 栈是满递减的,SP 保持 16 字节对齐

实践建议:在调试崩溃 (Segfault / Panic) 时,CPU 的当前 PC 加上 FP 链就可以还原出完整的调用堆栈。理解 X29/X30 的机制,就等于掌握了栈回溯的全部密码。


对比分析:x86-64 与 ARM64

核心差异总览

维度 ARM64 (AArch64) x86-64
返回地址存放 专用寄存器 X30 (LR) 栈上call 压栈,ret 弹栈)
帧指针 X29 (FP) RBP
调用指令 bl target — LR=下条地址,跳转 call target — 返回地址压栈,跳转
返回指令 ret — 跳转 X30 ret — 从栈顶弹出地址并跳转
序言范式 stp x29, x30, [sp,#-N]! / mov x29, sp push rbp / mov rsp, rbp / sub $N, rsp
尾声范式 ldp x29, x30, [sp], #N / ret leave(或 mov rsp, rbp; pop rbp)/ ret
叶子函数优化 可不保存 X29/X30 -O0 也会保存 RBP
参数传递 X0–X7(寄存器传参) RDI, RSI, RDX, RCX, R8, R9(寄存器传参)
栈方向 满递减 (Full-descending) 满递减 (Full-descending)

关键机制差异详解

差异一:返回地址放哪?

ARM64:返回地址放在 X30 (LR) 寄存器中。函数调用链的每一级都必须在自己被覆盖前把 LR 保存到栈上。

1
2
// ARM64: bl 不碰栈,只写 X30
bl  calc          // X30 = 0x7f0, PC = calc

x86-64:返回地址直接压到栈上call 等价于 push 返回地址; jmp target

1
2
// x86-64: call 自动压栈
call  calc         // RSP -= 8, [RSP] = 返回地址, RIP = calc

这意味着 x86-64 的栈帧底部天然有返回地址,无需专门的寄存器来保存。

差异二:帧指针的角色

ARM64:X29 (FP) 由序言 mov x29, sp 设定,指向刚保存的 (旧 FP, 旧 LR) 这对数据。

1
2
  FP ──→ [ 旧 FP  |  旧 LR  ]  ← 连续 16 字节
           ↑ +0       ↑ +8

x86-64:RBP 由序言 push rbp; mov rsp, rbp 设定。栈帧布局为:

1
2
3
  RBP ──→ [ 旧 RBP         ]  ← +0
           [ 返回地址        ]  ← +8  (由 call 压入)
           [ 局部变量区      ]  ← 负偏移

重要区别:ARM64 的 FP 指向一个成对保存区(FP+LR 一起存),x86-64 的 RBP 指向单独保存的旧 RBP,而返回地址在 RBP+8。

逐函数 x86-64 反汇编

add —— x86-64 的叶子函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
0000000000001129 <add>:
;===== 序言 =====
    1129: f3 0f 1e fa    endbr64                     //  CET (Control-flow Enforcement)
    112d: 55             push   %rbp                 // 保存旧 RBP 到栈上
    112e: 48 89 e5       mov    %rsp,%rbp            // RBP = RSP(建立帧指针)

;===== 函数体 =====
    1131: 89 7d ec       mov    %edi,-0x14(%rbp)     // 参数 a(来自 EDI)→ 栈上
    1134: 89 75 e8       mov    %esi,-0x18(%rbp)     // 参数 b(来自 ESI)→ 栈上
    1137: 8b 55 ec       mov    -0x14(%rbp),%edx     // 加载 a
    113a: 8b 45 e8       mov    -0x18(%rbp),%eax     // 加载 b
    113d: 01 d0          add    %edx,%eax            // eax = a + b
    113f: 89 45 fc       mov    %eax,-0x4(%rbp)      // sum 存栈
    1142: 8b 45 fc       mov    -0x4(%rbp),%eax      // 返回值放入 EAX

;===== 尾声 =====
    1145: 5d             pop    %rbp                 // 恢复旧 RBP
    1146: c3             ret                          // 从栈顶弹出返回地址

与 ARM64 对比 (add)

步骤 ARM64 x86-64
序言 sub sp, #0x20(仅腾空间) push rbp + mov rsp, rbp(还是保存了帧指针!)
原因 叶子函数:X30 安全,不保存 -O0 强制所有函数建立帧指针
尾声 add sp, #0x20 pop rbp
返回 ret(跳 X30) ret(弹栈顶地址)

关键观察:x86-64 在 -O0 下即使是叶子函数也执行 push rbp / pop rbp,这是因为 x86-64 的调试约定更依赖 RBP 链,而 ARM64 的 DWARF .eh_frame 表提供了另一种展开路径,所以叶子函数可以跳过 FP 保存。

calc —— x86-64 的非叶子函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
0000000000001147 <calc>:
;===== 序言 =====
    1147: f3 0f 1e fa    endbr64
    114b: 55             push   %rbp                 // 保存旧 RBP
    114c: 48 89 e5       mov    %rsp,%rbp            // RBP = RSP
    114f: 48 83 ec 20    sub    $0x20,%rsp           // 分配 32 字节局部变量空间

;===== 函数体 =====
    1153: 89 7d ec       mov    %edi,-0x14(%rbp)     // 存参数 x
    1156: 89 75 e8       mov    %esi,-0x18(%rbp)     // 存参数 y
    1159: 89 55 e4       mov    %edx,-0x1c(%rbp)     // 存参数 z
    115c: 8b 45 ec       mov    -0x14(%rbp),%eax
    115f: 0f af 45 e8    imul   -0x18(%rbp),%eax     // tmp = x * y
    1163: 89 45 f8       mov    %eax,-0x8(%rbp)      // 存 tmp
    1166: 8b 55 e4       mov    -0x1c(%rbp),%edx     // 准备参数 z
    1169: 8b 45 f8       mov    -0x8(%rbp),%eax      // 准备参数 tmp
    116c: 89 d6          mov    %edx,%esi            // 第二个参数 → ESI
    116e: 89 c7          mov    %eax,%edi            // 第一个参数 → EDI
    1170: e8 b4 ff ff ff call   1129 <add>           // ① 压入返回地址 0x1175
                                                      // ② 跳转到 add
    1175: 89 45 fc       mov    %eax,-0x4(%rbp)      // res = add() 返回值
    1178: 8b 45 fc       mov    -0x4(%rbp),%eax      // return res

;===== 尾声 =====
    117b: c9             leave                        // 相当于: mov rsp, rbp; pop rbp
    117c: c3             ret                          // 从栈顶弹出返回地址

与 ARM64 对比 (calc)

步骤 ARM64 x86-64
保存返回地址 隐式:stp x29, x30 一起存 显式:由 call 指令自动压栈
保存帧指针 和 LR 一起存在 stp push rbp 单独压栈
分配空间 stp 中一并完成(sp,#-48! 单独 sub $0x20, rsp
参数传递 X0=tmp, X1=z(先放好再 bl EDI=tmp, ESI=z(System V 约定)
释放空间 ldp x29, x30, [sp], #48 一步完成 leave = mov rsp, rbp + pop rbp 两步

main —— x86-64 的入口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
00000000000011c5 <main>:
;===== 序言 =====
    11c5: f3 0f 1e fa    endbr64
    11c9: 55             push   %rbp
    11ca: 48 89 e5       mov    %rsp,%rbp
    11cd: 48 83 ec 10    sub    $0x10,%rsp           // 分配 16 字节

;===== 函数体 =====
    11d1: c7 45 f8 2a 00 00 00  movl   $0x2a,-0x8(%rbp)  // v = 42
    11d8: 8b 45 f8       mov    -0x8(%rbp),%eax      // 加载 v
    11db: 89 c7          mov    %eax,%edi            // 第一个参数 → EDI
    11dd: e8 9b ff ff ff call   117d <process>       // 压入 0x11e2 并跳转
    11e2: 89 45 fc       mov    %eax,-0x4(%rbp)      // r = process() 返回值
    11e5: 8b 45 fc       mov    -0x4(%rbp),%eax
    11e8: 89 c7          mov    %eax,%edi
    11ea: e8 c8 ff ff ff call   11b7 <print_result>
    11ef: b8 00 00 00 00 mov    $0x0,%eax            // return 0

;===== 尾声 =====
    11f4: c9             leave
    11f5: c3             ret

x86-64 栈内存布局(执行到 add 时)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
                  ┌──────────────────────────┐  ← 高地址
                  │         ...               │
                  ├──────────────────────────┤
                  │   main 的局部变量          │
                  │   v  = [RBP-8]  (值 42)   │
                  │   r  = [RBP-4]  (返回值)   │
                  ├──────────────────────────┤
                  │   main 的旧 RBP            │  ← main 的 RBP
                  │   main 的返回地址          │  ← 由 _start 的 call 压入
       main 帧    ├──────────────────────────┤
                  │   process 的局部变量        │
                  │   n  = [RBP-14] (值 42)   │
                  │   a  = [RBP-C]  (值 84)   │
                  │   b  = [RBP-8]  (值 45)   │
                  │   c  = [RBP-4]  (返回值)   │
                  ├──────────────────────────┤
                  │   process 的旧 RBP (main的)│  ← process 的 RBP
                  │   process 的返回地址        │  ← main 中 `call process` 压入
    process 帧    ├──────────────────────────┤
                  │   calc 的局部变量           │
                  │   z = [RBP-1C] (值 42)    │
                  │   y = [RBP-18] (值 45)    │
                  │   x = [RBP-14] (值 84)    │
                  │   tmp=[RBP-8]  (值 3780)  │
                  │   res=[RBP-4]  (值 3822)  │
                  ├──────────────────────────┤
                  │   calc 的旧 RBP (process的)│  ← calc 的 RBP
                  │   calc 的返回地址           │  ← process 中 `call calc` 压入
     calc 帧      ├──────────────────────────┤
                  │   add 的局部变量            │
                  │   a = [RBP-14] (值 3780)  │
                  │   b = [RBP-18] (值 42)    │
                  │   sum=[RBP-4]  (值 3822)  │
                  ├──────────────────────────┤
                  │   add 的旧 RBP (calc的)    │  ← add 的 RBP(也是当前 RBP)
     add 帧       │   add 的返回地址           │  ← calc 中 `call add` 压入
                  └──────────────────────────┘  ← RSP(栈顶)
                                               ← 低地址

帧指针链 (RBP Chain)

1
2
3
add 的 RBP ──→ [calc 帧中的旧 RBP] ──→ [process 帧中的旧 RBP] ──→ [main 帧中的旧 RBP] ──→ 0
     ↓                  ↓                        ↓                       ↓
  指向 calc 的 RBP   指向 process 的 RBP       指向 main 的 RBP        指向初始值

栈回溯:x86-64 版本

x86-64 的帧链遍历比 ARM64 多一个间接层:

1
2
3
4
5
6
7
// ARM64: FP 指向 [旧FP | 旧LR]
prev_fp = *(uint64_t*)(fp + 0);    // +0 是旧 FP
prev_lr = *(uint64_t*)(fp + 8);    // +8 是旧 LR

// x86-64: RBP 指向 [旧RBP | 返回地址在+8]
prev_rbp = *(uint64_t*)(rbp);       // +0 是旧 RBP
ret_addr = *(uint64_t*)(rbp + 8);   // +8 是返回地址(由 call 压入)
1
2
3
4
5
6
7
8
void unwind_x86(uint64_t rbp) {
    while (rbp != 0) {
        uint64_t prev_rbp = *(uint64_t*)rbp;         // RBP+0 = 旧 RBP
        uint64_t ret_addr = *(uint64_t*)(rbp + 8);   // RBP+8 = 返回地址
        printf("RIP = 0x%lx\n", ret_addr);
        rbp = prev_rbp;
    }
}

leave 指令揭秘

x86-64 的 leave 是 ARM64 中没有的指令,它等价于:

1
2
leave      mov  rsp, rbp      // RSP = RBP(丢弃局部变量空间)
            pop  rbp           // RBP = [RSP], RSP += 8(恢复旧 RBP)

对比 ARM64:

1
2
3
4
5
6
7
// ARM64 尾声必须两条指令
ldp   x29, x30, [sp], #48     // 同时恢复 FP、LR,释放空间
ret                            // 跳回 LR

// x86-64 尾声也常是两条
leave                          // 恢复 RSP、RBP
ret                            // 弹出返回地址并跳转

但结构完全不同:

  • ARM64 的 ldp加载成对寄存器 + 更新 SP(一条指令完成恢复+释放)
  • x86-64 的 leave恢复 RSP 和 RBP,不碰返回地址
  • x86-64 的 ret 负责弹出返回地址

优化模式下的差异

场景 ARM64 x86-64
-O0 叶子函数 不保存 FP/LR 保存 RBP(push rbp
-O2 叶子函数 可能无栈操作 可能无栈操作
-fomit-frame-pointer 可用 strip 优化掉 FP 默认开启(省略 RBP)
无帧指针时栈回溯 依赖 .eh_frame (DWARF) 依赖 .eh_frame (DWARF)

关于 -fomit-frame-pointer

现代 Linux 上 x86-64 GCC 默认开启 -fomit-frame-pointer(优化级别 -O1 及以上),此时 RBP 作为通用寄存器使用,不再作为帧指针。栈回溯完全依赖 .eh_frame 中的 DWARF 展开描述。

1
2
3
# 对比有无 frame pointer 的反汇编
$ gcc -O2 -fno-omit-frame-pointer -o with_fp demo.c
$ gcc -O2 -fomit-frame-pointer -o without_fp demo.c

总结对比

特性 ARM64 (X29/X30) x86-64 (RBP/栈)
返回地址载体 X30 寄存器 栈内存call 压入)
帧指针 X29(显式 mov x29, sp RBP(push rbp; mov rsp, rbp
序言原子性 stp 一条指令完成 保存+分配 分开:push + mov + sub
返回地址保护 函数序言主动保存 LR 到栈 call 已自动压栈,天然保护
叶子函数 可完全跳过序言/尾声 -O0 仍保留帧指针操作
栈回溯取址 帧底 +0→旧 FP,+8→旧 LR 帧底 +0→旧 RBP,+8→返回地址
特殊指令 stp / ldp 成对操作 leave 一步恢复 RSP+RBP
设计哲学 寄存器优先(减少内存访问) 栈优先(简化硬件设计)

一句话区别

ARM64 把返回地址放在寄存器 (X30/LR) 里,需要时再存到栈上;x86-64 让 call 指令直接把返回地址压到栈上。前者省内存但多一条保存指令,后者多一次内存写入但硬件更简单。


实践建议:当你在 x86-64 上调试崩溃时,bt 命令的原理就是在 RBP 链上遍历 [rbp+0](上一帧指针)和 [rbp+8](返回地址)。而在 ARM64 上,则是沿着 X29 链读取 [fp+0](旧 FP)和 [fp+8](旧 LR)。两种架构殊途同归:一个链表,遍历即得全貌。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计