超详细函数栈帧详解(C语言为例)
一 函数栈帧是什么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-32ESP32 位栈指针x86-64 / AMD64RSP64 位栈指针ARM 32 位SP/R13ARM 中 R13 通常作为栈指针ARM64 / AArch64SP64 位 ARM 架构的栈指针macOS on IntelRSPIntel Mac 使用 x86-64 架构macOS on Apple SiliconSPM1/M2/M3/M4 等使用 ARM64 架构Linux on x86-64RSP常见 PC/Linux 服务器架构Linux on ARM64SP例如树莓派 64 位系统、ARM 服务器等所以更完整地说在 i386 下栈顶由ESP寄存器定位在 x86-64 下栈顶由RSP寄存器定位在 ARM / ARM64 架构下栈顶通常由SP寄存器定位。无论寄存器名字如何变化它们的作用都是保存当前栈顶地址。另外还有一个容易混淆的寄存器架构栈指针栈帧指针x86-32ESPEBPx86-64RSPRBPARM64SPFP/X29ESP/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 预编译在预处理阶段源⽂件和头⽂件会被处理成为.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] (index4)*(26);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 函数栈帧的创建.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 Add3. 重定位符号决议解决的是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 cab; return c; } int main() { int a1; int b2; int cAdd(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 调用 Addldur 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一开始必须保存自己的x30stp x29, x30, [sp, #0x20]否则等main最后ret的时候原来的返回地址就丢了。3.8 ARM64 和 x86-64 栈帧的对应关系你之前学的 x86/x86-64 里常见的是RSP / ESP栈指针 RBP / EBP帧指针 返回地址通常由 call 指令压入栈ARM64 中对应关系是概念x86-64x86-32ARM64栈指针RSPESPSP帧指针RBPEBPX29/FP返回地址栈中栈中X30/LR必要时再保存到栈函数调用callcallbl函数返回retretret第一个 int 参数常见为edi多数走栈w0第二个 int 参数常见为esi多数走栈w1int 返回值eaxeaxw0所以你看到w0, w1不是普通临时变量而是 ARM64 调用约定中的参数寄存器。3.9 重点指令符号解释Reg 寄存器 Mem[address] 某个内存地址里的值 sp 栈顶指针 x29 帧指针 FP x30 返回地址寄存器 LR PC 当前 CPU 要执行的指令地址1.sub sp, sp, #0x30sub 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, #0x30add 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 Addbl Addbl是branch with link意思是“跳转并保存返回地址”。公式x30 当前下一条指令的地址 PC Add 函数的地址解释它做两件事1. 把返回地址保存到 x30 2. 跳转到 Add 函数执行可以类比 C 语言Add(...);也可以类比 x86 里的call Add区别是x86 的 call 通常把返回地址压入栈中 ARM64 的 bl 通常先把返回地址放入 x30如果当前函数还会继续调用别的函数就需要把x30保存到栈上否则下一次bl会覆盖它。8.retret公式PC x30解释ret的意思是跳回x30保存的返回地址。也就是说函数执行完了回到调用者那里继续执行例如bl Add会把返回地址放到x30。Add最后执行retCPU 就会跳回x30指向的位置也就是main里调用Add之后的下一条指令。总表指令公式作用sub sp, sp, #0x30sp sp - 0x30开辟 48 字节栈空间add sp, sp, #0x30sp sp 0x30释放 48 字节栈空间stp x29, x30, [sp, #0x20]Mem64[sp0x20]x29Mem64[sp0x28]x30保存旧 FP 和返回地址ldp x29, x30, [sp, #0x20]x29Mem64[sp0x20]x30Mem64[sp0x28]恢复旧 FP 和返回地址str w0, [sp, #0xc]Mem32[sp0xc]w0把 32 位值存到栈上ldr w8, [sp, #0xc]w8Mem32[sp0xc]从栈上读取 32 位值bl Addx30返回地址PCAdd地址调用 Add 函数retPCx30返回调用者3.103.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 返回值。如果觉得对你有帮助不妨点个关注点点赞鼓励一下作者