在我们敲代码的途中,我们会创建并且运用各种函数来实现我们需要的功能,但是,大家有没有深入了解函数究竟是如何被编译器使用的呢?今天我们就来深入了解编译器是如何使用函数的吧!
首先,我们需要了解计算机本身的几个结构。
首先,计算机的存储结构分为三种,分别是硬盘,内存,以及寄存器,而我们的编译器就是利用内存以及寄存器来编译函数的。
而内存里又分成了栈区,堆区以及静态区,一般来说,我们的函数的创建以及销毁,以及变量的创建和销毁都是在栈区里面进行的。
而寄存器和内存不同,寄存器是在cpu里面,也是读取数据最快的部分,不用进行IO操作。
寄存器由以下几种:
eax 累加和结果寄存器
ebx 数据指针寄存器
ecx 循环计数器
edx i/o指针
esi 源地址寄存器
edi 目的地址寄存器
esp 堆栈指针
ebp 栈指针寄存器
接下来让我们来大概了解一下函数是怎么创建以及销毁吧。
当我们创建了main函数并且编译运行的时候,编译器会在栈区中开辟一个一定大小的区域,然后
用寄存器中的ebp指向main函数的底部,esp指向main函数的顶部。
但是当我们新建立一个断点,然后进行调用堆栈的时候,可以发现,这样一条语句:
extern "C" DWORD mainCRTStartup(LPVOID)
{
return __scrt_common_main();
}
这条语句告诉我们,main函数实际上是由这个mainCRTstartup语句调用过来的,而我们main函数的return 0就是返回但该函数里面。
之后我们开始深入了解函数栈帧的具体过程。
之前我们说过,main函数实际上是由mianCRTstartup函数引用进来的,因此编译器在栈区里面会先建立mainCRTstartup函数的栈帧,如下:
在这里我们所用来示例的代码如下:
#include<stdio.h>
int Add(int x,int y)
{
int z = x+y;
return z;
}
int main()
{
int a = 10,b = 20;
int c = 0;
c = Add(a,b);
printf("%d\n",c);
return 0;
}
之前我们说过,main函数实际上是由mainCRTstartup函数引用而来的,所以编译器会在栈区中首先将esp和ebp压入栈,并且用esp减去一个数用来移动对应的地址,然后作为栈顶(低地址),ebp指向栈底(高地址),这样就成功开辟出一个空间,之后当main函数开始运行后,编译器会进行下面的操作:
这里的汇编语句实际上是将ebp压栈,然后将esp的值给ebp,这样就会将ebp移动到esp的位置,然后进行sub操作,esp会减去0E4大小的值,这样就开辟出一个空间,给main函数使用,
然后在压入三个元素入栈,分别是ebx,esi,edi,实际上,每压一个元素入栈区,esp都会指向最新的元素的地址,所以压入三个元素入栈后,实际上esp的地址就会指向edi的位置。
上面这张图是压入三个元素后,几个元素所保存的值以及它们的地址,我们可以看到,edi中存储的值是00c2110e,而右边可以看到,这个值的地址是008fab4,正好是esp所存储的值。(疑惑为何值的从右往左看的可以了解一下数据的存储方式)。
之后再看接下来编译器进行了lea操作,(lea操作实际上是加载有效地址),这个操作将ebp-0E4h的值给了edi,而ebp-0E4h实际上就是指main函数的实际空间大小。
接下来的两个mov分别将39h放到ecx中,0CCCCCCCC放到eax中,最后一句操作是说从edi位置开始,向下的39h大小的空间全部设置为0CCCCCCCC大小的值,这样就将main函数内部的全部设为了0CCCCCCCC。而这就是为什么我们设置局部变量的时候需要初始化的原因了,当我们还未初始化局部变量的时候,我们输出的值是一个随机值,而随机值的大小就是看编译的时候,编译器给了多大的数放在main函数内部。
表示如下:
以上就是编译器为main函数创建空间的步骤,之后就开始执行我们的代码
接下来的编译器会遇到我们所创建的局部变量,分别是a = 10,b = 20,以及c = 0;
编译器会在main函数的空间内部分别找到一个地址来将a的值,b的值以及c的值存储,若是没有初始化,那么局部变量的值就是之前所存储的0CCCCCCCC。
然后我们的编译器就遇到了Add(a,b),开始了函数调用操作。
编译器的操作如下
首先将ebp-14h的值放在eax中去,而ebp-14h的值实际上就是b的值(看上面的图),然后压栈,在将ebp-8的值给ecx中去,也就是a的值,再压栈。
之后进行call指令,我们可以看到地址出现了这样的变化:
在eax和ecx的值分别被压入栈之后,我们可以看到在它们的顶上又被压了一个值(00c24105)入栈,而这个值就是call指令的下一条指令的位置,这样就为函数返回提供了一个位置。
进行完call指令后,我们就来到了Add函数内部,在这里,编译器会进行为main函数开辟空间的操作
与main函数一样,用ebp和esp来维护函数,其空间大小为0CCh,其中放的值是0CCCCCCCCh。
但是有一点要注意的是,这里第一句指令是push,将ebp的地址压入栈,这里的ebp地址实际上是main函数的ebp的地址。(实际上在main函数调用的时候,栈区里面还压了CRTmainstartup的ebp的地址,只是我忘记画了)
之后碰见了我们为Add函数创建的局部变量,z = 0,并且碰到我们所做的操作z = x+y;
遇到我们写的z=x+y后,编译器进行了三个指令mov add,mov,我们可以看到,编译器首先进行mov指令将ebp+8里的值给了eax,然后进行add指令将eax的值和ebp+0Ch中的值相加,最后进行mov指令把eax的值给到ebp-8的地址上去,而z的地址就是ebp-8,这样就将两个数的和传给了z。
而ebp+8和ebp+0Ch的值就是我们在main函数中压入栈的ecx和eax。
这两个ecx和eax实际上是作为我们的形参使用的,与我们之前在main函数中的a,b的值相同但是地址不同,这就是为什么传值操作形参改变不了实参,而传址操作可以。
之后回到正题;
我们进行完相加的操作后,就会执行return z的指令
这里的mov指令将ebp-8(z)的值放到寄存器的eax中去,这样函数退出的时候结果就不会被销毁了。
后面进行了三个pop指令,分别弹出栈顶元素,然后放到edi,esi,ebx中,这时esp就到了之前ebx的位置,然后将esp移动到ebp的位置,就将编译器为Add开辟的空间全部释放了,然后弹出栈顶元素给ebp,而这里的栈顶元素所存储的值是main函数中ebp的地址,这样ebp就指向main函数的栈底,而esp因为栈顶的弹出而+1,重新指向了main函数的栈顶。
然后编译器执行了ret指令, 由于之前在栈里存储了call指令的下一条地址,所以执行了ret后就会弹出栈顶的地址并且直接从call指令的下一条地址继续执行。
然后我们成功返回了main函数后,我们在栈中压入的ecx以及eax(形参)就没有用了,我们需要删除,编译器就执行了下面的语句:
首先执行add指令,让esp+8,之前我们压入栈的两个形参eax和ecx的地址正好是8,esp+8后,就将这两个变量的空间给释放了。
然后执行下面的mov指令
这里是将eax的值放到ebp-20h中去,eax是在Add函数里面计算出的结果,而ebp-20h是main函数中的c的地址,这样就成功将函数的返回值给了main函数的局部变量c中去了。
然后编译器碰见了main函数中的return 0,开始执行main函数的栈帧的销毁,与Add函数一样,就不再赘述了。
总结
1.局部变量如何创建?
利用ebp的偏移量在函数对应的栈区空间输入值
2.为什么局部变量不初始化会是随机值?
因为编译器在创建栈帧的时候,不仅会设定空间的大小,也会把空间里的地址上全部赋上随机值,这个值每一次编译时都不一样。
3.函数如何传参?
编译器会利用寄存器,在函数调用之前,从右往左将实参的值赋给寄存器,然后压入栈中,之后在使用形参的时候就会通过指针的偏移量直接使用
4.形参和实参的关系?
形参是实参的临时拷贝。值相同,但是空间独立,除非传址操作,让形参实参建立关系
5.函数调用如何返回?
在函数调用之前,编译器会提前将main函数中所执行的call指令的下一条指令的地址压入栈中,然后将main函数的栈底的地址压入栈中,这样在返回的时候通过pop指令将弹出地址赋给ebp,这样ebp就能直接返回main函数的栈底,并且成功找到函数调用之前mian函数执行的指令的地址(不是在main函数中调用也时这样,都是存的上一个函数的栈底地址和call指令的下一条指令的地址),而返回的值就放在寄存器中,将其赋给main函数中接收返回值的变量。
这就是函数栈帧的全过程。