嘿嘿,家人们,今天呢咱们来详细讲解函数栈帧的创建与销毁,好啦,废话不多讲,开干!
我们在写C语言代码的时候,经常会把一个独立的功能封装在一个函数中,因此,C程序是以函数为基本单位的,那函数是如何调用的呢?函数的返回值又是如何带回呢?函数的参数又是如何传递的呢?这些都与函数的栈帧有关系。
函数栈帧就是用来函数在调用过程中在程序的调用栈所开辟的空间,这些空间是用来存放:
(1):函数参数和返回值
(2):临时变量(包括函数的非静态区的局部变量以及编译器自动产生的其他临时变量)
(3):保存上下文信息 (包括在函数调用前后需要保持不变的寄存器)。
只要理解了函数栈帧,那么如下问题就能很好地理解了,并且更方便我们日后的学习!
(1):局部变量是如何创建的?
(2):为什么局部变量不初始化内容就被赋予随机值?
(3):函数调用时参数是如何传递的?传参的顺序是怎么样的?
(4):函数的形参与实参分别是怎么样实例化的?
(5):函数的返回值是如何带回的?
那么就让我们一起走进函数栈帧的创建与销毁的过程中。
在解析函数栈帧的创建与销毁之前,首先呢得了解一些预备知识,这样子方便后续的理解。
栈是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,同时也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先进后出(先入栈的数据后出栈).就好比叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
eax:通用寄存器,保留临时数据,常用于返回值。
ebx:通用寄存器,保留临时数据。
ebp:栈底寄存器。
esp:栈顶寄存器。
eip:指令寄存器,保存当前指令的下一条指令的地址
mov:数据转移指令。
push:数据入栈,同时esp栈顶寄存器也要发生改变。
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变。
sub:减法命令;
add:加法命令;
call:函数调用, 1. 压入返回地址 2.转入目标函数
jump:通过修改eip,转入目标函数,进行调用。
ret:恢复返回地址,压入eip,类似pop eip命令。
了解了上面的相关知识后,我们还需要达成一些预备知识才能有效地帮助我们去理解,函数栈帧的创建与销毁。
1.:每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2.:这块空间的维护是使用了2个寄存器:esp和ebp,ebp记录的是栈底的地址,被称为栈顶指针,esp记录的栈底的地址,被称为栈底指针。
3.函数栈帧的创建和销毁过程,在不同的编译器上实现的方法是大同小异,博主在这里使用的是VS2019.建议家人们使用VS2013或者更低版本的编译器,不要使用更高的编译器,越高级的编译器,环境虽然稳定,但不容易进行观察和学习,不同版本的编译器在观察函数栈帧的创建与销毁是有些差异的。
了解上面的预备知识后,博主将通过以下这段代码来具体解析函数栈帧的创建和销毁
#define _CRT_SECURE_NO_WARNINGS
#include
int Add(int a,int b)
{
int value = a + b;
return value;
}
int main()
{
int a = 10;
int b = 20;
int result = Add(a, b);
printf("%d", result);
return 0;
}
我们通过F10进行调试,然后进入窗口,点击里面的调用堆栈,我们可以清楚地看到,此时main函数被调用了,但是它是被谁调用的呢?这里博主引用了下vs2013的环境下main函数的调用情况。因为在vs2019的环境是看不到main函数时被哪个函数调用的.
从这张图片中,我们可以得知,main函数是由一个叫** _tmainCRTStartup函数 所调用,而 _tmainCRTStartup是由mainCRTStartup所调用的**,在之前的预备知识我们讲到过,每一次调用函数都要为函数的开辟一块空间,这块空间就是函数栈帧。博主通过一张如下图片来慢慢解析。
可能有的uu会有些疑惑,为什么博主不画main函数的栈帧呢,uu们不要着急此时main函数的函数栈帧还没有创建呢,接下来博主将通过反汇编指令来带着大家一步一步地解析。
首先我们右击鼠标,然后点击转到反汇编,跳到反汇编后,就能看到如下的汇编代码!
估计大多数uu们看到这段汇编代码都是晕晕的感觉,没关系,博主将一步一步地带着大家去解析!
首先第一步是进行push,压栈操作,将ebp寄存器的值压入到栈顶,在之前我们了解过,每一次压栈栈顶指针esp指向的地址也会发生变化,此时esp寄存器里头存放的是寄存器ebp的值。uu们如果想详细地了解的话,可以打开调试里面的监视和内存去观看寄存器esp的变化。
通过push前与push后的对比,我们可以清楚地发现,栈顶指针的地址发生了变化,减小了,在之前我们了解过,栈是向下增长的,也就是从高地址到低地址进行增长,那么此时所对应的函数栈帧图如下图。
第二步进行mov:数据移动指令,将寄存器esp的值赋给ebp,那么此时esp与ebp都指向栈顶。
通过mov前与move后的对比,我们可以清楚地发现此时ebp与esp的值相等,两个寄存器都指向栈顶,那么此时所对应的函数栈帧图如下图!
第三步执行sub指令,将寄存器栈顶指针的值减去0E4h(16进制),那么此时栈顶指针往低地址走。
通过对比sub前与sub后,我们会发现此时esp和ebp不再维护最初的空间,而是维护了一块新的空间,我们之前说过,函数每次调用都会在栈区上开辟一块属于自己的函数栈帧,那么此时栈顶指针esp与栈底指针ebp所维护的空间就是为main函数所开辟的函数栈帧,所对应的函数栈帧图如下。
第四步:连续push三次,也就是向栈中压入三个值,分别为ebx,esi,edi。我们之前说过,每进行一次压栈,栈顶指针esp都会向下增长(向低地址增长)。
通过对比三次push前与三次push后,esp的值此时减少了,此时所对应的函数栈帧图如下图
第五步:执行lea指令,lea指令的全称呼是load effective address(加载有效地址); 将ebp-24h这个地址的值存放到寄存器edi中,我们可以通过监视来观察。
第六步:执行两次mov指令,将9(16进制)与0CCCCCCCCh分别存放到寄存器ecx与eax中。
通过两次mov的对比,我们会发现此时寄存器ecx中存储的值为9,eax中的值为16进制的0xcccccccc。
第七步:从ebp的位置开始到edi的位置也就是ebp - 24h的位置,将eax中的值拷贝到这两个寄存器所维护的区域,每次拷贝dword(doubleword)双字,一个字占2个字节,双字占四个字节,总共拷贝 ecx次也就是9次。
对比拷贝前与拷贝后,我们可以发现,ecx的值从9变为了0,也就是总共拷贝了九次,每次拷贝4个字节!此时所对应的函数栈帧图如下图
第八步,创建局部变量a并且为其赋值,在将0Ah(10)(存储到ebp - 8的位置上,实际上在ebp - 8的位置上就是局部变量a。
通过mov前后的对比,此时在ebp-8的位置上从最初的cccccccc变成了0x0000000a也就是10。此时所对应的函数栈帧图如下
创建局部变量b并且为其赋值,在将014h(20)存储到ebp - 14h(ebp - 20)的位置上,实际上在ebp - 14h的位置上就是局部变量b。
通过对比mov前与mov后,此时在ebp-14h的位置上从最初的cccccccc变成了0x00000014也就是20。此时所对应的函数栈帧图如下
将ebp - 14h这个位置所存储的四个字节的数据存放到寄存器eax中,然后再进行压栈,接着再将ebp - 8这个位置所存储的四个字节的数据存放到寄存器ecx中,然后再进行亚栈。PS:通过之前的操作我们可以得知,ebp - 14h所存储的值就是局部变量b,ebp - 8这个位置所存储的值就是局部变量a。这几步操作是在调用Add函数前进行传参
此时所对应的函数栈帧图如下。可能家人们对这几步操作还不是特别地理解,没关系,我们先向下看!
这一步,我们将要执行Call指令即调用函数,在执行Call指令前我们首先记住这个地方的地址,然后这个时候uu们就不要按F10了,按F11,此时我们再观察一下,栈顶指针
的位置
在call之前,我们可以观察到,栈顶指针里面存储的是00 00 00 0a也就是20,这是一开始压栈所影响的,在call之后,esp的地址发生了变化,家人们有木有觉得此时栈顶指针esp所存储的内容是不是很熟悉,没错!在调用的同时,会将call指令的下一条指令的地址进行压栈,那么此时所对应的函数栈帧图如下。至于它的意义何在呢?我们先继续往下看!
执行完第11步后,此时我们再按F11,这时候算是真正来到了Add函数里面,来到Add函数里面,仔细观看一下,前面这一部分,是不是跟main函数的情况大同小异,也就是在为我的Add函数开辟函数栈帧。这里博主再带家人们走一遍步骤.
此时进入了Add函数后,首先进行push,此时将ebp的值进行压栈(这个ebp是main函数的ebp),我们知道ebp为栈底指针,ebp之前一直在维护main函数的函数栈帧,此时对其压栈,就等价于是将main的ebp进行压栈。
对比push前与push后,我们可以清楚地发现此时栈顶指针esp指向的地址发生了变化,减小了。此时所对应的函数栈帧图如下。
第十五步:进行mov操作,将寄存器esp的值赋给ebp,那么此时esp与ebp都指向栈顶。
对比mov前与mov后,我们可以清楚地看到,此时esp与ebp所指向的地址是一样的,此时所对应的函数栈帧图如下
此时将执行sub指令,将寄存器栈顶指针的值减去0CCh(16进制),那么此时栈顶指针往低地址走。
通过过对比sub前与sub后,我们会发现此时esp和ebp不再维护main函数的函数栈帧,而是维护了一块新的栈帧,我们之前说过,函数每次调用都会在栈区上开辟一块属于自己的函数栈帧,那么此时栈顶指针esp与栈底指针ebp所维护的空间就是为Add函数所开辟的函数栈帧,所对应的函数栈帧图如下。
第16步,连续push三次,也就是向栈中压入三个值,分别为ebx,esi,edi。我们之前说过,每进行一次压栈,栈顶指针esp都会向下增长(向低地址增长)。
通过对比三次push前与三次push后,esp的值此时减少了,此时所对应的函数栈帧图如下图
第十七步,执行lea指令,lea指令的全称呼是load effective address(加载有效地址); 将ebp-0Ch这个地址的值存放到寄存器edi中,我们可以再次通过监视来观察。
第十八步,执行两次mov指令,将3(16进制)与0CCCCCCCCh分别存放到寄存器ecx与eax中。
通过两次mov的对比,我们会发现此时寄存器ecx中存储的值为3,eax中的值为16进制的0xcccccccc。
第十九步,从ebp的位置到edi也就是ebp - 0ch的位置开始,将eax中的值拷贝到这两个寄存器所维护的空间,每次拷贝dword(doubleword)字,一个word占两个字节,douleword占四个字节,总共拷贝ecx次也就是3次。
对比拷贝前与拷贝后,我们可以发现,ecx的值从3变为了0,也就是总共拷贝了三次,每次拷贝4个字节!此时所对应的函数栈帧图如下图。
第20步,将ebp + 8这一个位置所指向的值,存放到eax寄存器中,我们可以通过观察函数栈帧图,ebp + 8所指向的值,正好是我Add函数里头形式参数a,没错,就是就形参a的值mov到寄存器eax中!
通过对比mov前与mov后,我们可以发现,此时eax中的值为16进制的 0x00 00 00 0a(即十进制的10)
第二十一步,执行Add命令,将ebp + 0Ch所指向的值加到eax寄存器中,我们通过观察函数栈帧图可以发现,ebp + 0Ch所指向的值正好是形参b,没错,就是将变量b的值加到eax寄存器中。
通过对比Add前与Add后,我们可以发现,此时eax中的值为16进制的 0x00 00 00 1e(即十进制的30)
将eax中的值mov到ebp - 8 的位置上,之前的mov与add操作使得寄存器eax中所存储的值为30,也就是说此时ebp - 8的位置所存放的值是30并且由变量value所接收,那么也就是说ebp - 8的位置上存储的就是局部变量value。
通过对比mov前与mov后,我们可以发现此时ebp - 8的位置上存储的确实是寄存器eax中的值也就是十六进制的0x 00 00 00 1e(十进制的30),此时所对应的函数栈帧图如下
第二十三步,将ebp - 8这个地址所指向的值(也就是变量value的值)存放到寄存器eax中,我们知道,寄存器不会随着程序的退出而销毁,与此同时,变量value一会出Add函数后会销毁,因此先存放到寄存器eax中。
第二十四步,连续进行三次pop,分别将edi,esi,ebx这三个值从栈顶弹出并且弹到edi寄存器,esi寄存器,ebx寄存器中,同时esp栈顶寄存器也要发生改变,每次弹出esp的值会 + 4。
通过对比三次pop前与三次pop后,我们可以发现,此时esp的值加了 12,此时所对应的函数栈帧图如下图。
第二十五步:这一步操作,将ebp赋给esp,这一步的操作就是在销毁Add函数的函数栈帧,因为函数调用完了,我的变量value值存放在了eax寄存器,因此此时通过mov操作将ebp的值赋给esp来销毁Add函数的函数栈帧。此时所对应的函数栈帧图如下图。
第二十六步:将栈顶元素弹出到寄存器ebp中,我们可以发现此时的栈顶元素是main函数的ebp的地址值,那么为什么会在这里存放main函数的ebp的地址值呢,是因为随着函数栈帧的销毁,main函数的栈顶是很容易找到的,但是main函数的栈底是难以找到的,因此将main函数的栈底ebp的地址值存储值,当Add函数调用完以后,进行pop,此时ebp就回到了main函数的栈底了。pop的同时栈顶指针esp也会 + 4。
PS:博主在这里经过了多次调试,所以可能call指令的下一条指令的地址值会有些不同,家人们这里要注意一下哦
通过对比pop前与pop后,我们可以发现此时esp的地址值增加了4,ebp的值也发生了变化,此时所对应的函数栈帧图如下。
第二十七步:执行ret指令,我们仔细观看函数栈帧,此时栈顶上存储着最初调用Add函数的call指令的下一条指令的地址,在执行ret指令的同时此时会在栈顶弹出call指令的下一条指令的地址,那么此时ret指令就是回到执行Add函数时的call指令的下一条地址。**那么家人们可以思考一下,为什么我们要存放call指令的下一条指令的地址呢?其目的是,当调用完函数以后,我还能回来,回来之后还能够从call指令的下一条指令开始执行。**此时所对应的函数栈帧图如下。
第二十八步:对esp寄存器执行 + 8的操作,我们观察函数栈帧图,此时在栈中还存在着调用Add函数的形参变量a与形参变量b,Add函数已经调用完了,那么此时既要销毁函数栈帧又要销毁形参,此时这一步操作就是用来销毁形参的,将其还给操作系统。
通过对比Add前与Add后,我们可以发现,esp的值发生了变化,此时所对应的函数栈帧图如下
第二十九步,执行mov操作,将eax中的值mov到ebp - 20h的位置上,我们会发现,刚刚调用完Add函数的结果会存放变量result中,而result变量就是在ebp - 20h的位置上,然后我们再想一下,eax中的值是什么,就是我们在调用Add函数时求出来的和存放到了eax寄存器中,那么也就说此时ebp - 20h位置上的值就是调用Add函数时求出来的和。
通过对比mov前与mov后,我们可以发现,此时ebp - 20h上的值就是eax寄存器中所存放的值,此时所对应的函数栈帧图如下
剩下的操作就是执行printf函数,将其打印在屏幕上,然后再销毁main函数的函数栈帧,这里博主就不再细讲啦,uu下去以后可以结合博主讲解的Add函数栈帧的销毁过程去理解哦!
局部变量的创建,首先是为函数开辟好栈帧空间,栈帧空间里头,初始化一部分空间以后,然后再在栈帧空间里头对局部变量进行分配空间。
函数栈帧在开辟好以后会对一部分空间进行初始化,而初始化的值是随机值,这也就是为什么局部变量在不初始化时会是随机值。
当我们要调用函数时,其实在还没调用前,通过不断地push,从右向左开始压栈,压入到栈中,当真正进入到函数里头,通过指针的偏离量来找到形参。
形参是我们压栈时为其开辟的空间,这块空间与实参的空间是相互独立的,两块空间只是存储的值一致,因此我们才会说形参是实参的一份临时拷贝,形参的改变影响不到实参。
当我们在调用函数的同时即执行call指令时,call指令会将下一条执行指令的地址值压入栈中与此同时又会将上一个函数栈帧中的ebp的地址值也压入栈中,在调用完函数后要返回时,通过pop ebp找到上一个函数的栈顶的地址值,从令esp栈顶指针与ebp栈底指针重新维护这块函数栈帧与此同时会销毁此时调用的Add函数的函数栈帧,然后通过在栈中压入的call指令的下一条指令的地址值跳转到与之对应的位置,令函数返回,返回值则是通过在函数调用完之前事先存放在寄存器中被带回来的。
好啦,家人们,关于函数栈帧的创建与销毁这块的相关细节知识,博主就讲到这里了,如果uu们觉得博主讲的不错的话,请动动你们滴滴给博主点个赞,你们滴鼓励将成为博主源源不断滴动力!