函数栈帧的创建和销毁

可能在前期学习过程中,对于内存以及代码的执行我们有着很多困惑,比如:

1、局部变量是怎么创建的?

2、人们常说的初始化是什么意思?是如何做到的?

3、函数是怎么传参的?传参顺序是怎么样的?

4、形参和实参是什么关系?修改形参会改变实参的值吗?

5、函数调用是怎么做的?调用结束后如何返回?

6、代码运行时内存空间是如何分配及变化的?

  而以上这些问题,都离不开函数栈帧这个东西,本文将通过一串最简单的代码作为切入来一步一步详细介绍以及深层次刨析以上这些功能在编译器中是如何实现的。而我们在日常代码编写中又该注意什么?相信屏幕前的你阅读完本章内容之后会对代码的运行和内存的使用产生新的理解和看法。

目录

前言:

一、寄存器

二、main函数栈区空间的开辟/压栈/与初始化

2.1简述

2.2main函数是如何被调用的

三、局部变量的创建与ADD函数的传参及调用

3.1局部变量的创建

3.2传参

3.3创建ADD函数的栈帧并初始化

四、ADD函数的销毁/esp、ebp的复位   

五、main函数的结束与销毁


前言:

  首先我们应该了解内存的基本组成一般分为三部分:栈区、堆区、静态区。三区各有各自的功能和作用,从而我们的使用方式和情景也不同。而今天主要是基于栈区之上,对栈帧的创建和销毁进行讲解。

  而在不同编译器下,函数调用过程中栈帧的创建和销毁是略有差异的,具体细节取决于编译器的实现,但越高级的编译器,越不容易学习和观察。所以考虑到为了各位能更好的理解和学习本章内容,同时也不和当下最新版本的编译器脱轨,本次我将基于VS2013环境下,主要围绕以上几个问题进行叙述。

一、寄存器

  首先我们要了解,在进行main、void函数或各种自创函数执行时,函数内部的局部变量的储存单元(地址)都在栈上进行创建,而当函数执行完毕时其会自动释放。也就是传统意义上的“清除内存”。而栈帧就是在函数执行时基于寄存器来对函数进行维护的。栈帧的维护、创建、销毁离不开寄存器,所以在此我们不得先了解-寄存器。

  寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和地址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC)。在中央处理器的算术及逻辑部件中,寄存器有累加器(ACC)。

而常见寄存器有以下几个:

eax:累加寄存器,相对于其他寄存器,多用于运算。
ebx:基地址寄存器,在内存寻址时存放基地址。

ecx:计数寄存器,用于循环操作,如重复的字符存储操作或者数字统计。
edx:作为EAX的溢出寄存器,总是被用来放整数除法产生的余数。
esi:源变址寄存器,主要用于存放存储单元在段内的偏移量。通常在内存操作指令中作为“源地址指针”使用。
edi:目的变址寄存器,主要用于存放存储单元在段内的偏移量。
而与本章内容密不可分的espebp这两个寄存器中存放的是函数顶部和底部的地址,而这两个寄存器用来维护函数栈帧的。

注:esp和ebp在维护函数栈帧时,编译器正在调用哪个函数,它们就会维护哪个函数。

二、main函数栈区空间的开辟/压栈/与初始化

2.1简述

  每一个函数调用,都需要在栈区上创建一个空间,所以接下来我们以一段最简单的代码作为切入点来详细描述栈帧从创建到销毁的逐过程。

 我们可以看到这是一个最简单的通过自己创建一个ADD函数来实现加法的代码。在函数开始执行时就会在栈区开辟空间,而空间的开辟和内存的使用一般都是先使用高地址,再使用低地址。栈就像一个木桶一样,往里面倒水,水会先占用深处的空间再慢慢往上来,同样我们加入或者减少水也只能从桶顶进行。栈作为一种运算受限的线性表被限定仅在表尾也就是栈顶进行插入和删除操作,而往栈顶放置一个元素这个行为简称为压栈也叫push,而从栈顶删除一个元素称为出栈或退栈,也叫pop。

  而在调用函数时,ebp和esp这两个寄存器的作用就凸显出来了,我们调用哪个函数,ebp和esp就会维护哪个函数。例如刚开始我们调用main函数,那么这两个寄存器就去维护main函数。由于ebp和esp中存放的是地址,所以通俗一点来理解ebp记录函数所占内存的最高地址也叫栈顶指针,esp记录最低地址也叫栈底指针。而它们俩之间的空间就是为本次函数所创建的空间。

