前言
前期学习的时候,我们可能有许多困惑:
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数时怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
当你了解了函数栈帧的销毁与创建之后,以上这些问题你就可以想明白了。
函数栈帧的创建和初始化
进入正题:今天使用的环境是2013,不要使用太高级的编译器,因为越高等级的编译器,越不容易观察和实现。同时在不同的编译器下,函数调用的过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。
今天我们将会遇到如eax、ebx、ecx、edx、ebp、esp等这几个寄存器。它是独立于内存的,它集成到CPU上面。其中ebp、esp这两个寄存器很重要,因为它们是用来维护函数栈帧的。
每一次函数调用,都要在栈区创建一个空间,假如我们写上一个代码:
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("%d\n", c);
}
这个代码十分的简单,每一步执行的细节都给细化了出来,这样更方便我们观察。今天我们就以这个代码来理解函数的栈帧的创建与销毁。
每一次调用函数的过程中,编译器都要在内存中的栈区上为该函数开辟一块空间。我们称这一块空间为该函数的栈帧。
它是怎么被维护的呢?
它由两个寄存器来维护:ebp和esp。寄存器里面存储着地址,那么ebp就存储着栈帧的高地址,esp存储着低地址。正在调用哪个函数,它们两个就指向这块栈帧的两端。
在vs2013中,main函数也是被其他函数调用的,它是被__tmainCRTStartup这个函数调用。而__tmainCRTStartup函数又被mainCRTStartup这个函数调用。
我们按下f10,开始调试,鼠标右键点击转到反汇编按钮,我们就可以看到每一条语句对应的反汇编代码。
当程序进入main函数之后,编译器就开始为main函数创建栈帧。但此时ebp、esp还在维护着__tmainCRTStartup这个函数。通过一系列的汇编指令,就可以将两个寄存器移动到新的位置,开始维护main函数。下面我们就来分析一下以上这些汇编代码的含义。
第一条语句是push,意思是压栈,将ebp这个值压入栈中。
然后esp就不再指向原来的位置,它会向上走一格,它会始终指向栈顶。
打开监视:我们可以看到
走完这个push,发现它的值已经改变:
打开内存,我们就可以看到:
esp的值为008ffba4,这个地址对应的就正好是ebp的值008ffbf4。
接着来看下一条指令:
mov的作用是赋值,将esp赋给ebp。那就表示ebp现在指向的和esp指向的位置是同一个位置。
接着看下一条指令:
sub就是减的意思,将esp减去0E4h,0E4h是一个八进制数字,十进制是228。它减去了这个数字,它就会向上移动,指向上边的某一个位置。其实上面就是为main函数开辟的栈帧。
然后继续往下看汇编代码,我们遇到了三次push。编译器往栈里面压了三个元素:ebx、esi、edi。这三个值无关紧要。
再往下看汇编指令,我们遇到了lea,它的全名为load effective address,意思是加载有效地址,就是把[ebp-0E4h]这个值放到edi里面去。
再往下看汇编指令,我们遇到了两个mov,第一个mov是将39h放在ecx中去,第二个mov是将0CCCCCCCCh放在eax里面去。
而下面的一条汇编指令rep stos才是比较重要的,它的意思是从edi开始,往下39h个dword的内容改成cccccccc,而dword表示四个字节(word是两个字节),也就是往下多少行(这里的一行包括4个字节)的内容全部改成cccccccc。
当程序执行过后,再来看监视:
可以看到,有非常多的空间内容被改变了。也就是ebx以下,ebp以上的空间。
局部变量的创建
我们再来往下看:
第一个mov是将0Ah,也就是10(十进制)放到ebp-8这个位置
第二个mov是将14h,也就是20(十进制)放到ebp-14h这个位置
第二个mov是将0,也就是0(十进制)放到ebp-20h这个位置
当我们没有给变量初始化,那么里面的值就是cccccccc,这也就是为什么当程序出错时会打印出cccccccc或者“-52 -52 -52”等没用的数据。所谓初始化,就是将原来的数据释放掉。
变量间的间隔大小由编译器决定。不同的编译器会略有差异。
以上就是局部变量的创建过程。
函数的调用和形参的创建
之后程序就走到了函数调用部分。
第一条汇编指令时mov,把ebp-14h赋给eax中。而ebp-14h就是我们的变量b。
第二条指令时push,也就是压栈,将eax压入栈中。
第三条语句是将ebp-8的值赋给ecx,而ebp-8的值就是变量a的值。
第四条语句是将ecx压入栈中。
上边的四条指令其实做的操作是传参。它们都是形参。它们的传参顺序是从右向左传的,先传b,再传a。
然后下一条指令时call指令,是跳转指令,跳转至00C20E1地址处,同时向栈中压入call指令的下一条指令的地址。因为当call执行完后,要回到call指令的下一条指令处。编译器将这个地址记住,当call回来的时候就找到这个地址,继续往下执行。
执行完call指令后,程序就跳转到了Add函数里面。
而上图的这些汇编代码,跟main函数一样,是在为当前函数开辟栈帧用的。
第一条指令,是将ebp给压入栈顶。
第二条指令,是将esp赋给ebp。
第三条指令,是将esp减去一个值。
前三条指令执行完毕,栈帧就变成了这个样子:
下面又是三个push,后面四个也是初始化:
初始化完成,就开始创建局部变量:
mov指令是将0放进ebp-8这个位置当中。
下面就开始执行z = x + y 这条语句。x和y此时就在main函数的栈帧里面。
第一个mov是将ebp+8的值赋给eax。
第二个add是将ebp+0Ch,也就是20,加到eax里面去,就是30。
第三个mov是将eax放到ebp-8里面去。
函数的返回值和函数栈帧的销毁
接下来开始返回值。
第一个mov是将ebp-8赋给eax,而eax是寄存器,不会因为函数的退出而销毁,因此它能够保存返回值。
之后又三个pop,代表出栈。分别将edi、esi、ebx给出栈了。每出栈一次,esp就向下移动一格,始终指向栈顶。
然后mov操作,是将ebp赋给esp。ebp和esp不再维护Add的栈帧,Add函数栈帧销毁。
然后pop,将ebp弹出去并赋给ebp,ebp中存放着main函数的栈底指针,赋给ebp后ebp就指向了main函数的栈底。同时esp也指向了main函数的栈顶。重新维护main函数的栈帧。
ret指令就是退出Add函数并回到main函数中call指令的下一条指令,同时弹出这个地址。
当从Add函数中退出之后,程序回到了main函数。它应该从call指令的下一条指令开始执行。
当程序回来之后,此时esp指向栈顶,栈顶此时存着形参a和b,但此时它们已经没有用处了,于是便执行下一条指令。
这个add指令使esp加8,向下移动两格,也就是八个字节,将两个形参空间给释放掉了。继而证明了形参是实参的临时拷贝,随着函数的退出,形参的生命周期就结束了。
下面的一条mov指令,将eax赋给ebp-20h这个位置处。而这个位置处存储着c。所以c就存储着Add函数的返回值。以上就是main调用Add函数获得返回值的过程。