问题的引进
在初步学习了C语言,尤其是C语言的函数之后,我们通常会有一些问题无法理解,比如:
局部变量是怎么创建的?
为什么局部变量的值不初始化是随机值?
函数是怎么传参的?传参的顺序是什么样的?
形参和实参是什么关系?
函数调用的过程是怎么样的?
函数调用结束后是怎么返回的?
在这一篇文章中,我们就来简单分析一下函数的调用和销毁的过程,相信在分析完这个过程后,什么的问题也基本上都有了答案。
函数栈帧
首先声明一下,在分析函数栈帧的时候,不要用太高级的编译器,越高级的编译器我们越不容易观察和学习这个过程,同时,我在写这篇文章的时候用的是VS2022,因为我平常写代码就是用的这个版本,不同的编译器观察到的函数调用时创建栈帧的过程也不愿意,在这里仅以我使用的编译器为例来分析。
栈底指针、栈顶指针
在讲栈帧之前,我们先来了解一下几个寄存器,eax、ebx、ecx、edx、esp和ebp,重点关注esp和edp,在这两个寄存器中存放的时两个地址,这两个地址是用来维护函数栈帧的。
在函数调用到我们的main函数时,会在栈区为main函数开辟一段内存,这段内存就是main函数的栈帧,函数的栈帧是由esp和ebp这两个寄存器维护的,他们存放了函数栈帧两端的地址。如下图
我们知道,栈区的使用是先使用高地址,再使用低地址。在上图中,ebp存的是main函数开始的指针,叫做栈底指针,esp叫做栈顶指针,他们存放的是main函数栈帧的起始和结束的地址。
注意,当程序调用到哪个函数时,ebp和esp维护的就是哪个函数的栈帧。
同时,给函数栈顶放一个有元素的操作叫做压栈(push),它是在函数栈帧外的下一个空间放一个元素,这时候esp也会变成指向这个元素地址,函数栈帧变大了。
从栈顶弹出一个元素的操作叫做出栈(pop),他是弹出esp所指的那个元素,同时把它的值放到pop接的变量中去,这时候esp所指向的地址也会变化。
栈帧的创建
我们使用一个简单Add的函数来进行分析,函数代码如下:
#include<stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
return 0;
}
当我们ctrl+f10进入调试后,可以先打开调试窗口的反汇编,内存方便我们观察(注:在调试过程中我可能会多次返回上面的代码重新调试,所以图中寄存器的位置可能不统一,我们主要关注它指向的地址之间的关系)。我们要了解到,main函数其实也是被调用的函数,我们这里从main函数创建栈帧开始分析。
如图:
下面是我简单画的图以便理解
当我们执行反汇编中main下面第一行 push ebp时,ebp压栈,ebp指向的就是原来esp指向的位置了,此时esp指向的位置如图:
下一步操作mov ebp esp则是把esp的值赋给ebp,这时候ebp也指向了上图中esp的位置,因为esp和ebp存的都是地址,赋值之后他们所指向的空间是同一块。
再下一步sub esp,0E4h则是把esp的值减了0E4h(0E4h是一个十六进制数字)个内存,也就是esp移到了上面的距离ebp 0E4h个字节的地方,这个数是操作系统为函数预开辟的空间,是操作系统决定的。
接下来是三个push操作,将ebx,ebi和edi压栈,与此同时,esp也变成了指向edi的地址。
下一行lea edi,[ebp-24h],这是将ebp-24h这个地址载入到edi中;
mov ecx,9 是把ecx赋值为9;
mov ecx,0CCCCCCCCh是把ecx赋值为0CCCCCCCCh;
这三行代码这时候我们看不懂这样做的意义没关系,下一行代码就用到了这三个变量。
rep stos dword ptr es:[edi] ; 这一行代码的意思是把从edi(刚刚赋值为ebp-24h)开始的9个双字(4个字节)大小的空间内容全部改为0CCCCCCCCh;
效果如图:
下面一行是mov ecx,0DCC008h是把后面这个地址赋值给ecx,这里应该存的是上个函数的一个地址,当我们调用完main函数之后会通过这个地址返回上一级函数。再下一行则是调用main函数(call:调用)。
main函数中变量的创建
mov dword ptr [ebp-8],0Ah 这一行代码是将ebp-8的地址开始的四个字节赋值为0Ah(a)
mov dword ptr [ebp-14h],14h 这一行是将ebp-14h开始的四个字节赋值为14h(b)
mov dword ptr [ebp-20h],0 这一行是将ebp-20h开始的四个字节的内容赋值为0;
上面这三行就是变量的初始化。
mov eax,dword ptr [ebp-14h] 这里是将eax赋值为ebp-14h里面的内容(也就是b)
push eax eax压栈
mov ecx,dword ptr [ebp-8] 将ecx赋值为ebp-8里面的内容(a)
push ecx ecx压栈
然后是call指令,调用函数,但是在调用函数之前,我们先截个图留着一会用,注意观察call指令的下一条指令的地址
然后按f11进入函数,
首先我们发现esp向上移了四个字节,
然后观察esp里面存储的值也变成了call指令下一条指令的地址,也就是说call让它的下一条指令的地址压栈了。因为我们执行完Add函数之后还要回到call指令的下一条指令继续执行main函数,把他的地址放在这里返回的时候就会用到这个地址。
这时候我们再按以下f10才算真正进入到了Add函数中。
Add函数
创建Add函数的栈帧的过程与main函数是差不多的。
首先main函数的ebp压栈,再将esp赋值给ebp,让ebp指向esp指向的地址,然后esp-0CCh,然后ebx、esi、edi压栈,再将edi开始的三个双字的内存改为0CCCCCCCCh。
后面还是给z分配内存。
然后就是实现加法,但是这时候我们发现,函数进行到现在并没有创建x和y。
然后我们将ebp+8的值赋给eax,
结合图我们就能知道,这其实就是将10付给了eax,这是之前压栈放在这里。这时候eax存的是10
然后add给eax加上ebp-12存储的内容,也就是10+20这时候eax=30;
再把eax得知放到ebp-8的位置也就是z变量;
到这里我们就能确定了函数的形参是实参的一份临时拷贝,甚至再Add函数站侦察机之前就已经把实参丢过去压栈了,而在Add函数中也不会为形参开辟内存,所以,改变形参是不会影响实参的。
Add函数的返回与销毁
在返回的时候,第一步就是把z得知放到eax中,因为z是函数里面的临时变量,马上就要被销毁了,而eax则是独立于内存之外的寄存器,放在eax中Add函数销毁也不会影响eax。
接下来就是三个pop指令,根据我们当时压栈的顺序,就是把edi弹出放到edi中,把esi弹出放到esi中,把ebx弹出放到ebx中。这时候esp的位置如图
然后再将esp+0CCh,这是把Add函数的栈帧回收了,因为当时创建站真的时候是esp-0CCh。
然后比较esp与ebp是否相等,如果不相等,则说明内存出问题了。
再将esp赋值成ebp,这时候esp和ebp所指向的地址如图:在这时,esp与ebp指向相同的空间。这时候栈顶的元素是之前存的main函数的ebp所指向的空间,也就是main函数的栈底
pop ebp将栈顶的main函数的栈底地址传给了ebp,这时候ebp指向的即使main函数的栈底了。
这样当我们函数调用完后汉能找到main函数的栈帧空间。
此时esp所指的是call的下一条指令的地址,当我们调用完Add函数之后要继续执行call指令的下一条指令,而执行ret指令就是将栈顶的元素弹出去并且跳到了他所指的地址,也就是call指令的下一条指令。
这时候我们就回到了主函数的执行流程中。
这时候esp指向了创建的两个形参,这时候函数以及调用完了,形参已经没有意义了,所以下一步就是add esp,8 ,让esp所指向的地址加了八个字节,也就是到了edi的位置,这时候才把形参销毁了。
mov dword ptr [ebp-20h],eax 这时候再把eax存储的返回值放到ebp-20h中去,这也就是c的地址,这时候c就接收到了Add函数的返回值。
下一行代码xor eax,eax 则是eax与eax异或,可以理解为把eax置零,
后面的main函数销毁的指令就是与Add函数销毁一样的过程了。
问题的答案
分析到这里我们就能解答一开始的问题了,
局部变量的创建过程我们刚刚已经了解到了;
局部变量不初始化时是随机值是因为编译器往里面放的时0CCCCCCCCh这样的随机值,而且不同的编译器放的值也不一样;
函数传参时通过压栈的方式来实现的,传参的顺序是从右往左,同时,在我们接收到返回值之前,函数栈帧和形参就已经销毁了;
形参是实参的一份临时拷贝、调用完函数就销毁了;
函数的调用的流程也就是函数创建栈帧和计算的流程我们也在上面仔细分析了;
函数调用返回是通过寄存器将返回的值带回来。
结语
了解函数栈帧的创建和销毁对于我们理解函数是十分重要的,在我们观察函数栈帧时,使用的编译器不同,函数调用过程中的栈帧的创建时略有差异的。
最后,因为本人也是C语言的初学者,对于文中的表述或者理解如果有错误各位读者可以指出来,我会积极改正。