【浅谈】栈帧的创建与销毁

【浅谈】栈帧的创建与销毁

什么是栈帧?

  简单点说,C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。
从逻辑上讲,栈帧就是一个函数执行的环境:函数参数、函数的局部变量、函数执行完后返回到哪里等等。
  从简单的文字我们无法想象和理解栈帧到底是一个什么样的存在,下面我通过一系列的图片和文字带大家领略一下函数的栈帧,希望对大家有一定的帮助。

为什么要认识栈帧?

  我们通过上面栈帧概念简单的描述知道了栈帧是一种过程活动的记录,它记录了函数在未结束前在内存中的一系列行为,包括函数参数,函数的局部参数的创建,传递,函数在内存中空间的边界,函数执行完后返回的地址等等数据。而他们的创建与销毁都是在内存中进行的,我们在学习C语言的过程中仅仅会了简单的编程是不够的,通过认识栈帧,认识函数在内存中的创建与销毁,能让我们更加清晰深入的认识函数的执行过程。对我们以后的编程水平会有大幅度的提高。

栈帧的创建与销毁

  接下来,我会通过VC6.0编译器32位环境下逐步调试一段函数来让大家对栈帧有一个深入的认识。这里我之所以用VC6.0编译器对代码进行调试,原因是VC6.0作为一个早期的编译器版本,它在编译代码时步骤更加简洁明了,更能直观的反应栈帧的销毁和创建。将要调试代码如下:

```int Add(int x, int y)
   {
    int z = 0;
    z = x + y;
    return z;   
   }
   int main()
   {
        int a = 10;
        int b = 20;
        int ret = 0;
        ret = Add(a,     b);
        printf("%d\n", ret)
        return 0;
   }
```

  这里我放上我调试的代码,也希望大家下去后也能积极调试,毕竟代码只有自己上手写过操作过才能更加理解。
  如果有小伙伴在调试过程中遇到汇编语言下,某些值和我的不一样,不必惊慌,要知道这很正常,在不同版本或不同编译器或不同环境下编译时,同样的某些代码产生的结果都可能会有所不同,一来,可能是编译器自己的原因,二来可能是编译器版本的原因,新的版本可能会和老旧的版本在内存的创建,命令上有一些差异。三来,也有系统位数不同的差异,在64位环境下,int型变量占8个字节,而32位环境下,int型变量则只占4个字节。这其中的差异希望大家在心理上有一个预期,在如果出现异常情况时,希望大家能够积极的上网搜索答案,或者来到我的博客下留言,我看到后都会积极帮助的。

main函数在内存中的开辟

在查看main函数在内存中的创建前,我需要先给大家说明一个概念:
  大家在编辑好代码后,按逐语句调试按钮(F10)开始调试,这时在上边菜单栏的调试窗口(DEBUG)打开调用堆栈(Call Stack)这一选项:
           这里写图片描述
  在调用堆栈(Call Stack)中我们可以看到下面的画面,这里第一行箭头指向的是我们创建的main函数,学习过C语言的小伙伴都知道,程序始终是从main函数开始执行的,而这里的调用关系是从下往上,也就是说main函数被下面这个mainCRTStartup()函数调用。
      这里写图片描述
  这里我们另外提一句,main函数虽然是程序的开始,但不代表它就是调用关系的最上级,它依然会被其他函数调用,我把这几个函数列举出来,希望大家也能够了解以一二:

  //main()函数被下面这个函数调用
  //__tmainCRTStartup
 //而__tmainCRTStartup()  函数又被下面这个函数调用
 //mainCRTStartup

  这里我们可以不用过于深入的了解mainCRTStartup()的作用,但main函数被它调用,我们就有了理解这段代码的汇编代码的引子。
  我们在main函数代码块开头区域处右击鼠标,在弹出的菜单栏内选择转到反汇编(Go To Disassembly),如下图窗口:
          这里写图片描述
  会弹出一个如下窗口,我们需要将窗口调整到如下图中main函数的位置,从这里开始观看栈帧的创建:
          这里写图片描述
  在观看之前,我还需要给大家普及一些简单的知识,大家可能看到在黄色箭头所指向的这一行末尾的ebp这个词,以及下一行末尾的esp。以及下面几行末尾提到的ebx,esi,edi,ecx,eax。它们都是我们电脑中的寄存器,寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和地址。这里我们对寄存器的相关知识不作展开,大家只需要知道寄存器是被用来存放数据,指令,地址的临时储存工具。它与CPU组合,可以形成高效的工作方式。

  通过刚才的调用堆栈,我们看到在main函数之前,还有一个mainCRTStartup函数,既然这个函数被使用,那么它在内存中也必然会有一块自己的空间,而esp,ebp这两个寄存器就是用存放这块空间的边界地址(栈顶与栈底地址)的,换言之,这两个寄存器也是用来维护这一块空间的两个寄存器。事实上,每一个函数开辟的空间都是由这两个函数来维护的。如下图:
     这里写图片描述
  而栈空间的数据存放方式是从栈顶压入栈中,取出方式相应的也是由栈顶依次将数据取出。这种数据的读写方式我们称之为压栈,出栈。
  在mainCRTStartup这个函数中它又调用了main函数,相应的,也应该给main函数开辟一块内存空间。
  我们回到汇编窗口,看汇编语句的前三句话:
          这里写图片描述
  这里的push(压栈)将ebp寄存器压入栈顶,其实就是将ebp里存放的栈底的地址压入栈顶的一块空间。后面又使用mov(赋值)将esp寄存器的地址赋值给ebp寄存器,通过下图我们可以看到,这时ebp与esp在内存中同时指向mainCRTStartup函数的栈顶空间:
  这里写图片描述 这里写图片描述
  执行第三条汇编语句前,esp寄存器为了维护这块空间的边界,指向了刚刚压入栈顶的ebp区域的顶部(如上图),第三条语句执行之后,esp寄存器中的地址被sub(减去)十六进制4Ch。我们要知道,栈空间中,栈顶到栈底的地址是由低到高的。也就是说esp这时指向[原地址 - 4Ch]处(如下图所示),在内存窗口(Memory)中我们也能看到如下红框中的一块内存空间(其中窗口第一行地址反应的是esp,ebp的地址,这里的用ebp的地址减去esp的地址,刚好是4Ch):
