函数栈帧是如何创建的?又是如何销毁的?涉及到内存空间的本质问题,我们大多数人可能是一头雾水,栈是什么?有什么用?接触细节原理,印象才能更加深刻,带你深入的学习栈是如何创建与销毁能帮助你理解更多深层的问题,局部变量是如何创建的,为什么一开始要从main函数开始呢?函数的形参与实参有和关系,返回值又怎么返回的等等问题。理解这些问题你对编程的理解将会大大提升的。
一. 什么是函数栈帧?
我们在写C语言代码时,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。
那函数如何调用?函数的返回值如何返回的?函数参数是如何传递的?这些问题都与函数栈帧有关系。
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈所开辟的空间,这些空间是用来存放:
- 函数参数和函数返回值
- 局部变量
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
二. 理解函数栈帧能解决什么问题呢?
理解函数栈帧有什么用呢?
只要理解了函数栈帧的创建和销毁,一下问题就能很好的理解了
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机值?
- 函数调用时参数是如何传递的?传参的顺序是怎样的?
- 函数的形参与实参分别是怎么实例化的?
- 函数的返回值是如何带回来的?
等讲完函数栈帧的创建于销毁的过程后,这些问题我会一一解释的。
三.剖析函数栈帧的创建和销毁
1.什么是栈
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。
2.认识相关寄存器和汇编指令
寄存器:寄存器是独立于内存是集成到CPU上的
硬盘
内存
寄存器
三者都是存放内存的是相互独立的。
2.1相关寄存器
eax: 通用寄存器,保留临时数据,常用于返回值。
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址。
2.2相关汇编命令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1.压入返回值2.转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
3.解析函数栈帧的创建和销毁
3.1相关知识
首先我们需要知道一些基本的知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
1.函数每次调用的时候,都要为本次函数开辟空间,就是函数栈帧的空间
2.这块函数栈帧空间是由两个寄存器维护的:esp ,ebp,ebp是维护记录栈底的地址,esp是维护记录栈顶的地址。所谓栈顶,栈底,是根据栈的使用方式来确定的,栈是先使用的后弹出,就像书本从上往下叠,而书本的下面是栈底,书的上面是栈顶。
例如:
3.函数栈帧的创建和销毁在不同的编译器的实现方法大同小异,本次演示的是VS2019。
4.栈空间的使用方式是先使用高地址的空间,再使用低地址的空间
5.push(压栈):给栈顶放一个元素。pop(出栈)从栈顶删除一个元素。
3.2函数的调用堆栈
当我们按F10调试,进入函数的调用堆栈里发现main函数被调用了。可是main函数又被谁调用的呢?
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,main函数调用之前,是由谁调用main函数的。
main函数是由一个_…main函数调用的。
在_…main函数之前还有函数调用,我们先不管。
那我们可以确定,_…main函数应该也会有自己的函数栈帧,main函数和Add函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp,esp来维护栈帧空间。
所以:
在main函数调用之前已经有函数在开辟空间了。
好的理解这个,我们就从main函数的栈帧创建开始将:
3.3转到反汇编
调试到main函数开始执行的第一行,右击鼠标转到反汇编上。
int main() { //函数栈帧的创建 00C21410 push ebp 00C21411 mov ebp,esp 中00C21413 sub esp, 0E4h 00C21419 push ebx 00C2141A push esi 00C2141B push edi 00C2141C lea edi, [ebp+FFFFFF1Ch] 00C21422 mov ecx, 39h 00C21427 mov eax, OCCCCCCCCh 00C2142C rep stos dword ptr es:[edi] //main函数中的核心代码 inta=10; 00C2142E mov dword ptr [ebp-8], 0Ah intb=20; 00C21435 mov dword ptr [ebp-14h],14h intC=0; 00C2143C mov dword ptr [ebp-20h],0 C = Add(a, b); 中00C21443 8B 45 EC mov eax, dword ptr [ebp-14h] 00C21446 50 push eax 00C21447 8B 4D F8 mov ecx,dword ptr [ebp-8] 00C2144A 51 push ecx 00C2144B E8 91 FC FF FF call 00C210E1 00C21450 83 C4 08 add esp,8 00C21453 89 45 E0 mov dword ptr [ebp- 20h],eax
3.4剖解函数栈帧的创建
上面是main函数转化而来的汇编代码
接下来我们就一行一行的拆解汇编代码。
int main() { //函数栈帧的创建 00C21410 push ebp 把ebp寄存器中的值进行压栈,此时的ebp中存放的是_tmainCRTStartup函数栈帧的ebp,ebp-4 00C21411 mov ebp,esp move指令会把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是_tmainCRTStartup函数栈帧的esp 中00C21413 sub esp, 0E4h//sub会让esp中的地址减去一个16进制数字0xe4h,产生新的 //esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一 //个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量 00C21419 push ebx//将寄存器ebx的值压栈,esp-4 00C2141A push esi//将寄存器esi的值压栈,esp-4 00C2141B push edi//将寄存器edi的值压栈,esp-4 //上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄 //存器原来的值,以便在退出函数时恢复。 //下面的代码是在初始化main函数的栈帧空间。 //1. 先把ebp-0e4h的地址,放在edi中 //2. 把9放在ecx中 //3. 把0xCCCCCCCC放在eax中 //4. 将从edp-0e4h到ebp这一段的内存的每个字节都初始化为0xCC 00C2141C lea edi, [ebp+FFFFFF1Ch] 00C21422 mov ecx, 39h 00C21427 mov eax, OCCCCCCCCh 00C2142C rep stos dword ptr es:[edi] //main函数中的核心代码 inta=10; 00C2142E mov dword ptr [ebp-8], 0Ah intb=20; 00C21435 mov dword ptr [ebp-14h],14h intC=0; 00C2143C mov dword ptr [ebp-20h],0 C = Add(a, b); 中00C21443 8B 45 EC mov eax, dword ptr [ebp-14h] 00C21446 50 push eax 00C21447 8B 4D F8 mov ecx,dword ptr [ebp-8] 00C2144A 51 push ecx 00C2144B E8 91 FC FF FF call 00C210E1 00C21450 83 C4 08 add esp,8 00C21453 89 45 E0 mov dword ptr [ebp- 20h],eax
所以你知道为什么局部变量不初始化会变成随机值了吗?
因为在函数栈帧开辟的时候,栈帧里面已经被初始化成cccccccc,,是不是有时你还会莫名其妙的打印出
烫烫烫烫烫烫烫烫烫~就是因为这个原因。
之所以上面的程序输出“烫”这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。
以上就是main函数栈帧的开辟过程,没错,这只是开辟main函数栈帧空间,main函数的功能还没实现,接下来再来看看main函数是如何实现功能的。
//main函数中的核心代码--main函数功能的实现 inta=10; 00C2142E mov dword ptr [ebp-8], 0Ah //将Ah存储到ebp-8的地址处,ebp-8的位置其实就a变量 intb=20; 00C21435 mov dword ptr [ebp-14h],14h //将14h存储到ebp-14h的地址处,ebp-14h的位置就是b变量 intC=0; 00C2143C mov dword ptr [ebp-20h],0 //将0存储到ebp-20h的地址处,ebp-20h的位置其实是ret变量
以上汇编代码就表示局部变量a,b,ret的创建和初始化
所以局部变量的创建是在栈帧空间中创建的
好了main函数的局部变量也创建好了,要准备进入Add函数了,也就是要调用Add函数了,不过Add函数是怎么传参的呢?
请往下面接着看:
//调用Add函数 C = Add(a, b); //调用Add函数时的传参 //其实传参就是把参数push到栈帧空间中 中00C21443 8B 45 EC mov eax, dword ptr [ebp-14h] //传递b,将ebp-14h处放的20放在eax寄存器 00C21446 50 push eax//将eax的值压栈,esp-4 00C21447 8B 4D F8 mov ecx,dword ptr [ebp-8] //传递a,将ebp-8处放的10放在ecx寄存器中 00C2144A 51 push ecx//将ecx的值压栈,esp-4 //跳转调用函数 00C2144B E8 91 FC FF FF call 00C2145083C408//看这是什么,这是call指令下一条的地址。为什么这样呢? //因为函数调用后,还要回来继续执行下面的代码,所以将下面的指令的地址记住,如何等函数结束时能找回来 00C21450 83 C4 08 add esp,8 00C21453 89 45 E0 mov dword ptr [ebp- 20h],eax
call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。
call调用函数后,就会进入Add函数内部,下面就是Add函数的反汇编了,看看前面是不是跟main函数很像呢,没错,前面也是在为Add函数预开辟栈帧空间。我们再来好好顺一顺吧。
预栈帧空间
图片中的 ecx 和 eax 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程,以及
函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。
代码执行到Add函数的时候,就要开始创建Add函数的栈帧空间了。
在Add函数中创建栈帧的方法和在main函数中是相似的,在栈帧空间的大小上略有差异而已
1.. 将main函数的 ebp 压栈
2.计算新的 ebp 和 esp
3.将 ebx , esi , edi 寄存器的值保存
4.计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。
5.将求出的和放在 eax 寄存器尊准备带回
以上就是main函数和Add函数栈帧的创建。接下来我们来看函数栈帧是如何销毁的呢?
3.5函数栈帧的销毁
当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
那具体是怎么销毁的呢?我们看一下反汇编代码
所以最后就还剩下main函数的栈帧空间
3.6.拓展了解:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。
四.总结:
我们来总结一下以上问题已经相关注意点。
1.局部变量是如何创建的?
1.首先先为函数分配栈帧空间
2.初始化部分栈帧空间ccccccc
3.然后在函数栈帧里给局部变量分配一些空间
2.为什么局部变量不初始化内容是随机值?
因为变量是我们自己放进去的,如果初始化就会把随机值覆盖了,不初始化就会保留原来的随机值
3.函数调用时参数是如何传递的?传参的顺序是怎样的?
1.在调用函数之前系统就先push push 把这两个参数从右向左开始压栈进去。
2.而真正到函数调用的时候我们是通过指针的偏移量来找到形参
4.函数的形参与实参分别是怎么实例化的?
形参确实是在我们压栈时开辟的空间,它和形参只是值上相同,但空间是独立的是不同的空间
故形参是实参的临时拷贝,改变形参不会影响实参
5.函数的返回值是如何带回来的?
函数的返回值一般是通过存放在寄存器中然后带回来的
6.注意点:
1.我们在调用之前就把call指令的下一条指令地址记住了,存进去了压进去了
2.我们在调用的时候就把main函数指向栈底的地址记住下,存进去压进去了,然后最好pop弹出去,把这个地址放进edp中,edp就更新到mian函数栈底去了
3.epb和eps是存放维护当前栈空间函数的栈底地址,跟函数地址没有关系。它当有函数调用时就维护这个函数栈帧,有另一个函数调用时就维护另一个函数栈帧。