在学习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函数相近。