c++函数执行过程(函数栈)

本节我们重点讨论栈指针esp和帧指针ebp,围绕这两个重要的寄存器,推导出函数栈帧结构。阅读本文之前补充一个概念:栈帧 每个函数的栈称为一帧,也就是该函数的栈帧。函数栈的基地址(EBP)称为栈帧指针,访问函数中的参数或局部变量,都是通过EBP加上偏移量来获得。


一:压栈和出栈的操作本质

        上一节我们了解到push和pop是汇编中压栈和出栈的指令。栈这个东东,当某个程序运行时,会划分一个块固定大小的区域(存储器映射),而栈就属于这个区域的一部分。要了解出入栈首先要了解栈的结构:

      地址     栈中内容

最大
地址
 数据(栈底)  
…………
0x108数据3
0x104数据2
0x100
%esp
数据1(栈顶)

%FC
新%esp
数据0
(新栈顶)


        从上图看出,栈的增长方向是向下的。栈有个最大地址,这个地址成为栈底,也是存储栈里面存储第一个元素的位置,随着入栈个数增加,栈顶的地址不断减小。

        esp寄存器就是专门用来存储栈顶地址的。在汇编中,%esp读出栈顶地址,(%esp)就能读出栈顶里的数值。如上图所示,如果再进行一次入栈push操作时,那么栈顶%esp就跳到地址0xFC(0x100-4)处,新压的数据也会存在这个地址上。如果上图不执行push,而是直接执行pop出栈时,esp将存储地址0x104。

        push和pop这两个汇编操作指令,是可以用基本的汇编操作代替的,事实上,push和pop在汇编中对应的操作是:


push %ebp:subl$4, %esp

      movl %ebp, (%esp)

pop   %eax:movl   (%esp),  %eax

     addl $4,  %esp


        在分析上面汇编代码之前再复习一下,%eax直接获取里面的值,(%eax)类似C指针‘*’间接寻址操作,是取出%eax里的值作为地址来看,再根据这个地址找到相应位置,并取出其中的值。


        还以上图为例。先来看push压栈,压栈是增加栈的元素,由于有新的数据(ebp里的值为数据0,具体什么值先不关心)要入栈,而栈又是向下生长的,因此需要将存有栈顶地址信息的esp进行调整,具体操作是将esp减4,得到增长后的下一个栈顶地址,subl$4, %esp操作使得esp的值从0x100跳变到0xFC,实现了栈顶的生长;接着是赋值,我们需要把ebp里的值传送到新的栈顶指向的空间中去(地址0xFC代表的空间),完成入栈。语句movl  %ebp, (%esp)比较好理解,就是把ebp里的值,通过“()”对栈指针进行间接引用,传送到地址0xFC的空间里面去,esp是栈指针(叫栈顶指针更好理解)。

        为啥%esp要加括号?如果不加括号,栈指针所存的地址数据将被破坏,本来跳变好了新栈顶地址0xFC,会因为你的一个不加括号的语句而使栈指针%esp被覆盖成%ebp的值(数据0)。而加了括号,则会做间接寻址操作,通过%esp,找到地址为0xFC的空间(也就是新的栈顶空间),并把数据0成功传送进去。


        一旦你理解了上面冗长的废话,再理解pop就很简单了,出栈无非就是把操作反过来。比如刚才push完了,我们再执行pop %eax,就是要把栈顶元素的值弹出来,传送到%eax中去,然后栈顶更新状态。那么movl   (%esp),  %eax语句就是将当前栈顶里的值(数据0),传送到eax中去;而addl  $4,  %esp就是更新栈指针,把地址值加回去(从0xFC变回0x100)。


        这里有个细节问题,关于出栈,有没有发现,只有数据出和栈顶更新,并没有数据删除操作。也就是说,刚才连续执行了push %ebp和pop %eax后,栈指针指向的是0x100地址,栈顶的值是数据1。那么地址0xFC里存的什么呢?答案当然是数据0,因为没有任何语句删除它,所以才会出现有时候你调试C语言程序,指针越界访问后,会读出一些已经失效函数里面的临时变量值,就是这个原因。


        用汇编语句理解出栈入栈,对于接下来的函数栈空间的理解是至关重要的。


