不同编译器的函数栈帧创建与销毁都略有差异
以vs2013为例
1.寄存器
eax
ebx
ecx
edx
函数栈帧:ebp,esp这2个寄存器中存放的是地址,这2个地址是用来维护函数栈帧的。那么怎么来保护的呢?
每一个函数调用,都要在栈区创造一个空间
//在vs2013中使用
#include
int Add(int x,int y)
{
int z=0;
z=x+y;
return z;
}
int main()
{
int a=0;
int b=20;
int c=0;
c=Add(a,b);
printf("%d\n",c);
return 0;
}
那么是如何维护的呢?不能说维护就维护
正在调用哪一块函数,esp和ebp就会维护那块函数的函数栈帧。
例如马上要调用Add函数,那么esp和ebp就会马上移动去维护Add的函数栈帧了,esp与ebp移动后之间的空间就是Add函数的这次调用所分配的空间。也就是本次函数的函数栈帧。
通常我们会叫成栈底指针:ebp 栈顶指针:esp
再使用空间时相当于在上面使用一块空间,就像栈一样,当我们放数据进去时是从上往下放数据,所以下方我们就称为栈底,上方就成为栈顶,那么ebp与esp也就可以分别成为栈底指针和栈顶指针。以后使用新空间时都是从esp(栈顶指针)往上使用的。
为仔细观察,我们正式打开vs2013
在vs2013内点击调试——窗口——调用堆栈
问题来了:main函数被谁调用?
一直按f10往下运行,直至运行完整个main函数,会跳出以下界面:
我们会发现,右边的调用堆栈框内出现了一个__tmainCRTStartup函数
在左侧我们是能找到这个__tmainCRTStartup函数的
是这个函数内部调用了我们的main函数,说明main函数也是被别的函数调用的。
而__tmainCRTStartup函数我们可以通过下图看到又是被另一个函数调用的
_tmainCRTStartup函数被mainCRTStartup函数调用后__tmainCRTStartup函数调用main函数,main函数就返回值return 0,返还给调用的那个函数
调用关系如下:
所以,我们在分配空间时,要为__tmainCRTStartup函数以及mainCRTStartup函数分配一块空间
下面开始具体研究是如何分配空间的(要用到汇编语言知识):
按下f10后右击鼠标——转入反汇编
此时就能在右边看到c语言对应的汇编代码:
此时先将右上方的显示符号名属性取消选择,
push压栈操作
已知在调用main函数前我们会调用一个__tmainCRTStartup函数,我们先假设如图开辟了一个空间:
push——压栈操作 :顾名思义,当我们创建完后ebp会进行压栈的操作,,也就是在__tmainCRTStartup函数上方开辟了一块新空间,放入此时ebp的值(也就是开始开辟的地址),
而我们都知道esp是维护栈顶的,那么此时esp会指向ebp上面
mov
mov的意思就是吧后面的值给到前面去
也就是将esp中的值赋给了ebp,相当于ebp去到了esp的位置上。
继续看下一条
sub
sub相当于减法,就是esp的值减去0E4h(八进制),减去后esp的值变小了,相当于在栈上esp没有在原来位置,而是指向上面的某一块位置,而此时的一块空间就是为main函数开辟的一块空间,而开辟的空间大小由编译器决定,那么此时如图所示:
然后来到了三次push代码:
三个push相当于向顶上压入了三个元素,ebx,esi,edi,压栈压进去了,每一次push压入应该元素进入栈后,esp的值会变,导致往上走
此时再往下走,到这一步
lea
lea:load effective address(加载有效地址),在这里就是将有效地址加载到edi内去
这里我们先重新将右上“显示符号名”属性勾选上
会发现此时的lea行的[ebp+FFFFFF1ch]变为了0E4h,可以发现与前面的进行sub(减去)操作时的0E4h值相同
进行此次操作就是让[ebp-0E4h]这个值放入edi内,然后通过观察我们可以发现,此时放入新值后的edi所指向的就是对应在进行push三个寄存器ebx、esi、edi操作前的esp的位置,如图:
那么找到这个地址又有什么用处嘞?
先接下来往下看汇编代码:
在执行完这些后我们就可以通过打开内存窗口看到有一段位置内的内容全部被初始化为了cc cc cc cc
表示成简易图解为:
push:压栈——给栈顶放元素进去
pop:出栈——把栈顶的元素删除
以上便是全部main函数的开辟~
终于,在main函数全部完成后,我们才开始看正式的c语言代码:
接下来看下一条代码:
看到了mov操作,把0Ah这个值放入到[ebp-8](地址)的位置上去
继续看回图解,我们知道每一块空间内对应四个字节
已知此时ebp位置的地址在这个位置:
那么[ebp-4]、[ebp-8]就对应着以下位置:
那么这段代码也就表示:
将上图所指向的位置内容改为0Ah这个数字,转换为十进制就是10。此时[ebp-8]内的内容就为10.
所以再次回想,当我们创造一个int变量却没有初始化时,是不是在使用vs编译器时运行用printf打印出来时总是会由一串乱的代码?其实那串乱的中文字就是cc cc cc cc。由此我们可知,初始化是十分重要的,不要忘掉啦!!!
接下来我们就可以类比这一段代码,继续往下解读下两行代码:
14h是十六进制的写法,转换为十进制就是20,对应着[ebp-8] , [ebp-14h]的对应地址比[ebp-8]少12,也就是[ebp-8]上方的第三个元素(12/4=3),也就是空了两个整形大小。
注意!!!在不同编译器下会有不同的效果,可能b的存放位置会不同。
接下来的20h与14h又相差了12,也就是c变量会在b变量位置往上的第三个元素内初始化为0。
以上,我们才算把int a=10;int b=20;int c=0;这三行代码的汇编语言解读完成。
终于到了Add函数阶段
有了之前的学习解读汇编语言,我们可以知道,第一行的代码意思是将[ebp-14h](此时也就是b)这个地址里存放的值,放入eax这个寄存器内。
再下一行的push操作,也就是压栈操作,将eax放入栈中,同时esp移动到栈顶。
再到下两行,同理将a的值放在了ecx这个寄存器中,并将ecx进行压栈操作,esp再次去到栈顶。
这样的操作便被称为传参
接着向下看汇编语言:
call:调用函数 call的这一条指令就是调用函数
要注意:call这个操作一定要先记得左侧的地址先
当我们按下f10一直跳转到call这一条时,先不要心急按下f10,观察两边的框内参数:
按下f11后,我们会发现:
左框变为了以下的样子:
而右框中的内存栏有一行被转换了,我们还能发现放在了b和a的上方:
对比后我们可以发现,这个被放入新地址值居然对应的是call指令的下一行指令add的地址
存放call下一行的地址的用处:call一调用就马上跳到了add这个函数上,add这个函数调用完后要回去,于是就要存放这个地址,以便之后调用完add函数执行完后要从add继续往下执行,所以此时把call指令的下一条指令地址给记住,等到用完后回来就可以找到这个地址,再从这个地址继续往下执行。
在走到add后,按下f11,左框内出现以下界面,才算真正进入了Add函数内:
我们在学习了前面的汇编语言后,能够较简单地理解上图中的汇编语言了。
其中如图所选框内的汇编代码,依然是为我们的Add函数创建栈帧:
开始读第一行代码(push ebp):
我们可以从之前的图解得知:ebp此时还在维护main函数,我们使用了push操作,就是将main函数的ebp重新放入栈顶,其地址就在存放call中下一条指令的地址上方。
第二行:
就是将esp里的地址放入到ebp内,就是让ebp取代了esp的位置。
第三行:
让esp再往上走若干个元素(不同编译器给的内存空间大小不同),
以上操作就是再为Add函数分配内存空间。
第四、五、六行:
同之前步骤相仿,放入三个寄存器,
最后几步:
就是将里面的元素全部初始化为cccccccc这个值。
初始化便完成了,我们能发现其实与main函数的初始化没有太多的区别。
继续解读下面代码:
第一行是让[ebp-8]位置上的值变为0,如图解:
接下来要执行c语言中z=x+y的语句,但是我们解读了很久,仍然没有看到有x和y出现的身影,继续仔细读下面代码:
通过观察,我们可以知道[ebp+8]和[ebp+0Ch]两个地址中分别对应了10和20这两个值,也就是a,b的两个值
在这里我们先将两个命名为a'和b'
我们在进行完操作后所得的eax中存放了值30,最后再将eax中的值mov,移动到[ebp-8]这个位置上。
而此时我们知道,[ebp-8]对应的位置就是z的位置,就是返还[ebp-8]上的值,此时为30,也就是放到了30在z里面。
所以我们可以知道,实参是从右往左进栈,形参根本上是没有在函数内部创建的,而是在外部完成了使用计算,再传值进入函数内部,而此时两个寄存器的可以在这里被当作x和y
所以,形参是实参的完全拷贝,这句话是非常正确精准滴。
知道算出z后,我们要怎么返回呢?
现在看到最后一部分代码:
看第一行代码:把此时[ebp-8]里面的值放入到eax中,放入eax寄存器中的原因是,当我们调用完函数后,里面的大小会被销毁,而eax是寄存器,里面的值不会被销毁,放在eax里面就没有被销毁的风险。
接下来的三个pop是弹出,每弹出一个esp都要++(每一次+4),最后变成如图解所示:
当我们将值都push出去后,这个函数就没有存在的必要了,于是运行下两行代码:
第一行就是将ebp的值放入到esp中,也就是将esp放到ebp的位置,此时esp与ebp位置重合,而运行第二行代码时,我们将ebp踢出,我们pop的东西是原来指向ebp的地址,所以pop后ebp也就回到了原来的位置,main函数的栈帧又开始由esp和ebp来维护了。
到最后的ret指令:
我们知道,当运行完call指令后会跳转到下面的代码继续执行,这个时候就可以知道当时存的call指令下一条指令地址的用处了,而ret就是让其退出后执行这一操作的代码。
ret执行完后会pop,于是esp又会+4,向下移动,如图:
此时指向了x,y的位置
继续看回到call下一条代码:
我们此时不再需要x和y,于是执行以下一条代码:
就是让esp向下移动,不包含x和y了,此时x和y就销毁了。
再执行下一条:
将eax里面的值(就是之前算出的30),mov放入到[ebp-20h]的位置里(也就是放入c中)。
接下来程序运行完后就是main函数的销毁,与之前Add函数销毁步骤大致相同,就不再赘述了。
在我们学习完后,要问自己以下几个问题,来考验是否真的明白了,快来试试看能不能答出吧~
注意!!!
寄存器不在main函数内,寄存器是独立的,是集成到cpu上的。