一 函数栈帧是什么?
1.1 栈的定义
可以补充成下面这一段,更准确一些:
在我们常见的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 |
| 32 位栈指针 |
x86-64 / AMD64 |
| 64 位栈指针 |
ARM 32 位 |
| ARM 中 R13 通常作为栈指针 |
ARM64 / AArch64 |
| 64 位 ARM 架构的栈指针 |
macOS on Intel |
| Intel Mac 使用 x86-64 架构 |
macOS on Apple Silicon |
| M1/M2/M3/M4 等使用 ARM64 架构 |
Linux on x86-64 |
| 常见 PC/Linux 服务器架构 |
Linux on ARM64 |
| 例如树莓派 64 位系统、ARM 服务器等 |
所以更完整地说:
在 i386 下,栈顶由ESP寄存器定位;在 x86-64 下,栈顶由RSP寄存器定位;在 ARM / ARM64 架构下,栈顶通常由SP寄存器定位。无论寄存器名字如何变化,它们的作用都是保存当前栈顶地址。
另外还有一个容易混淆的寄存器:
架构 | 栈指针 | 栈帧指针 |
|---|---|---|
x86-32 |
|
|
x86-64 |
|
|
ARM64 |
|
|
ESP/RSP/SP表示当前栈顶位置,会随着push、pop、函数调用、局部变量分配而变化。
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 预编译
gcc -E 函数栈帧的创建.c -o 函数栈帧的创建.i2.2 编译
gcc -S 函数栈帧的创建.i -o 函数栈帧的创建.sarray[index] = (index+4)*(2+6);2.2.1 词法分析
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 函数栈帧的创建.o2.4 链接
本次分析准确来说为静态链接的分析
静态链接发生在编译流程的最后阶段:
.c 源文件 ↓ .i 预处理文件 ↓ .s 汇编文件 ↓ .o 目标文件 ↓ 静态链接 可执行程序静态链接的作用是:把多个目标文件.o和静态库.a合并成一个可执行程序。
例如:
gcc main.o add.o -o main.out或者链接静态库:
gcc main.o libtest.a -o main.out1. 地址和空间分配
每个.o目标文件内部都有自己的代码段和数据段,例如:
main.o .text 代码段 .data 已初始化全局变量 .bss 未初始化全局变量 add.o .text .data .bss在链接前,每个目标文件都是“独立的”,它们里面的地址通常只是相对地址,还不能直接运行。
静态链接时,链接器会把相同类型的段合并:
main.o 的 .text add.o 的 .text ↓ 可执行文件的 .textmain.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 = bARM64 里,函数参数一般这样传:
第 1 个整数参数:x0/w0 第 2 个整数参数:x1/w1 第 3 个整数参数:x2/w2 第 4 个整数参数:x3/w3 ...所以这两句就是准备调用:
Add(a, b);bl 0x104164460 ; Addbl是函数调用指令,全称可以理解为:
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]这两句比较有意思。
这里的x8、x9可以先理解成临时寄存器,类似 C 语言里编译器随手用的临时变量。
3.5 x8和w8是什么关系?
在 ARM64 里:
x8 = 64 位寄存器 w8 = x8 的低 32 位可以理解成:
x8: 64 位 w8: x8 的低 32 位比如:
ldr w8, [sp, #0x10]意思是:从[sp + 0x10]这个栈位置取出 4 字节整数,放到w8里。
它把ret放到当前sp指向的位置,通常是为了给后面的可变参数函数printf准备参数区域。
这通常对应:
ret因为ret是int,所以用w8读取。
因为printf是可变参数函数:
printf("%d", ret);ARM64 调用可变参数函数时,编译器有时会额外把参数放到栈上某些位置,方便printf内部处理可变参数。
adrp x0, 0 add x0, x0, #0x4f4 ; "%d"这两句是把字符串"%d"的地址放入x0。
也就是准备第一个参数:
printf("%d", ret);此时:
x0 = "%d" 的地址 栈上某处保存了 retbl 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 |
|---|---|---|---|
栈指针 |
|
|
|
帧指针 |
|
|
|
返回地址 | 栈中 | 栈中 |
|
函数调用 |
|
|
|
函数返回 |
|
|
|
第一个 int 参数 | 常见为 | 多数走栈 |
|
第二个 int 参数 | 常见为 | 多数走栈 |
|
int 返回值 |
|
|
|
所以你看到:
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]stp是store pair,意思是“一次存两个寄存器”。
公式:
Mem64[sp + 0x20] = x29 Mem64[sp + 0x28] = x30为什么第二个是0x28?
因为x29是 64 位寄存器,占8字节。
0x20 + 8 = 0x28解释:
这条指令把x29和x30保存到栈上。
其中:
x29 = 旧的帧指针 FP x30 = 返回地址 LR栈上变成:
高地址 ┌────────────────────┐ │ sp + 0x30 │ ├────────────────────┤ │ x30,返回地址 │ ← [sp + 0x28] ├────────────────────┤ │ x29,旧帧指针 │ ← [sp + 0x20] ├────────────────────┤ │ 其他局部变量空间 │ └────────────────────┘ 低地址4.ldp x29, x30, [sp, #0x20]
ldp x29, x30, [sp, #0x20]ldp是load pair,意思是“一次读取两个寄存器”。
公式:
x29 = Mem64[sp + 0x20] x30 = Mem64[sp + 0x28]解释:
这条指令是在函数返回前,恢复之前保存的x29和x30。
也就是恢复:
x29 = 调用者的帧指针 x30 = main 函数应该返回到的地址它和前面的stp是一对:
stp x29, x30, [sp, #0x20] ; 保存 ... ldp x29, x30, [sp, #0x20] ; 恢复5.str w0, [sp, #0xc]
str w0, [sp, #0xc]str是store 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]ldr是load 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 Addbl是branch 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最后执行:
retCPU 就会跳回x30指向的位置,也就是main里调用Add之后的下一条指令。
总表
| 指令 | 公式 | 作用 |
|---|---|---|
sub sp, sp, #0x30 | sp = sp - 0x30 | 开辟 48 字节栈空间 |
add sp, sp, #0x30 | sp = sp + 0x30 | 释放 48 字节栈空间 |
stp x29, x30, [sp, #0x20] | Mem64[sp+0x20]=x29,Mem64[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 Add | x30=返回地址,PC=Add地址 | 调用 Add 函数 |
ret | PC=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 返回值。
如果觉得对你有帮助,不妨点个关注,点点赞鼓励一下作者