二:函数调用的栈帧结构

        在我看来,从某种意义上说,C语言就是个函数嵌套语言,从一个主函数开始,内部生出几个子函数,每个子函数下面还有更细的子函数,有时父子函数之间还会出现递归嵌套调用,在加上循环和条件判断,如此复杂的操作,编译器是怎么翻译成汇编来实现的?这依赖于简单实用的栈帧结构,这里我们引用网上的一个火图:




        说句老实话,本来这个图并不是那么难理解的,无论函数嵌套有多复杂,总有个先后吧?这个帧那个帧不就是根据调用的先后排列顺序的,先调用的函数,其栈帧结构就整体先入栈,后调用的函数就后入栈,那么栈顶所代表的函数帧(当前帧),就是当前正在调用的函数,所需要的数据映射,解释完毕。

        言归正传。要理解%ebp,首先还是要复习一下上面讲的间接引用,搞清楚寄存器所存值的概念。寄存器里存的值本质上就是数值,关键是我们如何看待它的意义,就比如栈指针%esp,叫它栈指针是因为它一般来说存的都是某个空间的地址,这是编译器的习惯分配。如果你是做编译器的,完全可以用%esp当成%eax或者其他什么寄存器来临时存放一下其他数值,再把地址赋回值给它,如果不嫌麻烦的话。因此类似栈帧结构的这些知识,其实是编译器事先定义好的对寄存器的使用规则,记住,寄存器里的值我们要怎么理解,那是由编译器说了算的。

        为了简单好理解,我们讨论最简单的函数嵌套,假如函数grand调用函数father,而father调用函数son,father的栈帧就是上图所说的“调用者的帧”,而son就是“当前帧”,grand自然就包含在“较早的帧”之中。father有1~n,n个变量要作为参数传给son。从上图能明显看出,n个参数是倒着排列的,这是由栈结构决定。在参数传递中,son(1,2,3,…,n)代码顺序,在栈帧结构上是地址由小增大排列的。参数下面是返回地址,这个返回地址,其实就是father函数自己的地址,同时也是father函数栈帧的末尾(注意和栈顶或者栈底概念完全无关)。

        好,回过头来看,那么参数n以上的省略号是什么呢?其实,son函数栈帧的“参数构造区域”,和father的参数1~n是一回事,也许里面放着的是参数1~m,用于son来调用孙子函数时用,因此参数n上面的省略号,就是father函数被保存的寄存器、本地变量和临时变量,再往上就是father函数自己的“被保存的%ebp”。

再往上呢?就进入grand函数的栈帧结构(较早的栈帧),往上第一个一定也是“返回地址”,其实就是grand函数执行完father后应该继续执行的代码的地址。

        说到这里可能你觉得还好,按照调用顺序,函数的栈帧结构维护得很清楚。可以想象,当某个函数要调用其他函数调用时,先通过一系列压栈操作,在栈里面备份函数自身的本地临时变量,还有传递给子函数的参数变量信息,最后压上函数自个儿的地址,完事,下面的空间就留给子函数玩了。

        这里问题就来了,CPU如何区分不同的栈帧?如何搞清楚栈里面哪部分是子函数哪部分是父函数?栈指针%esp只知道自己现在在哪玩,对于具体玩的是哪个函数的内容,那是一头雾水啊。于是我们有必要解开%ebp面纱了。


三:神秘的%ebp

        %ebp叫帧指针,相信熟悉C指针的朋友看到名字时,对%ebp的工作原理就基本明白个七八分了。没错,既然叫帧指针,那就是用来存放各帧首地址的指针。

        设想,当father函数要调用son函数时,需要对栈帧信息进行修改和维护,如何在son函数执行完后让CPU顺利的找到father的栈帧地址并成功返回呢?这就要在调用son之前做好充分的准备工作。比如,father栈帧有自己的帧首,在father函数执行时,%ebp就保存了这个帧首的地址值,或者说%ebp正指向帧首。当调用子函数son时,%ebp就会保存son的帧首地址,为了让son在返回时能够顺利更新%ebp,使得帧指针顺利指回到father的帧来,有必要在%ebp指向son帧首的同时,更改帧首空间内所保存的值为father帧首地址,也就是son的所谓“保存的%ebp”,或者说旧的%ebp值,父函数调用时%ebp的值。

        这里感觉很绕的同学,一定是指针基础还不够扎实。来区分下一个概念,一个存储单元空间,有两个属性:1、CPU访问这个存储单元需要依赖的地址值;2、这个存储单元所存储的数值,空间地址值和空间内存储的数值,区分这两个值是理解指针概念的基础。现在讨论函数的帧,每个帧都帧首,帧首作为存储单元空间,当然有标识自己的空间地址,同时空间里存了一个数值。栈帧结构恰恰巧妙的利用了这种概念,让%ebp始终保存当前调用函数的帧首地址,而当前帧首内又存储着父函数的帧首地址,以此类推,每一个当前调用函数的帧首内都保留着父函数的帧首地址,函数执行完成时都能顺利更新栈指针%ebp的值,一直可以推到main函数的帧首,通过栈指针%ebp的修改和被保存,就能确保栈帧结构的访问顺利进行,是不是很奇妙?

        </div>
            </div>
</article>
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值