超详细函数栈帧详解(C语言为例)
2026/5/13 9:54:12 网站建设 项目流程

一 函数栈帧是什么?

1.1 栈的定义

栈(stack是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函 数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可 以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出 栈(First In Last OutFIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据 从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
你是否会疑惑一个问题,我明明在少数教材看到的栈是向上生长的,类似于以上图中书摆放的画法,但其实这是画法迷惑,实际上道理是一样的,只不过画法不同。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。

可以补充成下面这一段,更准确一些:


在我们常见的i386 / x86-32架构下,栈顶通常由一个叫做ESP的寄存器定位。

其中:

ESP = Extended Stack Pointer

它保存的是当前栈顶的地址。当函数调用、局部变量分配、参数压栈、返回地址保存等操作发生时,ESP 的值会发生变化。

在 i386 中,栈通常是向低地址方向增长的,所以:

push 数据 → ESP 变小 pop 数据 → ESP 变大

例如:

高地址 0x8000 栈底附近 0x7FFC push 后 ESP 指向这里 0x7FF8 再 push 后 ESP 指向这里 低地址

也就是说,栈顶不是固定位置,而是由栈指针寄存器记录的当前位置


不过,不同 CPU 架构和操作系统中,栈指针寄存器的名字不完全一样。

常见情况如下:

平台 / 架构

栈指针寄存器

说明

i386 / x86-32

ESP

32 位栈指针

x86-64 / AMD64

RSP

64 位栈指针

ARM 32 位

SP/R13

ARM 中 R13 通常作为栈指针

ARM64 / AArch64

SP

64 位 ARM 架构的栈指针

macOS on Intel

RSP

Intel Mac 使用 x86-64 架构

macOS on Apple Silicon

SP

M1/M2/M3/M4 等使用 ARM64 架构

Linux on x86-64

RSP

常见 PC/Linux 服务器架构

Linux on ARM64

SP

例如树莓派 64 位系统、ARM 服务器等


所以更完整地说:

在 i386 下,栈顶由ESP寄存器定位;在 x86-64 下,栈顶由RSP寄存器定位;在 ARM / ARM64 架构下,栈顶通常由SP寄存器定位。无论寄存器名字如何变化,它们的作用都是保存当前栈顶地址。


另外还有一个容易混淆的寄存器:

架构

栈指针

栈帧指针

x86-32

ESP

EBP

x86-64

RSP

RBP

ARM64

SP

FP/X29

ESP/RSP/SP表示当前栈顶位置,会随着pushpop、函数调用、局部变量分配而变化。

EBP/RBP/FP通常表示当前函数栈帧的基准位置,方便访问参数和局部变量。不过现代编译器在开启优化时,可能会省略帧指针,把这个寄存器拿去做别的用途。


ESP、RSP、SP 本质上都是“栈顶地址寄存器”;不同架构名字不同,但栈向下增长时,它们的值会变小。

(参考《程序员的自我修养》)

1.2 堆栈帧的定义

堆栈帧一般更准确地叫栈帧,英文是stack frame。它不是“堆 + 栈”的意思,而是指:

每调用一个函数,系统都会在栈上为这个函数开辟一块临时空间,这块空间就叫这个函数的栈帧。


1.3 为什么需要栈帧?

比如有这样一段代码:

int Add(int x, int y) { int z = x + y; return z; } int main() { int ret = Add(10, 20); return 0; }

main()调用Add(10, 20)时,CPU 必须记住几件事:

Add 的参数 x、y 是多少? Add 的局部变量 z 放在哪里? Add 执行完以后,要回到 main 的哪一行继续执行? Add 执行期间,有些寄存器的旧值要不要保存?

这些信息通常就保存在Add()对应的栈帧里。


1.4 一个函数的栈帧里通常有什么?

不同架构和编译器细节不同,但大体包括:

在很多 x86 / x86-64 教材里,会把它画成这样:

这里要注意:

RSP / ESP / SP指向当前栈顶,会经常变化。
RBP / EBP / FP常用来作为当前函数栈帧的“固定参考点”。

二 编译与链接

这里简单介绍一下预处理相关知识,为后续代码演示铺垫,读者如果想了解更详细的预处理知识,可以点个关注,后续将会更新。

预处理流程图如下:

2.1 预编译

在预处理阶段,源⽂件和头⽂件会被处理成为.i为后缀的⽂件。
gcc环境下想观察⼀下,对test.c⽂件预处理后的.i⽂件,命令如下:
gcc -E 函数栈帧的创建.c -o 函数栈帧的创建.i
预处理阶段主要处理那些源⽂件中#开始的预编译指令。⽐如:#include,#define,处理的规则如下:
1.将所有的#define删除,并展开所有的宏定义。
2.处理所有的条件编译指令,如:#if#ifdef#elif#else#endif
3.处理#include 预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进 ⾏的,也就是说被包含的头⽂件也可能包含其他⽂件。
4.删除所有的注释
5.添加⾏号和⽂件名标识,⽅便后续编译器⽣成调试信息等。
或保留所有的#pragma的编译器指令,编译器后续会使⽤。
经过预处理后的.i⽂件中不再包含宏定义,因为宏已经被展开。并且包含的头⽂件都被插⼊到.i
⽂件中。所以当我们⽆法知道宏定义或者头⽂件是否包含正确的时候,可以查看预处理后的.i⽂件
来确认。

2.2 编译

编译过程就是将预处理后的⽂件进⾏⼀系列的:词法分析、语法分析、语义分析及优化,⽣成相应的 汇编代码⽂件。
编译过程的命令如下:
gcc -S 函数栈帧的创建.i -o 函数栈帧的创建.s

对下⾯代码进⾏编译的时候,会怎么做呢?假设有下⾯的代码
array[index] = (index+4)*(2+6);

2.2.1 词法分析

将源代码程序被输⼊扫描器,扫描器的任务就是简单的进⾏词法分析,把代码中的字符分割成⼀系列 的记号(关键字、标识符、字⾯量、特殊字符等)。
上⾯程序进⾏词法分析后得到了16个记号:

2.2.2 语法分析

接下来语法分析器,将对扫描产⽣的记号进⾏语法分析,从⽽产⽣语法树。这些语法树是以表达式为 节点的树。

2.2.3 语义分析

由语义分析器来完成语义分析,即对表达式的语法层⾯分析。编译器所能做的分析是语义的静态分
析。静态语义分析通常包括声明和类型的匹配,类型的转换等。这个阶段会报告错误的语法信息。

2.2.4 目标代码生成与优化

在编译过程中,目标代码生成与优化主要发生在编译阶段,也就是从.i文件生成.s文件的过程中。

整体流程可以简单理解为:

.c 源文件 ↓ 预处理 .i 预处理文件 ↓ 编译:优化 + 目标代码生成 .s 汇编文件 ↓ 汇编 .o 目标文件 ↓ 链接 .out 可执行程序
1. 代码优化

代码优化的作用是:在不改变程序运行结果的前提下,让程序运行得更快、生成的代码更少。

例如:

array[index] = (index + 4) * (2 + 6);

其中:

2 + 6

是固定值,编译器可以提前算出结果:

array[index] = (index + 4) * 8;

这就是常见的常量折叠优化

如果index的值也能确定,比如:

int index = 2;

那么编译器还可能继续优化成:

array[2] = 48;
2. 目标代码生成

目标代码生成的作用是:把优化后的代码转换成目标机器能够执行的指令。

例如 C 语言中的:

int c = a + b;

可能会被编译成类似这样的汇编代码:

ldr w8, [sp, #12] ldr w9, [sp, #8] add w8, w8, w9 str w8, [sp, #4]

也就是说,编译器会把高级语言中的变量、表达式、函数调用等,转换成 CPU 能识别的机器指令。

3. 总结

代码优化负责让代码更高效,目标代码生成负责把代码翻译成机器能执行的形式。

最终,编译器会生成汇编文件.s,再经过汇编和链接,得到可以运行的程序。

2.3 汇编

汇编器是将汇编代码转转变成机器可执⾏的指令,每⼀个汇编语句⼏乎都对应⼀条机器指令。就是根 据汇编指令和机器指令的对照表⼀⼀的进⾏翻译,也不做指令优化。
汇编的命令如下:
gcc -c 函数栈帧的创建.c -o 函数栈帧的创建.o

2.4 链接

本次分析准确来说为静态链接的分析

静态链接发生在编译流程的最后阶段:

.c 源文件 ↓ .i 预处理文件 ↓ .s 汇编文件 ↓ .o 目标文件 ↓ 静态链接 可执行程序

静态链接的作用是:把多个目标文件.o和静态库.a合并成一个可执行程序

例如:

gcc main.o add.o -o main.out

或者链接静态库:

gcc main.o libtest.a -o main.out

1. 地址和空间分配

每个.o目标文件内部都有自己的代码段和数据段,例如:

main.o .text 代码段 .data 已初始化全局变量 .bss 未初始化全局变量 add.o .text .data .bss

在链接前,每个目标文件都是“独立的”,它们里面的地址通常只是相对地址,还不能直接运行。

静态链接时,链接器会把相同类型的段合并:

main.o 的 .text add.o 的 .text ↓ 可执行文件的 .text
main.o 的 .data add.o 的 .data ↓ 可执行文件的 .data

也就是:

所有代码段合并成一个大的代码段 所有数据段合并成一个大的数据段 所有 bss 段合并成一个大的 bss 段

然后链接器会为这些段分配最终的虚拟地址。

例如:

.text 段:存放程序指令 .data 段:存放已初始化的全局变量 .bss 段:存放未初始化的全局变量 .rodata 段:存放字符串常量、只读数据

可以简单理解为:

地址和空间分配,就是链接器把多个目标文件中的各个段合并,并为它们安排最终的位置。


2. 符号决议

符号可以理解为程序中的“名字”,比如:

int global = 10; int Add(int x, int y) { return x + y; }

这里的:

global Add

都是符号。

假设有两个文件:

// add.c int Add(int x, int y) { return x + y; }
// main.c #include <stdio.h> int Add(int x, int y); int main() { int ret = Add(1, 2); printf("%d\n", ret); return 0; }

编译后:

gcc -c main.c -o main.o gcc -c add.c -o add.o

此时main.o里知道自己调用了Add,但还不知道Add的具体地址。

main.o:引用了 Add,但不知道 Add 在哪里 add.o :定义了 Add

静态链接时,链接器会查找所有目标文件和库文件,找到Add的定义,然后把main.o中对Add的引用和add.o中的Add定义对应起来。

这个过程就叫符号决议

简单说:

符号决议 = 找名字对应的真正定义

如果找不到定义,就会出现类似错误:

undefined reference to `Add`

如果同一个全局符号被多个文件重复定义,也可能出现:

multiple definition of `Add`

3. 重定位

符号决议解决的是:

Add 这个名字到底对应哪个函数?

但还有一个问题:

Add 最终被放到了哪个地址?

这就需要重定位

.o文件阶段,很多地址还没有确定。

例如main.c中调用:

Add(1, 2);

生成的汇编可能类似:

call Add

但是在main.o里,Add的真实地址还不知道,所以这个位置会先留下一个“占位”。

链接器完成地址和空间分配后,知道了:

Add 函数最终地址 = 0x100003f20

然后它会把main.o中调用Add的地方修改成正确的跳转地址。

这个修改地址的过程就叫重定位

简单说:

重定位 = 把占位地址改成最终真实地址

4. 三者关系

静态链接可以分成三个核心步骤:

1. 地址和空间分配 ↓ 合并各个目标文件的段,并分配最终地址 2. 符号决议 ↓ 找到每个符号引用对应的定义 3. 重定位 ↓ 根据最终地址,修正代码和数据中的地址引用

可以用一个例子理解:

// main.c int Add(int x, int y); int main() { return Add(1, 2); }
// add.c int Add(int x, int y) { return x + y; }

链接前:

main.o: main 函数 调用了 Add,但不知道 Add 地址 add.o: Add 函数

链接时:

地址和空间分配: 把 main 和 Add 都放进最终的 .text 段 符号决议: 确认 main.o 里的 Add 引用,指向 add.o 里的 Add 定义 重定位: 把 main 中调用 Add 的地址修正为 Add 的最终地址

链接后:

可执行程序: main 和 Add 都有了确定地址 main 可以正确跳转到 Add 执行

5. 总结

静态链接就是把多个目标文件和静态库合并成一个完整的可执行文件。

其中:

地址和空间分配: 把各个目标文件的代码段、数据段等合并,并安排最终地址。 符号决议: 确定每个符号引用到底对应哪个定义。 重定位: 把代码或数据中的临时地址修正为最终地址。

符号决议负责“找人”,地址分配负责“安排座位”,重定位负责“把座位号填到正确的位置”。

三 函数栈帧的创建过程(以ARM 64架构为例)

之所以以arm64位架构为例,是因为目前很多教材以sp作为栈顶指针,所以讲解起来会更容易,更方便读者去理解。目前大部分同学的机器普遍都是64位,虽然32位esp,ebp很经典,但在现实中不常用。下文中x29寄存器对应着esp(存放栈顶指针),而x30寄存器对应着ebp(存放栈底指针)。

看下面这个函数:

#include <stdio.h> int Add(int a,int b) { int c=a+b; return c; } int main() { int a=1; int b=2; int c=Add(a,b); printf("%d",c); return 0; }

进入反汇编看看main函数(对汇编指令迷惑的可以跳转到3.9指令集合)

main: sub sp, sp, #0x30 stp x29, x30, [sp, #0x20] add x29, sp, #0x20 mov w8, #0x0 str w8, [sp, #0xc] stur wzr, [x29, #-0x4] mov w8, #0x1 stur w8, [x29, #-0x8] mov w8, #0x2 stur w8, [x29, #-0xc] ldur w0, [x29, #-0x8] ldur w1, [x29, #-0xc] bl 0x104164460 ; Add str w0, [sp, #0x10] ldr w8, [sp, #0x10] mov x9, sp str x8, [x9] adrp x0, 0 add x0, x0, #0x4f4 ; "%d" bl 0x1041644e8 ; printf ldr w0, [sp, #0xc] ldp x29, x30, [sp, #0x20] add sp, sp, #0x30 ret

这份反汇编不是 x86/x86-64,而是ARM64 / AArch64,很像macOS Apple Silicon下 CLion 生成的反汇编。


3.1 main 函数建立栈帧

sub sp, sp, #0x30

main开辟 48 字节栈空间。

0x30 = 48 字节

因为栈向低地址增长,所以sp减小。


stp x29, x30, [sp, #0x20]

stp是 store pair,保存一对寄存器。

这句把:

x29:旧的帧指针 FP x30:返回地址 LR

一起保存到栈上。

这就对应之前画图中的:

旧的帧指针 RBP / EBP / FP 返回地址

在 ARM64 里:

x29 = FP,帧指针 x30 = LR,链接寄存器,保存返回地址

add x29, sp, #0x20

建立当前函数的帧指针。

执行后:

x29 指向 main 当前栈帧中保存 x29/x30 的位置

所以后面很多局部变量用[x29, #-偏移]来访问。


3.2 main 中的局部变量

mov w8, #0x0 str w8, [sp, #0xc]

这里把 0 存到[sp + 0xc],通常是main函数的返回值槽,最后会读回来作为返回值。


stur wzr, [x29, #-0x4]

wzr是零寄存器,永远是 0。

所以这句是:

某个位置 = 0;

通常是编译器给main准备的临时返回值或初始化空间。


mov w8, #0x1 stur w8, [x29, #-0x8]

把 1 存到[x29 - 0x8]

对应:

int a = 1;

mov w8, #0x2 stur w8, [x29, #-0xc]

把 2 存到[x29 - 0xc]

对应:

int b = 2;

3.3 main 调用 Add

ldur w0, [x29, #-0x8] ldur w1, [x29, #-0xc]

把局部变量读到参数寄存器里

w0 = a w1 = b

ARM64 里,函数参数一般这样传:

第 1 个整数参数:x0/w0 第 2 个整数参数:x1/w1 第 3 个整数参数:x2/w2 第 4 个整数参数:x3/w3 ...

所以这两句就是准备调用:

Add(a, b);

bl 0x104164460 ; Add

bl是函数调用指令,全称可以理解为:

Branch with Link

它做两件事:

1. 跳转到 Add 函数执行 2. 把返回地址保存到 x30 寄存器

所以 ARM64 上不像传统 x86 那样一定把返回地址直接压栈,ARM64 的返回地址首先进入x30

不过如果当前函数还要继续调用别的函数,为了防止x30被覆盖,就会像main这样提前把x30保存到自己的栈帧里。


3.4 Add 返回后

str w0, [sp, #0x10]

Add的返回值在w0中。

这句把返回值保存到main的局部变量里,大概对应:

int ret = Add(a, b);

ldr w8, [sp, #0x10]

ret读到w8


mov x9, sp str x8, [x9]

这两句比较有意思。

这里的x8x9可以先理解成临时寄存器,类似 C 语言里编译器随手用的临时变量。

3.5 x8w8是什么关系?

在 ARM64 里:

x8 = 64 位寄存器 w8 = x8 的低 32 位

可以理解成:

x8: 64 位 w8: x8 的低 32 位

比如:

ldr w8, [sp, #0x10]

意思是:从[sp + 0x10]这个栈位置取出 4 字节整数,放到w8里。

它把ret放到当前sp指向的位置,通常是为了给后面的可变参数函数printf准备参数区域。

这通常对应:

ret

因为retint,所以用w8读取。

因为printf是可变参数函数:

printf("%d", ret);

ARM64 调用可变参数函数时,编译器有时会额外把参数放到栈上某些位置,方便printf内部处理可变参数。


adrp x0, 0 add x0, x0, #0x4f4 ; "%d"

这两句是把字符串"%d"的地址放入x0

也就是准备第一个参数:

printf("%d", ret);

此时:

x0 = "%d" 的地址 栈上某处保存了 ret

bl 0x1041644e8 ; printf

调用printf


3.6 main 函数返回

ldr w0, [sp, #0xc]

把返回值读到w0

因为main返回int,所以最终返回值放在w0

这里读出来的是 0,所以相当于:

return 0;

ldp x29, x30, [sp, #0x20]

恢复之前保存的:

x29:旧帧指针 x30:返回地址

add sp, sp, #0x30

释放main的 48 字节栈帧。


ret

返回。


3.7 main 的栈帧图

执行完:

sub sp, sp, #0x30 stp x29, x30, [sp, #0x20] add x29, sp, #0x20

后,main的栈帧:

高地址 ┌──────────────────────────┐ │ sp + 0x30 │ ├──────────────────────────┤ │ 保存的 x30,也就是 LR │ ← [sp + 0x28] ├──────────────────────────┤ │ 保存的 x29,也就是旧 FP │ ← [sp + 0x20],x29 指向这里 ├──────────────────────────┤ │ 其他局部空间 / 对齐 │ ├──────────────────────────┤ │ ret = Add(a, b) │ ← [sp + 0x10] ├──────────────────────────┤ │ main 返回值 0 │ ← [sp + 0xc] ├──────────────────────────┤ │ b = 2 │ ← [x29 - 0xc] ├──────────────────────────┤ │ a = 1 │ ← [x29 - 0x8] ├──────────────────────────┤ │ 临时空间 / printf 参数区 │ ← [sp] └──────────────────────────┘ 低地址


为什么 Add 没有保存 x29/x30,而 main 保存了?

因为Add很简单:

Add: sub sp, sp, #0x10 ... add sp, sp, #0x10 ret

它没有再调用其他函数。

x30里保存着返回地址,只要Add不调用别的函数,x30就不会被新的bl覆盖,所以可以直接ret

但是main里面调用了:

bl Add bl printf

每次bl都会修改x30

所以main一开始必须保存自己的x30

stp x29, x30, [sp, #0x20]

否则等main最后ret的时候,原来的返回地址就丢了。


3.8 ARM64 和 x86-64 栈帧的对应关系

你之前学的 x86/x86-64 里常见的是:

RSP / ESP:栈指针 RBP / EBP:帧指针 返回地址:通常由 call 指令压入栈

ARM64 中对应关系是:

概念

x86-64

x86-32

ARM64

栈指针

RSP

ESP

SP

帧指针

RBP

EBP

X29/FP

返回地址

栈中

栈中

X30/LR,必要时再保存到栈

函数调用

call

call

bl

函数返回

ret

ret

ret

第一个 int 参数

常见为edi

多数走栈

w0

第二个 int 参数

常见为esi

多数走栈

w1

int 返回值

eax

eax

w0

所以你看到:

w0, w1

不是普通临时变量,而是 ARM64 调用约定中的参数寄存器。

3.9 重点指令

符号解释:

Reg = 寄存器 Mem[address] = 某个内存地址里的值 sp = 栈顶指针 x29 = 帧指针 FP x30 = 返回地址寄存器 LR PC = 当前 CPU 要执行的指令地址

1.sub sp, sp, #0x30

sub sp, sp, #0x30

公式:

sp = sp - 0x30

因为:

0x30 = 十进制 48

所以也可以写成:

sp = sp - 48

解释:

这条指令是在开辟栈空间

ARM64 的栈通常向低地址增长,所以sp减小,表示栈帧变大。

比如原来:

sp = 0x1000

执行后:

sp = 0x1000 - 0x30 = 0x0FD0

也就是为main函数开了48字节空间。


2.add sp, sp, #0x30

add sp, sp, #0x30

公式:

sp = sp + 0x30

也就是:

sp = sp + 48

解释:

这条指令是在释放栈空间

函数快结束时,把之前减掉的0x30加回来,恢复调用main之前的栈顶位置。

和前面的sub是一对:

sub sp, sp, #0x30 ; 开栈帧 ... add sp, sp, #0x30 ; 销毁栈帧

3.stp x29, x30, [sp, #0x20]

stp x29, x30, [sp, #0x20]

stpstore pair,意思是“一次存两个寄存器”。

公式:

Mem64[sp + 0x20] = x29 Mem64[sp + 0x28] = x30

为什么第二个是0x28

因为x29是 64 位寄存器,占8字节。

0x20 + 8 = 0x28

解释:

这条指令把x29x30保存到栈上。

其中:

x29 = 旧的帧指针 FP x30 = 返回地址 LR

栈上变成:

高地址 ┌────────────────────┐ │ sp + 0x30 │ ├────────────────────┤ │ x30,返回地址 │ ← [sp + 0x28] ├────────────────────┤ │ x29,旧帧指针 │ ← [sp + 0x20] ├────────────────────┤ │ 其他局部变量空间 │ └────────────────────┘ 低地址

4.ldp x29, x30, [sp, #0x20]

ldp x29, x30, [sp, #0x20]

ldpload pair,意思是“一次读取两个寄存器”。

公式:

x29 = Mem64[sp + 0x20] x30 = Mem64[sp + 0x28]

解释:

这条指令是在函数返回前,恢复之前保存的x29x30

也就是恢复:

x29 = 调用者的帧指针 x30 = main 函数应该返回到的地址

它和前面的stp是一对:

stp x29, x30, [sp, #0x20] ; 保存 ... ldp x29, x30, [sp, #0x20] ; 恢复

5.str w0, [sp, #0xc]

str w0, [sp, #0xc]

strstore register,意思是“把寄存器的值存到内存”。

公式:

Mem32[sp + 0xc] = w0

解释:

w0是 32 位寄存器,通常用来保存int类型返回值或参数。

因为w0是 32 位,所以这里存的是4字节。

例如:

str w0, [sp, #0xc]

可以理解成 C 语言里的:

*(int *)(sp + 0xc) = w0;

如果此时w0 = 0,那么就是:

把 0 存到 sp + 0xc 这个栈位置

6.ldr w8, [sp, #0xc]

ldr w8, [sp, #0xc]

ldrload register,意思是“从内存读取到寄存器”。

公式:

w8 = Mem32[sp + 0xc]

解释:

这条指令从sp + 0xc这个地址读取 4 字节数据,放到w8

可以理解成 C 语言:

w8 = *(int *)(sp + 0xc);

注意:

w8 是 x8 的低 32 位 x8 是完整 64 位寄存器

在 ARM64 中,写入w8时,通常会把x8的高 32 位清零。


7.bl Add

bl Add

blbranch with link,意思是“跳转并保存返回地址”。

公式

x30 = 当前下一条指令的地址 PC = Add 函数的地址

解释:

它做两件事:

1. 把返回地址保存到 x30 2. 跳转到 Add 函数执行

可以类比 C 语言:

Add(...);

也可以类比 x86 里的:

call Add

区别是:

x86 的 call 通常把返回地址压入栈中 ARM64 的 bl 通常先把返回地址放入 x30

如果当前函数还会继续调用别的函数,就需要把x30保存到栈上,否则下一次bl会覆盖它。


8.ret

ret

公式:

PC = x30

解释:

ret的意思是:跳回x30保存的返回地址。

也就是说:

函数执行完了,回到调用者那里继续执行

例如:

bl Add

会把返回地址放到x30

Add最后执行:

ret

CPU 就会跳回x30指向的位置,也就是main里调用Add之后的下一条指令。


总表

指令公式作用
sub sp, sp, #0x30sp = sp - 0x30开辟 48 字节栈空间
add sp, sp, #0x30sp = sp + 0x30释放 48 字节栈空间
stp x29, x30, [sp, #0x20]Mem64[sp+0x20]=x29Mem64[sp+0x28]=x30保存旧 FP 和返回地址
ldp x29, x30, [sp, #0x20]x29=Mem64[sp+0x20]x30=Mem64[sp+0x28]恢复旧 FP 和返回地址
str w0, [sp, #0xc]Mem32[sp+0xc]=w0把 32 位值存到栈上
ldr w8, [sp, #0xc]w8=Mem32[sp+0xc]从栈上读取 32 位值
bl Addx30=返回地址PC=Add地址调用 Add 函数
retPC=x30返回调用者3.10

3.10 流程总结

这段代码展示的是ARM64 下函数栈帧的创建、使用和销毁

main: 开栈帧 保存 x29/x30 设置 x29 为帧指针 准备参数 w0/w1 bl 调用 Add 保存返回值 w0 调用 printf 恢复 x29/x30 释放栈帧 ret 返回 Add: 开一个小栈帧 保存参数 x/y 计算 x + y 把结果放入 w0 释放栈帧 ret 返回

在 ARM64 里,sp是栈顶指针,x29是帧指针,x30是返回地址寄存器,w0/w1用来传 int 参数,w0也用来保存 int 返回值。

如果觉得对你有帮助,不妨点个关注,点点赞鼓励一下作者

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询