函数栈帧(栈区)

  • 一.前言
  • 二.main函数空间的开辟(函数调用是如何做到的)
  • 三.main函数内部的变量初始化(局部变量是如何创建的以及为什么是随机值)
  • 四.main函数内部的函数创建
    • 1.函数是如何传参的
    • 2.传参的顺序以及实参和形参的关系
    • 3.函数调用结束后是怎么返回的

一.前言

前言:在不同类型的编译器里,函数栈帧的大体是没有区别的,但在细节上可能会有一些区别。这里我们使用vs2013来查看,如果对调试还不太了解的话可以看看这篇博客vs调试

二.main函数空间的开辟(函数调用是如何做到的)

首先要看一看寄存器,寄存器有很多种,这次只看两种esp和edp,因为它们两个是用来维护栈帧的。esp是栈顶指针,edp是栈底指针。

栈区是优先使用高地址的,由上图也可以看出,栈区是向下延伸的。接下来单独画出栈区的图,为了更直观,我直接将高地址放在下面,低地址放在上面

ps:main函数也是函数需要在栈区开辟空间

为了验证上述结论,我们写一个很简单的函数并使用调试功能的调用堆栈监视来查看具体的过程

我们从调用堆栈里可以看出main函数被调用了,那么它究竟是被谁调用了呢?我们接下来让代码继续往下走。

我们可以很清楚的看到main函数其实是被__tmainCRTStartup()这个函数调用。而__tmainCRTStartup()又被mainCRTStartup所调用。可以看出来main函数的调用是比较复杂的,我们每次写main函数都用return 0结尾,其实这个0就被放入了mainret里。那么实际上我们也需要给上述两个函数也开辟空间,在调用main函数之前。

结论:在VS2013中,main函数也是被其他函数所调用的

既然我们在调用main函数的时候要开辟空间,那么我们在使用Add函数的时候也需要开辟空间并且肯定也需要esp和edp去维护,那具体的细节是怎样的呢?这里我们需要简单的阅读一下汇编代码


通过上文我们知道在调用main函数之前会先调用__tmainCRTStartup这个函数。既然我们现在已经进入到了main函数内部,那说明前面那个函数的栈帧肯定已经创建完成了,那么在刚准备调用main函数时,应当是如下图。

第一步push,压栈。将ebp的值放在__tmainCRTStartup这个函数的顶部,同时由于esp是栈顶,esp也会向上移。注意ebp本身所指的位置是不变的,顶部只是放入ebp的值

第二步mov,把后面的值赋给前面的。这里就是将esp的值给ebp,那不就相当于ebp不再指向当前位置而指向esp所指向的位置了吗。

第三步sub,减法。这里是把esp的值减去0E4h,这里的0E4h是16进制数(ps:0h是16进制的象征)。那么减去一个值后,esp肯定就变小了,那么它就会向上移动,指向上面的某一块区域。

从上可以看出ebp和esp准备开始维护下一个函数(main函数),上述的0E4h就是为main函数预开辟的空间(这个大小是由编译器决定的)。紧接着又是三个push,在顶部压三个元素,esp也就随着向上移(每次push后esp的值都会变),具体这三个值是什么目前不需要理解,之后它们会自动弹出。

下一步是lea,load effective address加载有效地址。就是把后面的地址放到前面来。这里ebp-0E4h有什么特殊含义吗?我们再梳理一下脉络,首先我们mov将esp的值给了ebp,再sub,esp的值减去了0E4h。那么可以看出,ebp-0E4h这个值就是esp在进行三次push之前的值。

从lea到rep stos可以完整的看作一个步骤。从edi这个位置开始向下每次初始化4个字节(dword双字,就是4个字节),总共初始ecx(39h)次,初始内容是eax(0CCCCCCCCh)。也就是把main函数内的空间全部初始化为0CCCCCCCCh。

三.main函数内部的变量初始化(局部变量是如何创建的以及为什么是随机值)

好了,现在main函数的空间开辟完成,由于符号名不能让我们直观的看到具体的过程,我们将符号名关掉,只显示地址。

