目录
在C语言的学习中,函数章节会给很多人带来疑惑:
局部变量是怎么创建的?
如何理解形参是实参的一份临时拷贝?
函数是怎么调用的?
......
而这些问题都指向了同一个知识点:函数栈帧
背景介绍
我们在调用函数的时候,都会在内存里的栈区上开辟一块空间,而这块空间,我们就称之为是该函数的函数栈帧。如下,假设我们有一个函数叫Add,那么如图所示的空间就叫做Add函数的函数栈帧
而在正式开始介绍之前,我们还需了解两个主要的寄存器:esp 和 ebp,这两个寄存器中放的是地址,而这两个地址是用来维护函数栈帧的。
我们这么来理解,一个函数的创建需要在内存上面开辟空间,而这块空间就是由esp和ebp来共同维护的,两者中间的那块空间就是正在执行的函数的函数栈帧。而esp又被称为栈顶指针,ebp又被称为栈底指针
main函数内部
注:不同的编辑器底下出现的效果也不同,此处使用的是VS2013
我们先来看这么一段代码:
int Add(int a, int b)
{
int z = 0;
z = a + b;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
这是一段非常基础的代码,我们将其拆分得足够细以便于我们的讲解
在正式开始讲解之前,我们先来看这么一个东西
我们可以看到,main函数也是被函数调用的函数,而这个调用main函数的函数在这个编辑器底下的名字是__tmainCRTStartup,这个知道就行,与我们今天的主题并无太大关联
接下来,我们就来仔细研究一下函数栈帧
我们要进入研究,只需要通过F10进入调试,随后右击鼠标转到反汇编代码就可以进行查看,如下
首先,我们在调用main函数之前需要先调用__tmainCRTStartup函数,也就是需要先为该函数在内存上开辟一块空间,如下
我们来看看下一步是怎么进行的
先是push了一个ebp在上面,push就是压栈,相对的,pop就是出栈。如下(此处__tmainCRTStartup的函数栈帧缩写为M,下同)
而接下来是move ebp,esp 这句话的意思就是:将esp赋给ebp,在这一步之后,ebp和esp将指向同一个位置
在往下看: sub esp,0E4h 这句话的意思是令esp向上走0E4(sub是减,而内存中下面是高地址,越往上地址越小), 这一步可以理解为正式在为main函数开辟空间了,如下
之后我们会看到三个连续的push:
因为与我们今天的主题无太大关系,所以这三步我们不做讨论,只需要知道压栈压了三个东西上去就行了
接下来是这四步
我们可以看到,其先是将ebp-0E4h的地址放进edi里面去了(lea的全称是load effective address,也就是加载有效地址),而ebp-0E4h是什么?不就是main函数的函数栈帧最上方的地址吗,也就是在连续三次push之前esp所指向的位置的地址
再来看,接下来的三句是先把39h放进ecx里,再把0cccccccch放进eax里面去,而最终产生效果的是最后一句话,这句话是什么意思呢:
把从edi指向的位置开始的,向下39h次的dword的内容全部改成0cccccccch
edi指向的位置我们刚才说过了,就是ebp-0E4h,word是两个字节,而dword中的d代表的是double,也就是四个字节。这句话我们可以这么理解:就是把main函数的函数栈帧全部初始化为0cccccccch,如下
到了这一步,我们才算是正式进入函数内部,前面的都是main函数的相关步骤
接下来我们一起来看看下一步是什么:
在此处我们会发现有符号我们不好观察,那我们就右击鼠标将显示符号名给去掉,接下来我们就会得到下图:
第一条汇编指令是 move dword ptr [ ebp - 8 ],0Ah 也就是将0Ah放进ebp-8这个位置里,而0Ah是一个16进制数,翻译成10进制就是10,简单来理解就是将a放进main函数的函数栈帧里
接下来的 b 和 c 也是一样的,如下图
由此,我们的局部变量是怎么创建的呢?我们先在内存上为该函数开辟一块函数栈帧,随后将局部变量放进该函数的函数栈帧内,这,就是局部变量的创建方式
Add函数内部
进行完这三步,我们再往下看:
我们先来仔细看看前四步:
我们会看到,move是先将ebp-14h这个地方里的东西放到eax里面去,随后将eax给push(压栈)到上面,而ebp-14h是什么?不就是 b 吗!
再往下看,将ebp-8给放到ecx里面去,然后将ecx给push(压栈)到上面,而ebp-8是什么?不就是 a 吗!
这么一看,我们分别将 b 和 a 的值压栈压在了上面,这可不就是形参吗!所以形参是实参的一份临时拷贝这句话有没有错?一点问题没有,如下:
这四步执行完之后我们会看到一个call,这个call会将这条指令的下一条指令的地址压栈压到上面,并且跳到函数内部
如上图,其会将00c21450压栈压到上面,并且跳转到Add函数内部(跳到00c210E1处),当我们执行完Add函数内部的指令之后,通过pop(出栈)00c21450这个地址我们就能找回来,如下图
当我们执行完call之后,我们需要按F11才能进入函数内部,如下:
相信看到这里的时候,你会发现:前几步和main函数的一模一样。先push一个ebp
注意,此时的ebp指向main函数下方,在此处push的作用是pop(出栈)后ebp能找回main函数
再将esp的值赋给ebp使其都指向当前esp的位置,随后将esp向上移动0cch个距离,相当于是给Add函数在栈上开辟了一块空间。接着push三个寄存器ebx、esi、edi,最后将整个Add函数的函数栈帧初始化为0cccccccch,如下:
当我们为Add函数开辟完空间之后,就该开始执行Add函数内的代码了
如上,其先是在ebp-8的位置放了一个 z,z 的值为0。
接着,(mov)将ebp+8内的数字放进寄存器eax中,(add)将ebp+0ch内的值与eax内的值相加,(mov)最后将eax内的值放到ebp-8的位置上
我们来仔细看看,ebp+8不就是a的形参吗?ebp+0ch(0c就是十进制的12)不就是b的形参吗?我们将其放进寄存器eax中进行相加,最后将算出来的值放进ebp-8也就是 z 里面。
我们发现,整个过程与main函数内的变量a、b是完全无关的,这也就解释了为什么当我们传值调用时,改变形参并不影响实参
接着我们看到 return z 的部分
这句话的意思是:(mov)将ebp-8(也就是z)位置内的数赋值给寄存器eax,放到寄存器后就安全了,即使函数被销毁了,这个数字也能被保留在寄存器中
注意注意!!接下来的部分相当重要!!
我们会看到这里先是连续三次的pop(出栈),(mov)接着将ebp的值赋给esp,相当于是让esp向下移动至ebp的位置,随后pop一下ebp,注意,此处的ebp内放的是什么?放的是指向main函数下方的地址啊!如果我们此时将其pop一下的话,就相当于让ebp从Add函数下方指向了main函数的下方,同时也相当于把Add函数的函数栈帧给销毁了。如下(虚线代表已被销毁):
然后就是ret,它代表的就是要回去,回哪里去?我们在创建Add函数的函数栈帧之前,除了压了一个ebp之外,我们是不是还压了一个call指令的下一个指令的地址啊,如上是00c21450,当我们ret之后,就会找到这个地址并执行这个处在这个地址的指令。如下,指向的是add:
也就是说,ret之后就从Add函数内部回到call的下一条指令:add
这条语句的意思是:将esp的数值加8,而我们又知道在内存中,高地址在下,低地址在上,所以简单点理解就是:将esp向下移动8。而esp+8之后也就意味着两个形参没有被维护了,所以就会被销毁。
综上,这一步就相当于是销毁两个形参。这也能说明为什么改变形参不影响实参,因为形参是自己开辟了一块空间且会被销毁,所以指向形参的指针也会变成野指针。
那有人可能就会问了:z不也被销毁了吗?那我们怎么接收函数的返回值啊?别忘了,我们在销毁 z 之前已经将存在 z 的值给存在eax这个寄存器上了,接往下看看编辑器是怎么做的
这条指令代表的是将eax中的值赋值到ebp-20h这个地址处,而ebp-20h就是 c 的地址。也就是说,我们将存在寄存器中的值放到了 c 里面去,寄存器又不会随着 z 的销毁而销毁,c 就是这样接收到Add函数的返回值的
至此,我们关于函数栈帧的创建于销毁就讲完了
结语
学完了函数栈帧之后,我们对函数这个章节的了解会来到一个全新的高度。作为示范,今天讲的只是一个简单的Add函数的函数栈帧,如果各位有兴趣的话可以在自己的编辑器上面试一试,但是请注意!!此处的环境是VS2013,不同编辑器的反汇编代码也有所不同,望周知。
另
祝我今天18岁生日快乐!!!
THANKS TO MY FAMILY AND MY PRINCESS ! ! !
—— 11.17