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 指向当前函数栈帧的一个固定位置。它的核心作用有两个:
- 为局部变量和临时数据提供基地址 —— 通过
[x29, #offset] 寻址
- 形成链表,支持栈回溯 (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 中的 x0–x30 均属此类,w0–w30 则用于访问它们的低 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 自增 → 回到 ①
|
- 取指 IF(Instruction Fetch):CPU 根据
PC 寄存器中的地址,从指令存储器或 Cache 中读取指令到 IR 寄存器。
- 译码/读寄存器 ID(Instruction Decode):操作控制器对指令进行译码,同时从寄存器堆中读取操作数。
- 执行/计算地址 EX(Execute):ALU 执行运算操作或计算内存地址。
- 访存 MEM(Memory Access):对存储器(数据 Cache)进行读写操作。
- 写回 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 仍然完好地保存着 calc 中 bl 写入的返回地址 0x770
print_result —— 另一个叶子函数
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 |
add → calc 中的 bl add 之后 |
| 1 |
0x7b4 |
calc → process 中的 bl calc 之后 |
| 2 |
0x7f0 |
process → main 中的 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)。两种架构殊途同归:一个链表,遍历即得全貌。