一、 什么是函数栈帧?
函数栈帧是用于在计算机程序中实现函数调用的一种数据结构。在函数调用过程中,每个函数都需要在内存中创建一个栈帧,用于存储局部变量、返回地址和参数等。
- 具体来说,函数栈帧通常包含以下部分:
- 局部变量表:存储函数的局部变量,包括基本数据类型(如整数、浮点数等)和对象引用(如指针)。
- 返回地址:存储函数的返回地址,即函数执行完毕后需要跳转到的地址。
- 参数表:存储函数的输入参数,通常按照传递的顺序排列。
- 操作数栈:用于存储函数的临时数据和中间结果,通常使用栈结构进行操作。
- 当一个函数被调用时,会在内存中创建一个新的栈帧,并将其压入调用该函数的栈中。当函数执行完毕后,该栈帧会被弹出栈并销毁。因此,函数栈帧在函数调用过程中起到了存储和传递数据的作用。
函数栈帧的实现方式取决于具体的编程语言和编译器。在一些高级编程语言中,编译器通常会为每个函数自动创建和销毁栈帧,而无需程序员手动管理。而在低级编程语言或手动控制内存分配的情况下,程序员需要手动创建和销毁栈帧。
二、 理解函数栈帧能解决什么问题呢?
理解函数栈帧有什么用呢?
只要理解了函数栈帧的创建和销毁,以下问题就能够很好的额理解了:
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数时如何传递的?传参的顺序是怎样的?
- 函数的形参和实参分别是怎样实例化的?
- 函数调用是怎么做的?函数的返回值是如何带会的?
让我们一起走进函数栈帧的创建和销毁的过程中。
三、 函数栈帧的创建和销毁解析
3.1、什么是栈?
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
- 在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈(Push):将数据项添加到栈的顶部。这相当于把数据放到栈的最上面。出栈(Pop):从栈的顶部移除数据项。这相当于移除栈顶的数据项。但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像一个桶,先放的东西最后才能拿出、
- 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的,在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的
3.2、认识相关寄存器和汇编指令
3.2.1 相关寄存器
- 【eax】:通用寄存器,保留临时数据,常用于返回值
- 【ebx】 :通用寄存器,保留临时数据
- 【ebp】:栈底寄存器
- 【esp】:栈顶寄存器
- 【eip】:指令寄存器,保存当前指令的下一条指令的地址
3.2.2 相关汇编命令
【mov】:数据转移指令
【push】:数据入栈,同时esp栈顶寄存器也要发生改变
【pop】:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
【add】:加法命令
【sub】:减法命令
【lea】 :load effective address,加载有效地址
【call】:函数调用,1. 压入返回地址 2. 转入目标函数
【jump】:通过修改eip,转入目标函数,进行调用
【ret】:恢复返回地址,压入eip,类似pop eip命令
3.3、 解析函数栈帧的创建和销毁
- 首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
3.3.1 预备知识
- 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间
- 这块空间的维护是使用了2个寄存器:
esp
和ebp
,【ebp】 记录的是栈底的地址,esp
记录的是栈顶的地址
如图所示:
- 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2022为例。
3.3.2 代码和环境搭建
- 这段代码,如果我们在VS2019编译器上调试,调试进入Add函数后,我们就可以观察到函数的调用堆栈
#include <stdio.h> int Add(int x, int y) { int z = 0; z = x + y; return z; } int main() { int a = 10; int b = 20; int c = 0; c = Add(a, b); printf("%d\n", c); return 0; }
- 首先我们来做一些环境的搭建工作
- 首先直接在键盘上按下F10【笔记本按下Fn + F10】。
- 以往写代码的时候,我们都知道要写这么一个main函数,程序就是从这里开始运行的
- 接下去在按下F10后到监视窗口打开【调用堆栈】的窗口
- 然后就出现了这样的界面。此时我们的main函数就从第13行开始运行了
- 一直按F10,当调试箭头运行到第【22行】的时候,就会自动进入到exe_common.inl,此时我们就可以观察到底是哪个函数调用了main函数
- 通过下图可知是invoke_main这个函数调用的,我们了解到这里就可以了~~
- 然后,关掉这个【调用堆栈】的窗口后,重新调试起来
- 调出【反汇编】【内存】【监视】这三个窗口
【反汇编】
【内存】
【监视】
好,现在我们的环境已经全部搭建好了
3.3.3 函数栈帧的创建
- 接下去,我们正式开始分析函数栈帧究竟是如何创建的
- 去掉符号名,方便看内存
- 从上图看到已经进入到main函数了
- main函数是由invoke_main这个函数来进行调用的,所以我们先画出它的函数栈帧
- 首先看到左边的两个寄存器【esp】和【ebp】,分别用来维护栈顶和栈顶。
- 对于栈来说是从【高地址】向【低地址】使用的。
- 好,接下去的话就要执行第一条指令。将栈中push一个ebp,也就是将ebp中的值进行一个压栈的操作,此时的ebp中存放的是invoke_main函数栈帧的ebp
00EE18D0 push ebp
- 随着push入栈的操作,维护栈顶的esp就要往上
- 然后我们看寄存器的变化
- 我们再继续执行一下push这句指令,你就会发现【esp】中所存放的地址变小了,原来存的是【ebp】中的值,只是这个存放的形式是倒着存放的,是因为有大小端存储的问题
- 接下来第二条,【mov】,我们在上面有讲到过是一个数据转移指令,这条指令的含义就是把esp的值存放到ebp中去
00EE18D1 mov ebp,esp
- 此时相当于产生了main函数的【ebp】,这个值就是invoke_main函数栈帧的【esp】,从这里开始就要开始维护main函数的函数栈帧了
- 通过VS再来看一下,【ebp】中就会存放【esp】的地址了
第三条指令
- 接下来第三条,sub是一条减法命令,那意思就是让esp中的地址减去一个16进制数字【0xe4】,产生新的esp,此时的esp是main函数栈帧的esp
00EE18D3 sub esp,0E4h
- 此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据以及调试信息等
- 通过图,此时你也可以认为【esp】指向了低地址的一块空间
- 来看一下寄存器中存放的内存变化
第四、五、六条指令
00EE18D9 push ebx //将寄存器ebx的值压栈,esp-4 00EE18DA push esi //将寄存器esi的值压栈,esp-4 00EE18DB push edi //将寄存器edi的值压栈,esp-4
- 上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复
- 那随着寄存器的入栈,维护栈顶的寄存器也将发生变化
- 此时esp也随着压栈而变化
- 到VS里来看一下三次的变化:
第七、八、九、十条指令
- 下面的代码是在初始化main函数的栈帧空间,【非常重要】
00EE18DC lea edi,[ebp-24h] 00EE18DF mov ecx,9 00EE18E4 mov eax,0CCCCCCCCh 00EE18E9 rep stos dword ptr es:[edi]
上面的这段代码最后4句,等价于下面的伪代码:
edi = ebp-0x24; ecx = 9; eax = 0xCCCCCCCC; for(; ecx = 0; --ecx,edi+=4) { *(int*)edi = eax; }
- 首先要来看的就是【lea】就是我们在上面讲到过的【load effective address】加载有效地址的意思,那也就是从【ebp】这个维护栈顶的寄存器减去24h的位置,加载到寄存器【edi】里面去
- 然后再将9放到【ecx】中去;以及将【0CCCCCCCCh】这块地址存到【eax】中去;
- 从【edi】所存放的这块地址的开始,每次初始化4个字节的数据,dword值就是4个字节的大小
- 这4句话的操作就是从edi开始,每次初始化4个字节的数据,总共初始化ecx次,初始化的内容为【0xCCCCCCCC】,总共初始化到ebp的地址结束
- 到这里,main函数才刚刚被初始化完成
- 那么里面的cccccccc是初始化的什么内容呢?–>我们来看一下
char arr[20]; printf("%s",arr);
- 可以看到上面的程序输出“烫烫烫烫烫烫烫烫烫烫”这一串,是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,上图中arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”,烫烫烫就这么来的
C语言之反汇编查看函数栈帧的创建与销毁(二) :https://developer.aliyun.com/article/1427058