2.2main函数是如何被调用的

   而代码开始运行后,我们通过编译器可以看到在VS2013中main函数也是被其他函数调用的,我们通过下图的调用堆栈可以看到main函数被__tmainCRTStartup这个函数调用的而这个函数又是被mainCRTStartup调用的。

   所以我们大概可以知道这样一个框架:main函数也是由其他函数调用的所以在main函数调用之前在main函数下面更高的地址位也为调用main函数的两个函数分配有空间。而在main函数执行时调用add函数时,编译器也会在main函数之后也为add函数开辟一块空间。

  而在main函数执行时ebp和esp就会维护main函数,而在编译器调用add时ebp和esp则会去维护add函数,在add函数执行完毕后ebp和esp又返回来继续维护main函数,总而言之ebp和esp就是程序执行到哪个函数它俩就去维护哪个函数。而这中间的一系列步骤编译器又是如何实现的呢?下面我们来更详细的观察。

  接下来,为了更加清晰的观察,我们在反汇编模式下来观察代码是如何一步步运行的。

 上面我们讲到,调用哪个函数ebp和esp就维护哪个函数,而main函数也是由其他函数调用的,所以在调用main函数之前ebp和esp则是位于__tmainCRTStartup的栈底与栈顶。通过上图第一行我们可以看到,进入到main函数后,第一行的指令为push也就是我们常说的压栈,即将__tmainCRTStartup 的ebp的压到了上面更低的地址位,而esp则会随着ebp的压栈也往低地址移动一个地址位来指向刚开辟的存放ebp值的地址位(ps:每当压进去一个值,esp就会自动往上移动一个地址位)。

随着箭头执行完第一行我们通过下图对比可以看到esp的值由原来的a8变成a4 ,即已经指向了含有ebp值(此时ebp还在__tmainCRTStartup的栈底)的内存单元。

 

 打开内存来看,esp指向的内存中确实存放着ebp的值

 至此,第一步指令push(压栈)已经完成。

而第二行,mov   ebp,esp则是将esp的值给ebp,从而将ebp调上来。

 随着f10往下走一行,我们可以看到ebp的值也变成了0x008ffba4,此时ebp和esp的值一样。

第三行sub  esp 0E4h,sub是做减法,此行指令就是给esp减去0E4h这个8进制数字,转换成10进制就是228。通过下图可以看到,执行完sub之后esp的值变成了0x008ffac0,相比于之前显然更小了。

 而此时ebp和esp中间的空间就是为main函数所预开辟的空间,也就是main函数的栈帧。

 至此,sub指令执行完毕。接下来的三个push就是三个压栈操作将ebx,esi,edi三个值  依次压进去,同时esp的位置也会依次向上移动三个地址位。三条push指令执行完毕后,就得到了下图所显示的。

而经过三次压栈操作后,内存中的结构如下图

接着往下走,lea指令的意思则是 load  effective address 即加载有效地址,就是把后面的[edp+FFFFFF1Ch]加载到edi中,相当于在edi中存放一个地址,(注:存放到edi中,可以理解为在edi中存入一个指针,并不是edi自己的地址),此时这个值不好观察,右击鼠标打开显示符号名我们就可以得到下图所示的表示方式。 

 在这种模式下可以更清晰的理解[edp+FFFFFF1Ch]这个值就是[ebp-0E4h],而这个值就是我们刚刚sub执行完之后esp的位置

接下来三步可以串联到一起来看:

 接下来的三步则是将edi中存放的那个地址往下39h个dword(double word:一个word是两个字节,double word就是四个字节)的值全部初始化为0CCCCCCCCh这个值,一直到ebp结束,执行完毕后可以得到下图结果:

至此,关于main函数栈帧的开辟以及初始化就正式完成了。

三、局部变量的创建与ADD函数的传参及调用

3.1局部变量的创建

  接下来才正式开始运行我们撰写的代码-创建局部变量int a,b,c,从下图可以看到,编译器在[ebp-8]也就是ebp所在地址往后移动8个字节这个位置为a开辟了一块空间将a的值0Ah这个八进制数字(转换成十进制也就是10) mov(移动)到这个位置。

通过在内存中搜索可以清晰的看到a的指针和a的值

 

 同样的,b和c也是依次放入,通过观察地址可以看出,元素在放入时并不是相邻地址,两个元素之间隔着两个地址位。

 三个变量创建完成之后就要通过ADD函数对a+b的和进行计算并存入c中。

 

3.2传参

  下面就要对ADD函数进行调用,而调用函数的第一部就是传参,编译器在进行传参时一般都会从右往左传,所以按顺序先对b进行传参再对a进行传参,mov 将[ebp-14h]即b的值20放入eax这个寄存器之中,然后将exa进行压栈操作,push到栈顶。然后对a进行传参,将a的值放入ecx中然后进行压栈。同样,两次压栈,esp也会跟着往上挪动两个地址位。

传参完成后,下一条call指令就是对ADD函数进行调用,此时我们一定要注意一个细节,记住call指令以及它的下一条指令的地址,它将为之后栈帧维护完ADD后跳回到main函数发挥关键作用。

   了解过调试的应该都知道,F10是逐过程调试不会进入函数内部,F11则是逐语句进行调试也就是逐行进行调试,而我们为了更加方便的观察代码的运行,此时调试时一定要按F11进入到ADD函数内部去。此时点F11执行下一条指令,我们通过内存可以看到又有一个值被push到栈顶压到了刚刚传参入栈的a和b的上面,而通过与上图对比可以发现,这个值就是call指令的下一条指令的地址。

   此时栈区中的结构如下图所示

