函数栈帧的创建和销毁

    在学习C语言的时候,我们可能有很多困惑。比如,为什么在改变形参的时候,实参的值不会跟着改变?为什么局部变量未初始化的时候,它的值是随机值?局部变量是怎么创建的?函数调用是怎么实现的,调用结束后又是怎么返回的?今天就和大家谈谈函数栈帧相关的知识。上面的疑问,或许可以在这篇博客中找到答案。

    今天使用的编译器为VS2017,虽然函数栈帧的创建在不同编译器中有所不同,但是大体上是相近的。

    我们首先了解一下寄存器。寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。而今天的重点是ebp和esp这两种寄存器,这两个寄存器中存放的是地址,而这两个地址是用来维护函数栈帧的。所以,这两个寄存器与函数栈帧的关系十分密切。

    这里我们举一个简单的例子来说明函数栈帧是如何创建与销毁的。我们构造一个Add函数,并返回两个形参的和。

#include <stdio.h>
int Add(int x, int y)
{
	int z = 0;
	z = x + y;
	return z;
}
int main()
{
	int a = 1;
	int b = 2;
	int c = 0;
	c=Add(a, b);
	printf("%d", c);
	return 0;
}

    我们知道,每一次函数调用,都要在栈区中开辟一个空间。其实main函数,也是被调用的,所以在内存中,main函数也有自己的空间。而这个空间,就由esp和ebp来维护。那么它们是怎么做到的呢? 在下面的图中,可以看到,ebp所存的地址指向了main函数的最高地址处,而esp指向了最低地址处,我们知道在栈区中,空间的使用是从高地址处向低地址处的。所以我们形象的称呼ebp为栈底指针,esp为栈顶指针。而每次调用函数的时候,esp和ebp也会随之改变,去维护所调用的函数。

     接着,我们按f10进入调试,并且打开反汇编窗口。

    那么,在调用了main函数之后,到底发生了什么呢?我们从头说起。

创建main函数的栈帧

第一步

在main函数被调用之前,调用main函数的函数栈帧是这样。

然后就来到了第一行语句 

01274E30  push        ebp

    这里的push是指压栈操作。我们知道main函数实际上也被调用了,那么第一行执行后会在栈顶压上一个值为ebp的空间。之后,esp也会指向顶部。

 

在内存里我们也能很好地的看到,esp所指向地址处存的就是ebp所存的值。

第二步

01274E31  mov         ebp,esp

 这里的mov(move)操作是将esp所存的地址赋值给ebp,那么这时候ebp所指向的空间就和esp相同了。

在内存中我们也能很好的观察到。 

 

 

第三步

01274E33  sub         esp,0E4h

这里的sub是指减法,这一行的意思是将esp所存的值减去0E4h。那么栈顶就会向上移动,而移动过的空间就是为mian函数所开辟的空间了。

第四步

01274E39  push        ebx  
01274E3A  push        esi  
01274E3B  push        edi

 我们可以看到这里push了三个元素,就是将ebx,esi,edi这三个元素依次压到栈顶。并且esp也不断上移。

 第五步

01274E3C  lea         edi,[ebp-0E4h]  
01274E42  mov         ecx,39h  
01274E47  mov         eax,0CCCCCCCCh  
01274E4C  rep stos    dword ptr es:[edi]  

 第一行这里的lea是指load effective address,这一行的意思是将有效的地址加载到edi。ebp-0E4h就是从ebp上移0E4h,所以edi最终实际保存的是main函数的栈帧顶部。

而接下来两行的操作就是将39h赋值给ecx,0CCCCCCCCh赋值给eax。

第四行,dword的意思是double word。而word是指两个字节,dword就是四个字节。这一行的意思是从edi向下的39h个的四个字节初始化为0CCCCCCCCh。

 

查看内存,可以看到main函数空间内的所有内容都被初始化为了CCCCCCCC。

所以这一步的意义就是将main函数内容初始化为CCCCCCCC。

 执行main函数内部的语句

int a = 1;
01274E58  mov         dword ptr [ebp-8],1  
	int b = 2;
01274E5F  mov         dword ptr [ebp-14h],2  
	int c = 0;
01274E66  mov         dword ptr [ebp-20h],0  
	c=Add(a, b);

我们知道mov可以赋值,上面的三句便是将1 2 0分别存入ebp-8 ebp-14h以及ebp-20h的地址处。也就是将对应的变量初始化。而14h其实对应了16进制的0x14,20h对应了0x20。而我们平常打印出错时打印的“烫烫烫”其实就是因为打印了没有被初始化的内容(“烫”字的汉字编码就是0xCC)。

此时我们查看内存也可以看到a,b,c变量的内容被修改为了1,2,0。

 

 

 调用Add函数

c=Add(a, b);
01274E6D  mov         eax,dword ptr [ebp-14h]  
01274E70  push        eax  
01274E71  mov         ecx,dword ptr [ebp-8]  
01274E74  push        ecx  

这四句根据我们上面的解释,可以知道,这是将ebp-14h处(其实是存放b变量的值的地址)的值赋值给eax,再将eax压栈到栈顶。并且将ebp-8处(其实是存放a变量的值的地址)的值赋值给ecx,再将ecx压栈到栈顶。

 

 