这里写图片描述 这里写图片描述
  没错,这块空间就是为main函数开辟的内存空间了。我们看接下来的几条汇编语句,将ebx,esi,edi寄存器push(压栈)压入栈顶(这里的ebx以及esi寄存器的作用并不影响我们理解栈帧,所哟我们不需要知道它被压栈的作用),并使用lea(加载有效地址 loading effective address)将有效地址[ebp - 4Ch]传递给edi寄存器(edi寄存器就拥有了main函数低地址边界的地址)。这里我们先不对这条汇编语句的作用做出解释,大家只需要记住这条语句的功能,后面我们会做解释。esp,ebp依然维护函数空间的上下边界,形象示意图如下:
这里写图片描述这里写图片描述
  接下来的三条语句将13h与0cccccccch分别赋值给ecx与eax寄存器,并使用rep stos命令(重复拷贝)将edi所存放地址向高地址的13h(十进制的19)长度的空间重复拷贝0cccccccch这个值。也就是将上面图中红框所框中的地址全部赋值为0cccccccch(通过这条汇编语句,有的同学应该能够理解ecx以及eax寄存器的作用了吧,它俩的作用是将相关命令参数存放传递的作用,同时这里的dword是double word的意思,也就是双字,在C语言中是4个字节的意思)。效果如下:
这里写图片描述
  上面的指令就是在为main函数开辟空间,可以说,对main函数开辟空间是非常重要的前提,没有main函数的空间,一切程序都无法执行,相信小伙伴们也都认可这点吧,那么现在读到这里,相信很多小伙伴都对栈帧有了一定的认识,那么接下来我们将要看到变量的创建以及函数参数的传递。
  首先我们看接下来的三条定义语句,第一句汇编代码mov(赋值)将0Ah(也就是十进制的10)赋值给[ebp - 4]地址。也就是将0Ah放在栈底向低地址 - 4字节间的空间里。同样的,后面两条语句也将14h以及0赋值给[ebp - 8]和[ebp - 0Ch]这两个地址所代表的空间里。形象示意图如下:
这里写图片描述这里写图片描述
  哈哈,函数变量的赋值在汇编语言与内存中的表达是不是不过如此,大家别急,我们再回到下一句汇编语句,Add函数的调用,让我们看一下它在内存中是怎么实现的。这可是重头戏,大家擦亮眼睛哦!
  下面我们看接下来的语句,mov(赋值)命令将[ebp - 8]地址中的内容赋值给eax,并用push(压栈)将eax压入栈顶,(这里小伙伴们想一想,是不是相当于将b的值压入了栈顶?)紧接着后面两句语句,同样将[ebp - 4]地址中的内容赋值给ecx,并将ecx压入栈顶。我们从形象示意图和内存可以同时观察到这一现象:
  这里写图片描述
  哎?有细心的小伙伴是不是猛然明白过来,对,这是在给Add函数传参,那这里被压入栈顶的两个寄存器就相当于a,b的一份临时拷贝。接下来我们看下一句汇编语句,Call(声明函数返回地址)(这里插一句,我们都知道,不管是变量还是函数都在内存中存放,但因为内存的分配使得他们的地址并不连贯,所以为了程序执行的流畅,这里需要声明被调用函数执行完成后返回上一级函数的地址,如果大家还不明白的话可以看一下我画的示意图)将返回地址存放在栈顶,这里我们要观察这个现象时,只要记住Call指令下一条指令的地址(即被调函数结束后返回地址),在内存中就可以观察到:
  注意:在Call语句处调试时需要按F11来进入被调函数Add。
