前言
大家平时学习C语言粗粮可能吃的有点多了,今天带着各位读者吃一些细糠。
想必大家在学习C语言之初,内心中一定有不少的困惑,如:
- 局部变量是如何创建的?
- 为什么局部变量的值是随机值?
- 函数是如何传参的?传参的顺序什么?
- 形参和实参之间的关系
- 函数调用是怎么做的?
- 函数调用结束后是如何返回的?
我会针对以上的问题,带着大家踏上寻求知识的旅途——讲解函数栈帧的创建和销毁。
温馨提示:本次讲解所采用的编程环境是VS2013 。如果大家想自行测试的话,建议不要使用过高版本的编译器,因为高版本编译器会对函数栈帧的创建和销毁的过程封装得比较复杂,不易于观察和学习。另外,不同的编译器对于函数栈帧创建和销毁有所不同,但是都大同小异,希望大家在自行学习时牢记这一点。
1. 寄存器
这里我只会笼统的给大家聊一下寄存器的作用,而不会深入的探讨,毕竟有这些知识就足够用了。
现在的寄存器大多出现在CPU中,其作用,就是用来暂存ALU(算数逻辑单元)、CU(控制单元)等的数据。等到CPU接受到某一条相关的指令时,就会从寄存器提出数据出来使用。
在本文中,你可以将寄存器粗略地看成C语言中的指针和能够存储值的硬件。
下面,我列出几个大家在编程中常常接触到的寄存器:
eax
ebx
ecx
…下面两个寄存器时本文的主角:
ebp(栈底指针)
esp(栈顶指针)
这两个寄存器就是专门用来维护函数栈帧的。
2. main其实也是被别的函数给调用的
我们平时写C语言代码时,在写完头文件时,紧接着就是写main函数。我们知道main函数是程序的入口,有且仅有一个。
可是对于main函数也是被其他函数调用的,这件事情我们好像没有感知。但是,它确实是存在的。
在VS2013中,main函数是被__tmainCRTStartup函数调用,而__tmainCRTStartup函数是被mainStartup函数所调用,总结一幅图就是:
为什么要提这件事?
原因是要我们更深刻的理解代码在底层中的运行,以及说明一件事:
每当我们调用一个函数,编译器都会为这次函数调用创建一个函数栈帧。
请大家务必记住这个知识点!!!
3. 函数栈帧创建与销毁的全过程
为了使过程观察得更加明显,我会把代码尽可能的详细写出:
#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;
}
碍于篇幅原因,我将会采取部分讲解的方式。
下面是这个部分代码所对应的汇编代码:
在未执行上面汇编指令时,栈区的状况:
- push ebp
这条指令的被称为“压栈操作”,意思讲ebp寄存器的值压入栈中,之后让esp(栈顶指针)往地址处挪动一个格子(4个字节)。做了这两件事。
值得牢记的一点是:栈区空间的使用规则一般先使用高地址的空间,再使用低地址的空间。
画成图就是这样的:
2. mov ebp,esp
将esp的值给寄存器ebp。也就是说,经过这条指令过后,ebp(栈底指针)和esp(栈顶指针)都指向了同一块区域。
3. sub esp,0E4h
将esp寄存器里的值减去十六进制的0E4h。就是将esp指针向上挪动一定的距离。
从这里你可能会猜到,这会不会是正在给main函数的主体创建空间。没错!
4. push ebx / push esi / push edi
这三条指令放在一起讲,跟第一条指令一样的效果。
- lea edi,[ebp - 0E4h]
lea:load effective address,意思:加载有效地址
就是将ebp-0E4h的值存放在寄存器edi中。
- move ecx ,39h
move eax 0CCCCCCCCh
rep stos dword ptr es:[edi]
这三条指令你可以看作是一体的,它们都在做着一件事:
将eax寄存器里面的内容,也就是0CCCCCCCCh。以4字节的形式,从edi寄存器所记录的地址开始,一直拷贝到ebp寄存器所记录的地址,拷贝的次数就刚好是ecx寄存器里面的值(39h)。
这里值得注意的一点是:为什么是4个字节?
其实是这样的,一个word(字)为2个字节,而dword的意思是double word也就是双字,计算一下就为4个字节了。
直到这里我们发现,关于main函数的函数栈帧就创建完毕了!
太好了,我们终于要开始执行main函数里面的语句了。
8. 从这里开始,我们就要调用我们自定义Add函数了。
那肯定又要给我们的Add函数创建属于它自己的函数栈帧,具体是怎样的?我们往下看:
下面我就加快速度给大家讲解了,套路跟main函数差不太多!
先执行这些指令:
mov eax,dword ptr[ebp - 14h]
push eax
mov ecx dword ptr[ebp-8]
push ecx
看到这幅图,有了main函数带来的灵感,你会感觉到这是不是在给Add函数创建函数栈帧。我只能说,你的感觉没有一点毛病!!!👌👌👌
它就是在给Add函数创建属于它自己的函数栈帧。
而且再进一步看,这两个地址是不是很眼熟(esp-8 / esp-14h),它们两个地址对应的值就是main函数里面的局部变量a和b的值。也就是说现在进行的是传参的操作,可以理解为现在是在给形参x和y传值。
我们还能看到它的传递参数的顺序,是从右往左传递的。因为是y先接收到值。
- call 00C210E1
这条指令的功能十分重要,它为后面出了Add函数之后还能回到main函数内部埋下了伏笔。那具体它是怎样埋下这个伏笔的呢?接着往下看!
这条指令一共做了两件事情:
- 先将call指令的下一条指令的地址压入栈中,也就是将00C21450压入栈中;
- 接着就是跳转到call指令后面的地址处,也就是00C210E1,执行那个地址的代码。
- 开始为Add函数建立函数栈帧
执行下面代码:
push ebp
move ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
- 接下来就像main函数那样给栈空间填充内容
lea edi,[ebp+FFFFFF34h]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
执行上面四条指令,观察中间两条指令ebp+8和ebp+0Ch。
你会发现这两个地址的内容不正是main函数向Add函数传的参数x和y。
所以我们说"形参是实参的一份临时拷贝",这句话一点也没错。
13.
14. 紧接着就又回到了main函数中
那它是如何回到main函数中的呢?这就要提到我们埋下的伏笔了,还记得call指令嘛。
先执行下面的指令:
pop edi
pop esi
pop ebx
我拿其中一条指令讲解其功能:
pop edi,它的在做两件事:一是弹出edi,二是栈顶指针(esp)往下弄挪动4个字节。
14.
move esp,ebp
pop ebp
ret
这三条指令的执行,就宣告着Add的函数栈帧就被销毁了。
另外重点讲解一下,这条指令:
pop ebp
它和普通的pop指令不一样,因为它是栈底指针。不一样的地方就在于,待esp弹出后,它就会指向上一次ebp所记录的地址处。
15.
add esp,8
将esp里面的内容加8,也就是将esp向下挪动8个字节。
mov dword ptr[ebp-20h],eax
此时,main函数中的局部变量c已经成功地就接受到了Add函数的返回值。
下面的汇编指令就不再解释了,套路都是一样的!
好了,讲到这里,你已经充分的了解到函数栈帧是如何被创建和销毁的。那我们就来一个个解释开头我们提到过的问题。
- 局部变量是如何创建的?
通过寄存器指针偏移,从而在栈区中开辟属于局部变量的空间- 为什么局部变量的值是随机值?
在我们未给局部变量初始化时,局部变量的值就为0CCCCCCCCh。也可能是你们常见的烫烫烫烫。- 函数是如何传参的?传参的顺序什么?
通过寄存器保留传参的值。传参的顺序是从右往左传。- 形参和实参之间的关系。
形参是实参的一份临时拷贝。- 函数调用是怎么做的?
用call指令进入调用的函数。- 函数调用结束后是如何返回的?
再通过存储call指令的下一条指令的地址,从而找回到main函数中。在这个过程中还会用寄存器暂存返回值。
好了,到这里,函数栈帧的创建和销毁就全部讲完了。内容有点多,多看两遍就懂了。
最后,觉得本文写得还不错的话,不要忘记给偶点个赞吧!!!