我们每次在调用函数的时候,都说会进行传参。每次创建函数,或者进行递归的时候,也会说会进行压栈。
那么,今天我们就来具体看看函数到底是如何进行压栈,传参的操作。
什么是栈?
首先我们要知道,我们将内存一般划分为三个区域:
- 静态区
- 堆区
- 栈区
我们平时创建的临时变量,函数都会在栈区中占据空间:
此时我们也要知道栈区的使用规则:从高地址向低地址使用
栈的使用规则:
我们知道抢的弹夹,我们要逐个把子弹往里面压,之后如果取出子弹,就需要将上一次压入的子弹取出,之后逐个取出子弹,并只能按照顺序取出。
栈就是这样的使用规则,遵循先进后出,后进先出。 此时你会想,不能把任意的数据取出,必须一个一个拿,这种结构真的好用吗?
起初我也这样认为,但是计算机就喜欢用这种结构。
在内存中,栈区的使用规则是从高地址向低地址使用的。
函数的栈帧:
C语言中,我们要想观察函数栈帧就需要用到调试。当我们调试时所在的函数(此时函数未运行完),每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。
因为我们知道,只要运行函数就会进行压栈操作,所以分析出一下信息:
- 栈帧是一块因函数运行而创建的的临时空间。
- 每调用一次函数都会创建一个独立的函数栈帧。
- 栈帧中存放着函数重要信息,如局部变量,函数返回地址,函数参数等。
- 当函数运行完毕后栈帧会销毁。
既然会创建函数栈帧,那么就会维护其空间,计算机使用寄存器维护空间。
什么是寄存器?
这里牵扯很多内容,我们只给出笼统解释:寄存器是集成到CPU上的,是独立的,寄存器可以暂存指令,地址和数据。所以寄存器也可以理解为指针。
我们会使用很多寄存器,要理解清楚函数栈帧,就必须理解ebp和esp,这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。我们详细来讲esp和ebp两个寄存器。其余寄存器混个脸熟即可,一会会用到。
esp寄存器:
维护栈顶,始终指向栈顶(此时esp寄存器存储栈顶地址)。
ebp寄存器:
维护当前函数栈帧的栈低(此时ebp寄存器存储函数栈帧栈低地址)。
几个必要的汇编指令:
我们观察函数栈帧的创建和销毁,就要知道几个汇编指令,这样可以更好的阅读以下内容。
记不住没关系,我们一下会一一讲解。
图解:
这里我们使用VS2013来观察,由于VS2022太过高级,有些内部细节就会看不到,所以用VS2013来观察。此时我们执行以下代码:
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;
}
esp和ebp就是维护当前调用的函数。
点击F10开始调试。 接下来我们看main函数被谁调用了,我们进入main函数,并直接执行完。
在VS2013中,main函数也是被其他函数调用的。没想到main函数也是被调用的函数,也理解了为什么每次都要有返回值。
mainCRTStartup函数调用 __tmainCRTStartup函数,__tmainCRTStartup函数调用main函数。
之后我们按住F10,之后右击鼠标找到“转到反编汇”,就可以找到C语言所对应的汇编代码,箭头的指向就可以一行一行的执行,我们来逐过程分析:
push ebp,将ebp压入栈区。因为esp维护栈顶,所以esp指向改变。我们可以观察其存放地址的改变。
之后执行move,move是把后面的值赋到前面去。
此时ebp和esp指向的位置相同。
之后,就要创建函数的栈帧了,执行sub,就是将其寄存器存放地址减去一个地址,因为栈区是高地址到底地址,所以该地址向上。
此时esp维护main函数的栈顶,ebp维护main函数的栈低。我们可以看内存:
这些内存都是为main函数开辟的空间。
之后有执行了3次push,push时会有一个动作,就是栈顶指针esp会变,一直指向栈顶。
我们通过内存窗口来观察: 因为我们说过,寄存器既可以存地址,也可以存数据,此时ebx存的是数据(就是内存里面的内容),而esp存放的是ebx的地址。压入ebx以后,继续将esi、edi压入。
之后执行lea : load effective address 加载有效地址。 此时会发现,正好是加载的空间正好是最开始esp减去的空间,就是main函数栈帧的低地址。
之后执行的命令,我们就需要先讲解一下了。
我们应该听过字节的概念,1字节等于8比特位,那么字和字节又什么关系?一个字等于两个字节。
比特记为bit,字节记为Byte,字记为word,所以有如下关系:
- 1Byte=8bits
- 1word=2Bytes=16bits
- dword:一个word是两个字节,d代表double,就是双字,就是4个字节。
此时我们要看其以下的三个步骤:
此时edi里面存放main函数栈帧的低地址。
这样就可以理解为什么每次打印未初始化的空间,打印出来的字符都是一个汉字“烫烫烫烫”了。
此时才会开始执行有效的代码。在此之前都是为main函数开辟的空间。
此时就要调用Add函数了,一样的,我们要改变ebp和esp的指向,因为进入Add函数就需要维护Add函数栈帧了,但是还是要做以下准备,就是传参,我们来看形式参数的创建。
这两个动作相当于传参,之后执行call,就是调用函数,要记住call的地址,此时点击F11才能进入Add函数。
我们可以发现就在ecx的下一个地址里面存储了call指令下一个执行的地址。为什么要记录地址?我们先埋个伏笔,此时我们会先进入Add函数,流程如下:
注意此时main函数的函数栈帧已经增长到call指令的下一个地址了。 此时我们来观察Add函数的细节:将esp减去一个地址改变指向:
之后还是main函数栈帧的那一套操作,压入3个寄存器并初始化空间,并将z初始化为0:
此时先将 ebp + 8 的值赋给 eax ,此时 eax = 10;之后又执行add,将 ebp + 12 的值等于30,最后将eax的值赋给 ebp - 8 ,此时 ebp - 8 地址的值是30. 我们可以发现,我们使用Add函数并没有创建形参,在我们传参时其实已经压栈过了,而且参数是从右向左传参的。
返回的话z会被销毁,我们来观察其如何返回。
我们将结果放入eax寄存器当中,此时就不用担心函数销毁。
此时将上面的3个寄存器弹出栈顶。
之后mov esp的位置,esp的指向改变: 此时弹出ebp,ebp弹出以后会指向main函数的栈低,因为之前记录着mian函数的栈低。
当前栈顶元素为call指令的下一个指令的地址,ret这条指令就是找到之前call指令记录的地址,并pop一次栈顶元素。
此时执行add esp,8 因为没有dword 所以是改变指向。
此时将形参x,y的空间还给操作系统。此时又执行mov,将eax存放的值赋给 ebp - 20h 就是给c赋值。
此时main函数执行完,也是以上步骤,我们不再赘述。
总结:
我们通过观察函数栈帧的创建和销毁,最后返回值是由寄存器带回来的;也可以理解为什么局部变量的值是随机的,形参和实参的关系,确实是一份临时拷贝。希望大家下去多加练习,逐渐就会顿悟其中的原理。
爆肝一整天,点点赞吧,呜呜~