3.3创建ADD函数的栈帧并初始化

 接下来,继续按F11,此刻才是真正进入ADD函数的内部 

 此时,通过观察下图可以发现,前面这些指令 push mov sub···和刚刚创建main函数时一样,此时编译器在为ADD函数开辟一块空间即为ADD函数准备栈帧。

然后依旧是和之前一样的操作,将还停留在main函数底部的ebp的值压到栈顶,esp往上挪动一个地址位,然后将esp的值给ebp,esp减去0cch这个八进制数,此时就为ADD函数开辟了一块空间即ADD函数的栈帧。

 然后就是三次push,压入ebx esi edi ,将[ebp+FFFFFF34h]加载到edi然后通过edi里的值往下将33h个dword大小的地址位全部初始化为0CCCCCCCCh。

3.4 ADD函数的实现

接下来就是在ebp-8的位置创建局部变量int z,然后招到ebp+8的位置将里面的值也就是a对应到ADD中就是我们创建的局部变量int x,mov到eax这个寄存器中,然后执行下一条add指令,将[ebp+0Ch](0Ch也就是12,通过查找该地址对应的就是b也就是局部变量int y)所对应的值加到eax中,此时eax里已经存了10,加上b后就是30。

 

 接着进行mov操作,将eax中的值存放到[ebp-8]中也就是z中。

  最后一步return z,mov操作,将[ebp-8]也就是z的值放到寄存器eax中去。因为当ADD函数执行完毕后,其所有值都会被销毁,而eax作为一个全局寄存器并不会受影响,所以将返回值z的值放入其中才可以安全的传回main函数。

四、ADD函数的销毁/esp、ebp的复位   

接着往下走就是三次pop(弹出),同样的,每弹出一个值esp就会往下移一个地址位,mov 操作将ebp的值给esp ,此时栈顶就回到了ebp所在的位置。

  然后进行pop指令,将ebp所指内存中所存储的值弹出来,而此时ebp所指位置中的值我们可以看到是创建ADD函数时压进去的main函数的ebp,此时在创建ADD之前将main函数ebp值压进去的重要性就体现了出来。这个值就是为了当ADD函数执行完毕后随着栈帧的销毁可以找到还未执行完的main函数的栈底而存在的。pop指令执行完毕后ebp就弹出到了main函数的栈底,而esp也随之往高地址处移动一个地址位,此时栈区中的结构如下图。

 

 接下来就是ret(返回)指令,ADD函数已经执行完毕,esp、ebp已经复位开始重新维护main函数的栈帧,而ret就是要继续执行main函数中ADD函数之后的还未执行完的指令,也就是call指令的下一条指令的地址,此时编译器找到未执行完的指令往下继续执行add,esp继续往下移动一个地址位。

 

  回到main函数中的add指令,这个add不是我们自己构造的函数ADD而是编译器自己的add(加),将esp加上8,而加8跳过的两个地址位存放的值就是刚刚copy的形参int x,int y。因为ADD函数已经执行完毕,x和y已经没有用处了,所以编译器对其直接进行销毁,释放其所占内存。

   通过上面一系列的操作我们也可以明白,我们直接传过去的参数并没有在ADD函数内部创建新的局部变量,而是在ADD函数创建之前就已经copy了一份需要传递参数的值然后push压到ADD下面,在需要使用时通过寄存器找到它们从而进行运算和使用,我们把这种使用方法也叫做“传值调用”。而在日常代码编写过程中,最常见出现的bug就是在而在ADD函数中对这类传过来的参数进行修改想要达到修改原始参数的目的,而修改的却是copy的形参也就是此代码中压在ADD下面的x和y,当执行完毕,栈帧进行销毁后,这两个数也会跟着销毁,而真正的a和b还是在main函数中,ADD函数中对x,y的操作对其没有任何影响。所以,这也印证了“形参是实参的临时拷贝”这句话。

五、main函数的结束与销毁

 

  mov 将eax中的值(也就是经过ADD函数计算得出的z)放到[ebp-20h]中,也就是c的地址。然后对其进行打印,而打印结束后,下面到return 0的操作就是对main函数的栈帧进行销毁和释放。其过程、逻辑和ADD函数一样,最终一步一步销毁其栈帧释放其所占用内存。最终完成整个代码的运行。

  至此,本章关于“函数栈帧的创建与销毁”的内容就已经全部结束了,感谢各位的查阅,如对您有帮助可以点赞收藏加关注。更多的c/c++相关技巧和编程知识请继续关注博主的CSDN博客,有好的想法和建议可以通过评论区或私信交流,欢迎大家评论留言,我们下期再见!

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

C+五条

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值