上面的a,b,c的名称就分别用ebp-8,ebp-14h,ebp-20h来替代。那么通过阅读不难发现就是把0Ah(10)这个值放入ebp-8这个位置,而这个10其实就是我们给a赋的值,如果我们没有给a赋值,那么它这里本身应该是CCCCCCCC,这也就是为什么我们经常打随机值时会出现烫烫烫的字样。同理下面的将14h(20)放入ebp-14h这个地址中。将0放入ebp-20h这个地址中。这样我们就将a,b,c三个变量全部赋值啦。其实这里我们仔细观察还能发现ebp-8,ebp-14h,ebp-20h,它们相邻之间差了12也就是差了两个整形,我们可以通过内存监视来查看。

由此我们完成了变量的初始化,接下来是Add函数的创建。

四.main函数内部的函数创建

1.函数是如何传参的

我们都知道函数调用是需要传参的,那它究竟是如何传参的呢?

第一步把ebp-14h里的值放进eax。ebp-14h的地址就是b所对应的地址,也就是将20放进eax里。

第二步是push,将eax放到顶部。

第三步ebp-8里的值放到ecx。也就是将a的值放入ecx。第四步再push,将ecx放到顶部。

第五步call,调用。这条指令就是进入函数内部需要按F11,在看这条指令时我们需要观察他地址

接下来按F11进入函数并且打开我们的内存监视。

我们可以看到它在0x008FFAA8这个位置存了我们call指令的下一条指令的地址并且在它下面的一个地址0x008FFAAC里存的是10。也就是说这个地址是在变量a的上方的。也就是把call指令的下一条指令的地址压在a的上面了。

我们都知道函数调用完毕后是需要返回的,而这个地址就是来帮助编译器进行定位。

回归正题,进入函数内部后我们可以看到下面的汇编代码。

我们可以看到它的前面一半其实跟我们的main函数的创立是一样的,是在为它分配栈帧,这里简单梳理一下。首先,将ebp进行压栈操作;接着将esp的值赋给edp;然后esp-0CCh就是开辟0CCh这么大的空间;之后又是三个push;紧接着就是将里面的空间全部初始化为0CCCCCCCCh。

接下来就是创建z,将0放入ebp-8的地址里。

然后是计算x+y并把值放入z中。首先是把ebp+8所对应的值放入eax,ebp+8就是把ebp向下移两个整形,就是10,然后再将ebp+0Ch所对应的值与eax相加;也就是10+20,再存入eax。之后再将eax里的值放入ebp-8(z)中。我们也可以看到x+y=z这个操作并不是在Add函数内部进行的实际上是直接通过寄存器实现的。

2.传参的顺序以及实参和形参的关系

通过上面的演示,我们可以理解到我们常说的传参其实是一份临时拷贝的意思了。本质上就是由寄存器复制参数的值然后再利用压栈,最后通过向下回访来的到参数,并不是在Add函数内部创造的。并且也可以看到我们传参是先传的b再传的a,所以传参是从右向左传的。

3.函数调用结束后是怎么返回的

之后是函数的返回。

把ebp-8里的值放入eax里。就是把z的值拷贝到eax里。这也就解释了为什么在调用函数后,函数被销毁还能有返回值。eax是寄存器是不会随着函数的销毁而销毁的。

返回完毕后紧接着是三句pop,pop就是弹出栈顶(也就是把该地址所对应的内容给后面的寄存器),每弹出一次esp就会向下走一步。由于edi,esi,ebx所对应的地址的内容就是它们本身,所以实际上就只是让esp向下移动3次。

弹出完成后就该销毁空间了,mov把ebp的值赋给esp,也就意味着ebp和esp指向同一位置。

之后又是pop,ebp。我们原本这块空格里存的是main函数的ebp,而此时弹出(将mian函数里曾经edp的地址赋给现在的edp),ebp寄存器也就是直接返回原本main函数ebp的位置;与此同时,esp向下走一步。

最后是一个ret,就是返回。返回到所对应的地址位置,也就是返回到call指令的下一条指令,看我们监视显示。

第六步,add。esp+8,也就是直接到esp+c的位置,释放掉我们所创建的形参。(一旦esp向下走,上面的部分就自动销毁了)

第七步,把eax的值放到ebp-20h里去。就是把30放到c里。这样我们的函数调用才算完毕。同理,main函数的销毁也是这个过程,就不再累述了。

还可以看到我们之后还有printf的汇编,因为printf实际上也是一个函数实现的大体与Add函数的实现相同,但由于printf很复杂,这里就不再解析了,如果大家有兴趣的话,可以根据上面的步骤自己调试一下代码。但需要注意的是编译器的不同会导致结果的不同。