至于为什么要将a,b的值传入ecx,eax并且压栈,我们后面会揭晓。

现在,我们 看向下面两行。

01274E75  call        01271393  
01274E7A  add         esp,8  

 实际上call指令就是在调用Add函数,那么call指令做了些什么呢?

可以看到,在ecx,eax上又进行了压栈,这次放入的数据是下一条指令的地址,这样我们在调用完函数之后,可以通过这个地址,返回main函数。 

 

 接下来Add内进行的操作和main函数执行的相似,我们再进行一遍。

01272680  push        ebp  
01272681  mov         ebp,esp  
01272683  sub         esp,0CCh  
01272689  push        ebx  
0127268A  push        esi  
0127268B  push        edi  
0127268C  lea         edi,[ebp-0CCh]  
01272692  mov         ecx,33h  
01272697  mov         eax,0CCCCCCCCh  
0127269C  rep stos    dword ptr es:[edi]

首先,我们将main函数的ebp值压栈到顶部。再将此时esp的值赋给ebp。然后esp保存的地址减去0CCh,为Add函数开辟空间。然后依次将ebx,esi,edi三个值进行压栈操作。最后对ebp-0CCh下方的33h个的四个字节的空间初始化为CCCCCCCC。

最终我们对Add函数完成了初始化 。

int z = 0;
012726A8  mov         dword ptr [ebp-8],0  

通过编译语言可以看出,这一行表示将0赋值给ebp-8位置的四个字节的空间,实际上这就是在给创建的变量z初始化赋值。

 

 

z = x + y;
012726AF  mov         eax,dword ptr [ebp+8]  
012726B2  add         eax,dword ptr [ebp+0Ch]  
012726B5  mov         dword ptr [ebp-8],eax

 接下来我们可以看到,在加法的执行命令中,先将ebp+8地址处的值赋给eax。再让eax增加ebp+0Ch地址处的值,最后将eax的值赋给ebp-8处的四个字节。

那么,ebp+8,ebp+0Ch(0Ch即为十六进制的C,即十进制的12)都分别代表了什么呢?

 通过查看堆栈图我们可以看到,实际上这两个地址所指向的就是我们刚刚存好的ecx以及eax,也就是main函数里传入的参数a与b的值。可以看出,实际上在函数内部使用形参的时候,函数调用的是一份来自实参的临时拷贝。所以我们修改形参的时候,实际上是修改了这份临时拷贝的内容,而不是修改了实参的内容。所以在改变形参的时候,实参的值不会跟着改变。这也解释了为什么要将把这两个值进行压栈操作。

所以上面的语句,就是利用eax寄存器储存x加y的值,将二者的和赋值给z,也就完成了我们的加法与赋值操作。

return z;
012726B8  mov         eax,dword ptr [ebp-8]  

 最后,将ebp-8,即z地址处的内容赋值给eax,这样eax就可以保存需要返回的值,并且返回至main函数。

函数栈帧的销毁

012726BB  pop         edi  
012726BC  pop         esi  
012726BD  pop         ebx  
 

第一行的操作,pop edi是指将栈顶元素保存至edi中并且弹出,此时esp会随着语句执行向下移动。接着再进行两次pop操作。

 

012726BE  add         esp,0CCh   
012726CB  mov         esp,ebp  
012726CD  pop         ebp  
012726CE  ret  

这里我们可以看到esp增加了0CCh,上文可以看到,我们给Add函数开辟的空间大小就是0CCh。所以此时esp和ebp指向同一块空间。此时,Add函数所使用的空间被释放。之后,ebp的值赋值给esp,此时ebp指向的地址是我们进行压栈操作时压入的main函数的ebp值。之后,我们进行pop操作,将main函数的ebp值赋给ebp。这样,ebp就会指向main的栈底,而esp向下移动,指向main函数的栈顶。

 我们将Add函数的栈帧销毁了,但是我们接下来应该从哪里继续执行呢?可以看到最后一行还有一条ret指令,还记得我们在压栈操作时压入了call指令下一条指令的地址吗?在执行ret指令时,我们会利用这个地址,找到执行Add函数之后应该继续执行的语句,并且将该空间弹出。这样,我们就可以在调用完Add函数之后,返回main函数继续执行操作了。

但是我们可以观察到,两个形参仍然保存在栈区。难道main函数在进行调用后形参会在栈区保留吗?我们看到接下来的语句。

01274E74  push        ecx  
01274E75  call        01271393  
01274E7A  add         esp,8  
01274E7D  mov         dword ptr [ebp-20h],eax

 可以看到,在call指令的下一行,也就是执行完Add函数之后,将esp的值加8,实际上就是释放了两个形参所占用的空间。

这样,esp就重新指向了main函数的栈顶。

最后一行语句,是将eax保存的值赋给ebp-20h处的空间。我们知道,ebp-20h处保存的就是c的值,所以这一条语句是在将返回值赋给c。 

至此,Add函数的函数栈帧的创建与销毁就介绍完毕了。对于main函数,就不做赘述,具体过程与Add函数相近。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值