调用自定义函数时需要在内存中申请一块栈区空间来存放该自定义函数的局部变量,这就是函数栈帧。在内存中有三种不同的内存空间,分别是栈区、堆区、静态区。而我们今天的主角需要开辟一块栈区空间,废话不多说,那么就开始今天的主题吧!
本篇笔记可以帮助大家解决以下6个问题。
- 局部变量怎么创建的?
- 为什么局部变量的值是随机的?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用怎么做的?
- 函数调用结束是怎么返回的?
注:在不同的编译器下,函数调用过程中栈帧的创建时略有差异的,具体细节取决于编译器的实现,但是整体逻辑是一致的,只不过实现上有一些大同小异。
想要了解函数栈帧,就先要了解几个概念:
寄存器:eax、ebx、ecx、edx (ebp、esp这2个寄存器是本篇笔记重点,这2个寄存器中存放的是地址,这2个地址是用来维护函数栈帧的)
我们先给段代码:
#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);
printf("%d\n", c);
return 0;
}
在给一张示图,表示main函数刚开始创建时,在内存栈区中创建的函数栈帧。
从上图我们可以得知两点:
1、维护函数的2个寄存器指针是可以改变指向去维护新开辟的函数栈帧。
2、函数在栈区中申请的空间是从高地址开始,逐渐向低地址申请空间。
以上只是大概的流程图,真正实现还是一些指令,下面就给大家看一下反汇编表示的函数栈帧的创建:
可以看到很多看不懂的反汇编指令,大概知道是函数栈帧创建和销毁的过程,以上的汇编指令就由我给大家一 一讲解吧!
函数栈帧的创建和销毁反汇编指令讲解:
1、函数的开辟过程
指令解析:首先进入main函数就要执行push ebp,什么意思呢?就是push是压栈指令,就是对ebp这个栈底指针进行压栈,那什么是压栈?就是每当调用其他函数时要将先当前函数的栈底指针压到栈顶指针的顶部,栈顶指针再指向这个地址,为即将调用的函数占个位置。这个过程叫做压栈。但是main函数创建时为什么要先让栈底指针压栈呢?这个栈底指针又是哪个函数的呢?(要了解main函数其实也是有其他函数调用的,只不过这些是底层的代码,我们需要知道这个概念,没必要深挖)。
可以看到main函数也是被其他函数调用的。
画图解析:
监视窗口看esp栈底指针的变化:
执行前:
执行后:
接下来就是下一条汇编指令:
指令:mov ebp,esp
指令解析:那mov是数据传输指令,就是将esp的值给ebp,看上图可以看出就是将后面的地址赋值给前面的ebp。大致意思就是让栈底指针也指向当前栈顶指针所指向区域。
画图解析:
监视窗口来观察前后执行后变化:
执行前:
执行后:
继续下一条指令:
指令:sub esp 0E4h
指令解析:sub是减法指令,什么意思?就是将esp当前的地址减去0E4h的内存大小,esp是栈顶指针,减去0E4h的内存后就指向减去这个大小内存后的地址,因为函数栈帧的创建就是由高地址往低地址创建的,所以可以理解为栈顶指针减去这块大小的内存后拿到的地址和当前栈底指针所指向的地址之间的内存就是为main函数申请的空间大小。
注:0E4h是一个十六进制的数字
画图解析:
监视窗口来观察前后执行后变化:
执行前:
执行后:
继续下一条指令:
指令:三次push压栈,ebx、esi、edi
指令解析:三次push什么意思?就是将三个元素通过压栈的方式传给main函数,就比如函数调用时,我们需要传参,在内存中就是通过压栈的方式传递参数的。
画图解析:
监视窗口来观察前后执行后变化:
执行三次结果:
执行前:
执行后:
可以看到每次压栈一次所占的内存为4字节
继续下一条指令:
下面4条指令捆绑销售,都是什么意思呢?
第一条指令解析:lea(lode effecitve address)加载有效地址,此时上面刚压栈过来的存储器指针排上了用处,就是将寄存器指针edi的地址加载为栈底指针减去0E4h大小的地址,相信前面认真看过的人对0E4h非常熟悉,他就是最初让栈顶指针到达这个位置的值,虽然通过压栈已经改变了栈顶指针的地址,但是想获得这个地址,就让栈底指针减去这个值再将地址加载给edi,edi本来是压栈时的空间地址,通过加载后edi就是当前空间的地址,注意edi只是寄存器,不是指针,如果是指针就只需将当前地址mov给edi就可以了,如果只是寄存器就需要加载它。
第二条指令解析:mov还是那个数据传输指令,将39h大小的数值传输给ecx。
第三条指令解析:将0CCCCCCCCh数据值传输给eax。
第四条指令解析:指令dword ptr是对空间存储的值或此空间关联的,这一行指令才是将以上三条指令整体操作的指令,rep stos大概意思是把刚刚edi(ebp-0E4h)位置开始向高地址ecx(39h)这么多个dword(double word)大小为4字节的数据全部改为eax(0CCCCCCCCh)数据。
人话:从edi也就是栈顶指针压栈之前所指向的地址的空间到栈底指针指向的地址空间全部置换为0CCCCCCCCh,此时这个main函数算是开辟好了,接下来开始其他操作。
画图解析:
观察内存中执行前后的变化:
执行前:
执行后:
以上过程是开辟函数栈帧的指令,开辟好后才执行下面的创建变量调用函数等指令。
2、函数内部执行过程
继续下一条指令:
指令解析:此时才开始创建变量的指令,相信看过前面知识的知道了接下来的指令是什么意思了,就是将[ebp-8]这块空间里存放的值(此时是0CCCCCCCCh)被mov传输dword大小的0Ah(十进制10)替换掉,变量a表示的就是这块空间,变量b和变量c的创建指令也是这样的。
因为变量在赋值之前存储的是ccccccc所以取出的便是随机值。
画图解析:
这里也就说明了C语言在栈区给变量开辟内存空间时喜欢先从高地址向低地址开辟。
内存表示开辟变量
继续下一条指令:
指令解析:这条指令的意思是将ebp-14h变量b的地址空间的数值20mov到eax,然后将eax进行压栈。紧接着将ebp-8变量a的地址空间的数值10mov到ecx,然后将ecx进行压栈,相信绝大多数人看到这里就明白了这就是函数传参的操作,所以才说形参只是实参的一份临时拷贝。
画图解析:
然后接下来就是下一条指令:
指令:call(调用函数)
指令解析:call指令是什么意思呢?call指令虽然是函数调用的,但是它后面的地址是什么呢?是哪里的地址,其实它后面的地址是下一条指令的地址,为的就是当call函数调用结束Add函数销毁后还能通过该指令的地址找到main函数中的下一条指令并执行。人生中有个非常重要的理念,就是人不管干什么事情都要给自己留条后路。举个例子:我要创业(调用函数),但是我要留一些钱(call下一条指令的地址),不管发生什么我都不动这笔钱,当创业失败(函数销毁)时我可以通过这笔钱(下一个指令的地址)继续东山再起(执行下一条指令)。
画图解析:
接下来就是执行Add函数的指令,下面的前10个指令竟然和main函数一模一样,这是因为前十条指令都是函数栈帧开辟的汇编指令,前面刚刚讲过。不管是哪个函数,函数栈帧开辟的方式都是一样的,所以前面10条指令知道就行了。
经过压栈ebp,申请内存空间,压栈三个寄存器,再开辟内存空间等一系列操作后。
画图解析:
接下来是创建变量的指令,我们也已经很熟悉这条指令了。
画图解析:
接下来是表达式赋值指令:
看上图,和下面的指令对比。
第一条指令:首先将ebp-8地址空间中的10mov给eax,eax就是10。
第二条指令:add为加法指令,就是将ebp-12地址空间的值附加给eax,eax此时是10,加20后eax就是30。
第三条指令:再将eax的30mov传输给ebp-8的地址空间,替换掉这个空间的值,这个ebp-8就是变量z。
这三条指令可以得知赋值表达式时,是先执行表达式再将表达式结果赋值给变量。
画图解析:
注意:形参的变量空间并不是在函数内部创建的,而是在一开始将值传输进某寄存器空间中进行压栈,再通过函数中的程序调用这个形参(寄存器空间里的值),怎么调用看个人如何敲的程序调用。
接下来就是函数返回指令:
就是将ebp-8的地址空间里的值传输到eax中,由eax将值给带回去,把值拷贝给eax后函数销毁后eax寄存器的空间可不会跟这销毁,eax寄存器的空间相当于全局变量,但是如果将函数内部创建的指针传输给寄存器,函数销毁寄存器空间的指针也会跟着销毁,因为传值就是一份拷贝,给eax,但是指针就不一样了,传址就会导致函数销毁时这块地址也会销毁,再通过这个地址访问就属于野指针了。
接下来就是销毁阶段了:
指令解析:pop是弹出指令,和push相反,push是压栈,pop就是弹出,就是将压栈的空间弹出后返还给操作系统。
画图解析:
接下来销毁整个函数:
指令解析:只需要一个简单的操作,就是将栈底指针的地址赋值给栈顶指针,那块函数空间没有了这2个指针的维护就自动销毁并返还给操作系统了。
画图解析:
接下来让ebp栈底指针指向main函数的底部:
指令解析:这个操作就是将压栈的ebp弹出,这个ebp并不是无缘无故的在这里压栈。那为什么要在这里压栈呢?因为将ebp在main函数底部的栈帧地址压栈压进当前位置,当函数销毁返回时可以使用pop弹出这个压栈ebp,再通过这个地址找到ebp原来的位置。
举个形象的例子,我有一个皮筋(ebp),它本来是被一个钉子作为支点(main函数栈底地址)栓起来的,但是当我想再开辟一个节点(函数的栈底地址)时,就再定一个钉子(压栈)将皮筋(ebp)拴起来,但是当我不想再要这个节点时(函数销毁)我就把钉子拔掉(弹出),皮筋(ebp)就弹回原来的节点(栈底地址)了。
画图解析:
找到下一条指令的地址:
指令解析:ret是返回call的指令,你们看上图栈顶下一个压栈就是call指令压栈过来的main函数下一指令的地址,所以这条指令可以理解为弹出call的压栈并通过call的main函数下一条指令的地址找到下一条指令并执行。
再回到main函数指令:
首先就是让esp栈顶指针+8
指令解析:因为还有栈空间还有两个形参,所以esp栈顶指针需要+8跳过这两个形参所在的空间,这两个形参的空间也就被释放了。
画图解析:
这一次指令后两个指针又是维护main函数的2个指针了。
继续下一条指令:
指令解析:这条指令就是将eax带回函数的返回值,传输到ebp-20h的地址空间中,也就是变量c的地址处。c此时就是30。
画图解析:
接下来的指令就是printf函数的调用和main函数的销毁,跟前面的基本上一样,这里就不过多的讲解了。
值得注意的是,在压栈时并不是将寄存器或寄存器的地址存放在栈帧空间中,而是将寄存器中存储的值拷贝了一份放进了栈帧空间。
到了这里函数栈帧就结束了,能看到这里并了解函数栈帧的人已经很厉害了,那么下期C语言笔记再见。 —— end ——