🦖作者:学写代码的恐龙
🦖博客主页:学写代码的恐龙博客主页
🦖专栏:【初级c语言】
🦖语录:❀未来的你,一定会感谢现在努力奋斗的自己❀
本篇文章将从以下几个方面带领大家深度学习函数栈帧的创建和销毁:
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序怎么样?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
首先大家得明确一点:在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现,本篇文章是在vs2013的基础上进行演示的
一:学习函数栈帧前的铺垫:
在学习函数栈帧前先要进行一点知识铺垫,这样会有助于我们后面对函数栈帧的理解
1:何为函数栈帧?
函数栈帧是在内存中的栈区为被调函数开辟的一块空间,里面用来存放该函数中定义的变量等东西(下文会详细讲到),当函数运行完毕栈帧将被销毁。再向大家介绍一下**“栈”这个概念,“栈”实际上时一种数据结构**,它是一种先进后出的数据表,何为先进后出?举个简单的例子:就像洗盘子,最先吃完饭的人把盘子放在水池的最低端,比他后吃完饭的人会把盘子落在他盘子上面,当洗碗的时候,会从最上面的盘子开始洗,这也就意味着,虽然你第一个吃完饭,但你的盘子却是最后一个被洗的。这就对应栈的先进后出。对栈常见的操作有两种:
- Push(入栈):为栈增加一个元素,就相当于往水池里放盘子
- Pop (出栈): 从栈中取出一个元素,相当于洗完一个盘子把这个洗过的盘子从水池中拿出来
2:寄存器
- eax:是"累加器"(accumulator), 用来存放函数的返回值。
- ebx:是"基地址"(base)寄存器,可作为储存器指针来使用, 在内存寻址时存放基地址。
- ecx: 是计数器(counter), 在循环和指针操作时,要用它来控制循环次数。
- ebp和esp:他俩都是指针寄存器它最经常被用作高级语言函数调用的"框架指针"(frame pointer),简单来说这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。
- ebp:存放栈底的地址(指向栈底)
- esp:存放栈顶的地址(指向栈顶)
- edi和esi:它俩都是变址寄存器,常用来配合使用完成数据的赋值操作
3:汇编指令
- move:move A,B (将数据B移到数据A)
- push:压栈(入栈)
- pop:出栈
- call:调用函数
- add:加法
- sub:减法
- rep: 重复
- lea:加载有效地址
4:每一个函数调用都要创建函数栈帧
所有的函数调用都会在内存里面的栈区创建函数栈帧,包括main函数。通过上面对函数栈帧的介绍我们知道,函数栈帧是为被调函数在内存的栈区中开辟的一块空间,所以这里间接证明了,main函数也是被调函数。可能很多小伙伴的认知都停留在,main函数是主函数,可以在main函数中调用其他函数,从来没有想过main函数其实也是被调用的。
二:函数栈帧的创建与销毁详解:
我们以下面这段代码为例,向大家讲解函数栈帧的创建和销毁
#include <stdio.h> int Add(int x, int y) { int z = 0; z = x + y; return z; } int main() { int a = 10; int b = 20; int c = 0; c = Add(a, b); printf("%d\n", c); return 0; }
2.1:main函数也是被调用的
首先点击F10进行调试,在窗口界面找到“调用堆栈”,点击,调出此窗口,此时我们就能很直观的看出main函数是被调用的。
接下来一直点击F10直到程序调试结束
调试结束后我们发现main 函数被 __tmainCRTStartup 这个函数调用。
2.2:main函数的函数栈帧的创建与销毁
由于main函数是被其他函数所调用,所以在 __tmainCRTStartup 这个函数调用main函数的时候会为main函数在内存的栈区中开辟空间:
这里大家需要注意的是:栈区的使用习惯是先使用高地址再使用低地址,在顶上往进放数据
接下来我们调到反汇编进行调试,深入了解函数栈帧,
第一步:push ebp
通过上图可以看出第一步是push ebp,这是因为mian函数是被__tmainCRTStartup 这个函数调用的,在调用main函数之前,esp和ebp分别指向__tmainCRTStartup 函数的栈顶和栈底,当调用main函数的时候,就要为main函数开辟相应的函数栈帧,此时esp和ebp就需要移动去指向main函数的函数栈帧。那这里的第一步就是push ebp,具体过程如下图:
push ebp就是把__mainCRTStartup 函数栈底的地址压栈,ebp的值压入后,esp指针会上移一位
如上图,再push ebp没有执行的时候,esp里面存的地址是0x0037fdb8,当执行完push ebp后,esp存的地址变成了0x0037fdb4,可见地址减小了4,这就意味着esp指针往上走了4个字节。,通过内存窗口我们也可以很容易看出:
此时esp所指向的地址里面存的数据就是ebp所指向的地址0037fe04,说明此时我们已经成功地把ebp所指向的地址压入栈中。
第二步:move ebp,esp
move ebp,esp的意思是:把esp的值给ebp。
esp当前的值是0x0037fdb4,也就是说esp此时指向0x0037fdb4这个地址,把esp的值给ebp后,ebp就也指向0x0037fdb4这个地址
通过下面的调试我们可以看出执行完第二步后ebp和esp指向同一块地址。
动画演示:
第三步:sub esp,0E4h
sub esp,0ECh,就是给esp减去一个0E4h。这里的0E4h是一个十六进制的数字(h表示是十六进制),0E4对应的10进制数字就是228。这也就意味着esp指向的地址会减小228,对应图示就是esp指针会上移228个字节
如上图,第三步执行后esp的值变成了0x0037FCD0,也就是说此时esp指向0x0037FCD0这个地址
如上图所示,紫色这一块空间就是为main函数申请的空间
第四步:3个push
如上图,此时有三个push操作,也就是分别把ebx、esi、edi压入栈中。
如上图:执行完push ebx后,esp指向的地址减小了4个字节(从0037FCD0变到00F37CCC,前者比后者大4)。此时esp所指向的地址里面存储的就是ebx的值005c200,这说明ebx成功压栈。剩下的两个push esi和push edi也是这样入栈,这里就不一一讲解了,可以通过下面的动图进行深入理解。
第五步:lea加载有效地址
lea是load effective address(加载有效地址)的缩写。而 lea edi,[ebp-0E4h]的意思就是把ebp-0E4h这个地址放到edi里面。还记得第二步move ebp,esp嘛?。执行完第二步后ebp和esp指向了同一个地址,然后第三步sub esp,0E4h,让esp指向的地址减了0E4h(228),,此后ebp指向的地址没有发生任何变化,第四步的3个push操作让esp指向的地址又减小了12(一次push减小4,3次push就减小12)。而当前的第五步中的地址ebp-0E4h也就是在执行完第三步后esp所指向的地址,就是要把这个地址放到edi里面(其实就是让edi指向这个地址,因为edi是一个变址寄存器,用存储地址的)如下图:
通过上面两张图可以看出,确实如我们上面分析的那样:执行完lea指令后edi指向的地址就是在第三步执行结束后esp所指向的地址(0x0037fcd0)。
图示如下: