相关疑问
在前期学习C语言的时候,我们可能会有很多的疑惑。
比如:
- 局部变量是如何创建的?
- 为什么局部变量的值是随机hi?
- 函数是怎么传参的?传参的顺序是怎么样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
那么上面的问题的答案是什么呢?博主通过函数栈帧的创建和销毁这篇博客来告诉你答案。进行讲解之前,我们需要了解一下前期知识。
预备知识
1.相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器(栈底指针),指向函数栈帧的底部
esp:栈顶寄存器(栈顶指针),指向函数栈帧的顶部
eip:指令寄存器,保存当前指令的下一条指令
ebp、esp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。每一个函数调用,都要在栈区创建一个空间。
2.相关汇编命令
mov:数据转移指令
push:数据压栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改 变
sub:减法命令
add:加法命令
call:函数调用,1.压入返回地址 2.传入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
lea:load effective address,加载有效地址
演示代码:
#define _CRT_SECURE_NO_WARNINGS 1 #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; }
上图就是整段代码的大概的流程图,我们可以注意到图中有两个我们不熟悉的函数:_tmainCRTStartup函数和mainCRTStartup函数。为什么会有这两个函数呢?其实是这样的。在VS中,mian函数是被_tmainCRTStartup函数调用的,而_tmainCRTStartup函数是被mainCRTStartup函数调用,mainCRTStartup函数是被操作系统调用的。因为被调用的函数都要在栈区开辟空间,所以上面就是这个函数的栈帧空间。
main函数栈帧的创建
写好我们的代码后,我们要按下Fn+F10键进入调试。进入调试后,右击鼠标点击转到反汇编。
然后就可以看到我们程序的汇编代码了,然后再将显示符号名取消勾选。
完成这些好,我们就来看我们的汇编代码了,同时通过按Fn+F10键来调试程序。需要注意的是,一开始ebp和esp维护的是_tmainCRTStartup函数的函数栈帧。
首先执行的是push ebp,这条指令的意思是将ebp压入栈顶,同时esp上移,esp的值减小,存放的是ebp的地址(可以通过监视和内存来观察)。下一条指令是mov ebp,esp,意思是将esp的值赋给ebp,那么现在ebp就指向了esp指向的位置。然后再执行sub esp,0E4h,让esp减去十六进制数字0E4,那么esp就指向了上面的空间了。目前ebp和esp就不在指向原来的位置了,而指向其他位置,而这两个位置之间的空间就是main函数的函数栈帧。
接下来的三条指令就是push push push,压栈三次,同时esp上移三次,esp的值减小三次。然后再执行lea edi,[edp-24h]指令,这条指令的意思是将地址edp-24加载到edi中去。
接下来的三条指令的意思是将edi以下的9个空间的值都赋为0CCCCCCCCh。(图中的dword的意思是4个字节)
执行完这些指令后,main函数的函数栈帧就已经开辟好了。
接下来就要执行我们写的代码了。
这三条指令的意思是相似的,是分别将0Ah、14h和0的值放到[ebp-8]、[ebp-14h]和[ebp-20h]中去,也就是分别为变量a、b、c开辟空间。
所以如果我们不给局部变量初始化的话,那么局部变量的值有可能是0CCCCCCCCh了。
Add函数栈帧的创建
现在a、b、c变量已经创建好了,那么就到了函数传参了。究竟是如何传参的呢?接下来我们一起来看一下。
讲下来的指令就是mov eax,dword ptr [ebp-14h]、push eax、mov ecx,dword ptr [ebp-8]和push ecx ,这几条指令的意思就是将[ebp-14h]和[ebp-8]中存放的值分别赋给eax和ecx,并它们压在栈顶。
其实这四条指令就在再给Add函数传参,不过我们现在还看不出来,我们接着往下走。
下一条指令是call 001310B4,这条指令是在调用函数。这时候我们要按下Fn+F11键进入函数内部。按下之后,我们来观察一下下面的图片。
我们可以发现esp指向的空间存放着call指令的下一条指令的地址,也就是说刚才我们按下Fn+F10键后,给栈顶压上了call指令的下一条指令的地址001318F7。为什么要这样做呢?这是为了调用完函数之后,能够返回到call指令的下一条指令继续执行代码。
再按下一次Fn+F10键就进入Add函数的内部了。
我们可以看到上图的汇编代码,好像是在开辟函数栈帧诶。对的,那些指令就是为Add函数开辟函数栈帧。为Add函数开辟函数栈帧跟为main函数开辟函数栈帧的思路一样,在这里就不带着大家分析了。Add函数的函数栈帧如图:
接下来就要进行我们的加法计算了,我们一起来看一下。
上图的汇编代码的意思是:将0存放到[ebp-8]的空间中去,然后将[ebp+8]存放的值(a=10)赋给eax,再然后给eax加上[ebp+0Ch]存放的值(b=20),此时eax的值为30,再将eax的值存放到[ebp-8]的空间中去,而[ebp-8]这个地址存放的就是z。最后执行return z语句,相应的汇编代码就是mov eax,dword ptr [ebp-8],将[ebp-8]存放的值赋给eax,现在eax的值就是30了。
其实我们可以发现,形参x、y在调用Add函数之前已经创建好了,而且是在main函数的函数栈帧中创建的。当Add函数的函数栈帧创建好后,再通过地址找到形参,将形参加好后,再赋给变量z。最后函数的返回值,也就是z的值存放在寄存器eax中,通过eax带回来。所以说,形参只是实参的一份临时拷贝,对形参的修改不会影响实参。
Add函数栈帧的销毁
现在已经完成了对形参x、y的相加了,那么接下来就要对Add函数的函数栈帧进行销毁,我们一起来分析一下函数栈帧是如何销毁。
首先是edi、esi和ebx分别pop出栈,然后esp下移。再然后就是mov esp,ebp,这条指令的意思就是将epb的值赋给esp,那么esp就指向了ebp指向的位置。接着就是pop ebp,将ebp弹回指定的位置,也是回到main函数栈帧的栈底,同时esp往下移,esp指向的空间存放的是call指令的下一条指令的地址。至此,Add函数的函数栈帧就已经被释放掉了,且回到main函数的函数栈帧了。
再然后执行ret语句,恢复返回地址,回到call指令的下一条指令中去。
回来之后,给esp加上8,那么esp就向下移,此时形参x和y的空间也销毁了。接下来的指令是,将eax的值放到[ebp-20h]的空间中去,而[ebp-20]这个空间存放的就是变量c,相当于将eax的值赋给c,那么Add函数的返回值就成功地带回来了。最后就是将c的值打印在屏幕上了。
下图里面的指令,就是销毁main函数的函数栈帧。和销毁Add函数的函数栈帧的过程是一样的,在此就不再赘述了。
以上就是函数栈帧的创建和销毁的全过程,是不是非常的巧妙和神奇?函数栈帧的创建和销毁是真正能够强化我们的内功的希望大家能够掌握。
看到这里,相信大家已经知道前面一些问题的答案了。
局部变量是如何创建的?
当main函数的函数栈帧创建好之后,然后在函数栈帧中给局部变量开辟一个空间。
为什么局部变量的值是随机值?
因为编译器会给函数栈帧里的空间放入随机值,而局部变量也是在函数栈帧中开辟空间的,所以局部变量的值是随机值。
函数是如何传参的?传参的顺序是怎么样的?
将b和a的值分别存在寄存器eax、ecx,然后把eax和ecx压在栈顶。再通过地址找到eax和ecx,也就是形参,将形参加起来,就这样完成了函数的传参。传参的顺序是从右往左传。
形参和实参是什么什么关系?
形参和实参的值是相同的,空间上是独立的,形参只是实参的一份临时拷贝,对形参的修改不会影响实参。
函数调用是怎么做的呢?
通过esp和ebp的移动来为调用的函数开辟函数栈帧,然后再将执行函数里的代码。
函数调用结束后是怎么返回的?
在调用函数之前,就把call指令的下一条指令的地址压在栈顶了,再将main函数的ebp也进行压栈。调用完函数之后,pop main函数的ebp,ebp就会重新指向main函数的栈底。然后通过call指令的下一条指令的地址找回到call的下一条指令,继续往后执行,至此被调用函数的函数栈帧就被销毁了,通过函数的返回值也通过寄存器eax带回来了。
函数栈帧的创建和销毁示意图:
以上就是函数栈帧的创建和销毁的全部内容了,如果大家觉得有收获的话,可以给个三连支持一下!谢谢大家!💕💕💕
结语
💪💪💪每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根。💪💪💪