这里写图片描述这里写图片描述
  逐语句F11跳转后汇编代码如下,jmp(跳转)命令将程序执行跳转到函数内部,这时我们再按F11进入函数:
  这里写图片描述
  相信大家对下面的语句都不陌生,这些语句时用来创建Add函数空间的语句。为Add函数开辟一块空间供它使用,事实上每一个函数的调用都需要进行这么一系列的相似的语句。这里我们不详细再将它说明了,它的创建以及随后的局部变量z的创建初始化我们直接给出相应的示意图:
  这里写图片描述这里写图片描述
  随后的z=x+y的汇编语句,而我们可以在汇编语句中清晰的看到,其中并没有创建变量的语句,那么它的x,y是怎么创建的呢?
  我们看一看z=x+y的汇编语句,mov(赋值)将[ebp + 8]地址的内容赋值给eax,add(加法)将[ebp + 0Ch]地址的内容加给eax。然后mov(赋值)将eax内容赋值给[ebp - 4]地址的内容。那么有的小伙伴可能疑问,这里的[ebp + 8],[ebp + 0Ch],[ebp - 4]到底是什么呢?我们不妨看一看刚才的示意图以及相应的内存:
这里写图片描述
  怎么样?是不是有点印象了呢?再配合上面的示意图看一看,[ebp - 4]是Call指令存放的地址,而[ebp + 8]是刚才传进来的参数a=10,[ebp + 0Ch]是参数b=20。现在大家明白了吧,函数内部并没有直接创建一个参数x,y。而是调用了传参过来的寄存器中的值。而且参数的传递还有一个特点,就是按你传递参数时的顺序来读取参数。
  到了这里,z=x+y就执行完了,接下来改返回函数的值了,让我们看看最后返回函数时,汇编语句又是如何反应的:
  这里写图片描述
  在返回z的值时,这里mov(赋值)将z的值赋值到了eax中。我们通过这里可以看出来,函数返回值的传递实质上是通过寄存器传递的。
  随后,函数结束,我们看到三个pop(出栈)指令,将edi, esi,ebx寄存器退出栈顶。并用mov(赋值)命令将ebp寄存器中的地址赋值给esp(这里的意思,大家可以揣摩一下,ebp,esp是维护空间边界的两个寄存器,当他俩地址相遇时,代表这片空间消失),相当于将为Add函数开辟的空间销毁。
  之后再pop(出栈)退出现在存放在栈顶的ebp寄存器返回ebp寄存器中。这个语句的具体意思是将原本储存在Add函数栈底的main函数ebp的地址返回给现在的ebp,这样ebp虽然和esp相遇,但随后ebp依然回到了main函数的栈底,是不是很厉害很自然?
  然后执行返回语句ret(返回)返回存放在当前栈顶的地址中的地址值,我们的程序又自然的回到了刚才Call(声明返回地址)指令的下一条指令处:
  这里写图片描述
  到这里,我们将Add函数的调用和销毁过程看的一清二楚。但这个程序还没有结束,我们接着往下看(按F11逐语句调试):
  这里写图片描述
  我们重新回到main函数中,这里看到add(加法)给esp加8,是不是相当于将刚才传递的两个参数销毁掉了呢?
  随后将eax寄存器中存放的值mov(赋值)给[ebp - 0Ch]地址所存的内容,呐,看看上面这张图,[ebp - 0Ch]地址处不就是ret嘛!
  随后的汇编语句开始调用printf函数,我们就不做深入讨论了,想必大家对函数的创建和销毁有了一定的认识了吧?(是不是不仅认识了栈帧,而且学了不少汇编指令?)
  这里最后提一句,我们在函数创建的这块空间通常叫做运行时堆栈或者叫函数栈帧。这就是栈帧的创建和销毁的全部内容了。

全文完,感谢浏览

猜你喜欢

转载自blog.csdn.net/qq_41866437/article/details/80070724