目录
前言
这里补充一下函数线帧的创建和销毁,我们知道函数调用一次就会占用一次栈内存。每一次函数调用都会为本次函数调用分配内存空间(是在内存的栈区),为本次函数调用分配的内存空间叫做被称为这次函数调用的栈帧空间,函数栈帧的创建和销毁。
编译器越高级,那么 就越不容易发现在函数调用的过程中线帧的创建,具体细节取决于编译器的实现。新的编译器由于考虑各种各样的问题,所以封装的更加复杂,不容易分离出来函数栈帧创建的过程。
栈空间的使用是从高地址向地地址增长。
寄存器是集成到cpu上的,跟mian函数是没有关系的,是独立的,硬盘,内存,寄存器是相互独立的。
补充知识
我们知道计算机中有寄存器,而寄存器包括很多,例如eax,ebx,ecx,edx,ebp,esp等。
而ebp,esp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。
每一个函数调用,都要在栈区上创建一个空间,而当调用哪个函数,esp和ebp就会维护哪个函数线帧。通常esp称为栈顶指针,sbp称为栈底指针。
当我们在调用main函数之前,会调用_tmainCRTStartup这个函数,这个函数内部调用了main函数,而这个函数又是被mainCRTStartup这个函数调用的。
其实在VS2013中,main函数也是被其他函数调用的。
一、函数线帧是什么?
函数栈帧是指在函数调用过程中,为了保存函数的局部变量、参数和执行上下文等信息而创建的一块存储区。
每次函数调用时,都会在栈上创建一个新的栈帧,用于存储该函数的局部变量、参数和执行上下文等信息。栈帧通常包括以下几个部分:
局部变量区域:用于存储函数内部定义的局部变量和临时变量等。
参数区域:用于存储函数的参数值。
返回地址:用于保存函数调用完成后的返回地址,以便能够返回到调用方继续执行。
上一级函数的栈帧指针:用于保存上一级函数的栈帧地址,以便能够返回到上一级函数。
其他上下文信息:如调用方的寄存器值、异常处理信息等。
函数栈帧的创建和销毁是由编译器和操作系统自动完成的,开发者一般无需手动管理。函数栈帧的创建和销毁按照函数调用的顺序,形成一个栈结构,因此也被称为调用栈或执行栈。在函数调用完成后,栈帧被销毁,栈指针回退到上一级函数的栈帧,继续执行上一级函数。这种递归结构的栈帧可以保证函数调用的嵌套和返回顺序的正确性。
二、函数线帧的实现(举例说明)
两数之和代码
#define _CRT_SECURE_NO_WARNINGS
#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;
}
我们通过用vs2022来进行讲解,例子用的上述代码+两数之和函数。
两数之和 汇编代码分析
我们通过F10来调试代码,之后在代码界面点击右键,选择反汇编,就会出现一个新页面,而这个页面就是图中右侧的,这是C语言对应的汇编代码。
把显示符号取消勾选,因为选上会显示符号名,而我们想要看其地址和布局,所以就去掉。
这时候里面就写的是符号的地址了,而不是符号名字了。
因为main函数是其它函数调用的,所以调用的__tmainCRTStartup已经分配好栈空间了,此时esp和ebp就会维护这个函数线帧(前提下面是高地址,上面是高地址):
执行第一条语句
意思是将ebp进行压栈,所以栈顶就多了一个元素(ebp),因为esp维护的是栈顶,所以esp指向往上移动了一步。内存就压进去一个ebp。
执行第二条语句
mov的意思就是把后者的值赋给前者,这里也就也是将esp的值赋给ebp,我们知道esp的值赋给了ebp,那么ebp的地址就指向esp,而esp又指向栈顶,ebp就指向了之前esp指的地方,所以就变成了下面:
执行第三条语句
sub是前者减去后者,也就是esp减去后面的数字,0E4h是八进制,esp减去这个数后值变小了,所以esp就指向了上面的某一位置,我们知道esp和ebp之间的就是函数的栈帧空间,所以他们俩之间的就是开辟出来main函数的预留空间,也就是main函数的栈帧。
执行第四、五、六条语句
分别压栈压进去三个元素,ebx,esi,edi,同时esp的值会往上走。每一个push,esp都会往上挪一下。
执行第七条语句
lea实际上是 load effecitive address(加载有效地址)的意思,这里面就是将后面的有效地址加载到edi中,相当于edi里面加上了一个地址。它实际上就似乎找了第三条语句的地址。
执行第八、九、十条语句
这三步真正有意义的是第三步,前两步是把后面的值放到前面的里面。而第三步则是从edi开始,将39h这么多个空间全部改成eax的内容,每一次初始化(dword个字节,一个4个字节)。实际就是将main函数预开辟的空间的全部数据改成0ccccccch。
自此,main函数的栈帧就已经准备好了。接下来才是操作的代码。
执行第十三条语句
这里就是将0Ah(10) 放到ebp-8这个地址的地方,也就是a的地方,这块地方就放了10
执行第十四、十五条语句
与第十三条语句一样差不多,都是赋值给对应地址。
这就可以总结出函数创建的时候局部变量的创建规律:
首先,创建这个函数的函数栈帧
之后找到一些空间把变量放进去
接下来就是调用函数,因为函数调用需要传参
执行第十六条语句
从这里我们可以看见后面这个地址与前面的有一个地址一样,这里就是b的地址,所以这里把b的值也就是20放进了eax.
执行第十七条语句
这里进行压栈,把eax压栈,放到栈顶。如下图:
执行第十七、十八条语句
这里与前两句一样,先把a的值存到ecx中,之后在把ecx进行压栈。
执行第十九、二十条语句
执行call指令,会调用一个地址,并且把地址压入栈中,而压入栈中的地址就是下一条指令的地址,也就是add的地址00521918(每次编译可能会不同)。
为什么要调用下一条指令的地址,因为call指令会进入add函数,但进入函数后还需要回来,回来怎么回来,就需要一个地址来进行返回,来回到call指令的下一条指令,再从这个地址往下执行命令。
进入Add函数里面
Add函数预开辟空间
再往下运行,就进入到了Add函数里面,而上面这些就是为了为Add函数预开辟线帧空间。
与之前开辟main函数的线帧空间一样。开辟之后就如上图所示。
创建z
在ebp-8的地址传入z,这里面放的是0
相加
我们知道ebp -8就是ecx的位置,ebp-12就是eax的位置,同时ecx里面是a的值,eax里面是b的值,所以给他们给他们命名为是a'和b'。
这里面吧ebp+8的值(10)加到eax里面去,所以eax就为10,之后吧ebp+0ch(12)的值(20)再加入eax中,这时候eax里面就为30,加起来之后再把eax的值放进ebp-8的地址里面去,而z恰好是ebp-8的地址,所以z就为30。
这里我们发现函数参数x,y并没有,而是通过调用指令进行传参,将形参进行push压栈,压到某一位置,参数是从右向左传的,当真的来到函数内部来调用这两个数相加的时候会发现,形参根本不是在函数内部创建的,而是回来找刚才调入进来传参传入的这个空间,压进去这个空间,上图a'就认为是x,b'就认为是y,也就是x+y,之后传入z的空间。
实际传参
实际传参是还没有调用这个函数的时候,参数a和b就已经传过去了,在函数栈帧中压入了两个参数(b和a),压进去之后,真正用函数内部的时候,其实是再找回之前压入栈中的这两个值,然后相加之后再给z。所以形参是实参的临时拷贝这句话得到了验证。
返回z
因为我们知道函数调用完后会销毁,同时z也会销毁,所以把ebp-8里的值也就是z的值30放进eax寄存器中,寄存器不会因为函数的销毁而销毁,所以放在这里就安全了,先保存起来。
弹出
pop就是弹出的意思,弹出一个esp就往下移动一个地址,之后就如下图了:
这时候发现有些空间没有用到,所以应该回收。
回收没用空间,回到main函数
这里将ebp的位置传入esp,用pop(把栈顶的元素 弹出来放到ebp里面去),因为此时ebp(main地址)存的是之前下面的ebp地址,所以ebp又回到了下面(之前的地方),而esp则指向下call指向下一条指令的地址,因为之前的ebp已经弹出了。
这样就回到了main函数里面,这样栈帧空间又是又esp和ebp开始维护。
现在栈顶元素是call指令下一条指令的地址
这条指令就是回到了call指向下一条指令的地址。再往下执行就是:
esp+8之后,esp就指向了如下图:
当指向这里的时候,就说明把ecx和eax就还给了操作系统。所以这时候形参的空间就还给了操作系统,释放了。
将数放进c中
这里将eax(计算的和)放进ebp-20h(c)里面去,这样返回值就带了回来。
至此,函数线帧的创建和销毁就结束了。
总图:
三、总结
局部变量是怎么创建的,局部变量的创建?
首先先分配好栈帧空间,然后在这个空间里面分配一些空间来创建局部变量
为什么局部变量的值是随机值?
因为这个随机值是我们自己放进去的,初始化后就把随机值覆盖了,这时候就不是随机值了
函数是怎么传参的?传参的顺序是什么样的?
当我们要调用这个函数的时候,其实函数还没有调用的时候,就已经把两个参数从右向左push压栈压进去了,当真正进入函数的时候,通过指针的偏移量来找到之前传入的这两个形参参数
形参和实参是什么关系?
形参是实参的一份临时拷贝,形参确实是在压栈的时候开辟的空间,它和实参只是值是相同的、空间是独立的,改变形参是不会影响实参的。
函数调用结果是怎么返回的?
之前就把call指令的下一个指令的地址就压进栈了,把ebp调用这个函数的上一个函数栈帧的ebp就存进去了,当我们函数调用完要返回时候,弹出edp,就可以找到原始(上一个函数调用的ebp),指针往下走就可以找到esp的地址,回到了mian函数。
记住call指令的下一个指令地址就可以回来进行下一个命令。
返回值通过寄存器来临时保存,之后进行返回。
重点理解本篇文章就行。