一、前言:
众所周知,函数是c语言学习中非常非常重要的一个知识板块。但与此同时,对于初学者来说,函数的学习过程中也会遇到许多令我们感到困惑的地方,例如:
①函数中的局部变量是如何创建的?
②为什么局部变量的值初始化前是随机值?
③函数是怎样传递参数的?顺序如何?
④形参和实参是什么关系?
⑤函数调用是如何实现的?
⑥函数调用结束后如何返回?
这些问题的答案都藏在函数栈帧的创建与销毁之中,接下来让我们开始本篇博客的内容~
二、前置知识补充:
① 寄存器
在讲函数栈帧之前,需要了解一些有关于寄存器的知识。在寄存器中有如:eax,ebx,ecx,ebp,esp 等诸多寄存器,其中 eax,ebx,ecx 为通用寄存器,用于保留临时数据; ebp 和 esp 分别为栈底寄存器和栈顶寄存器,也是函数栈帧所用到的两个主要寄存器。要理解函数栈帧,就必须理解这两个寄存器在其中的作用。
首先,ebp 和 esp 两个寄存器中存放的是两个地址,两个寄存器都以p结尾,其实也不难猜出二者是用来存放指针的(pointer)。而这两个地址的作用就是维护函数栈帧。我们知道,在c语言中,每一个函数的调用都要在内存(栈)中开辟空间,主函数更是如此。这个"维护"可能听起来比较抽象,我们借助下图来进行讲解。
#define _CRT_SECURE_NO_WARNINGS 1
#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;
}
#define _CRT_SECURE_NO_WARNINGS 1
#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;
}
不难看出,在栈中 ebp 寄存器指向当前函数的栈底,存储了当前函数所占用空间地址的最低处(高地址);而 esp 寄存器则指向当前函数的栈顶,也就是占用空间的最高处(低地址),而二者之间的空间则是当前函数运行所占用的空间,这个空间的大小是根据编译器对于代码进行编译之后自动分配的。
此外,ebp & esp 两个寄存器始终在维护正在被调用的函数的空间。比如在上面这个简单的程序段中,当 main 函数开始被调用的时候,两个寄存器会为 main 函数进行空间分配,而程序进行至 Add 函数的时候,在进行压栈操作后,它们又会“跑去”维护该函数的栈帧。(如上图)
② 函数的调用堆栈
首先,函数栈帧的创建和销毁过程在不同的编译器上实现的方法大同小异,此处演示以VS2019为例。
演示代码:
#define _CRT_SECURE_NO_WARNINGS 1
#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;
}
以上程序在调试进入 Add 函数时打开调用堆栈,并且右键勾选“显示外部代码”,就会出现以下列表:
可以看出,除了我们所编写的 main 函数与 Add 函数之外,也有调用了 main 函数的invoke_main 函数以及前置调用的诸多函数。那我们可以确定, invoke_main 函数同样会有自己的栈帧, main 函数和 Add 函数也会维护自己的栈帧,每个函数栈帧在函数被调用时都有自己的 ebp 和 esp 来维护。
三、正文:
有了足够的前置知识补充,接下来我们借助反汇编来深入了解函数栈帧创建与销毁的详细过程。
我们已经知道,在我们所编写的c程序中,是从main函数开始执行的,因此在我们按下F10进行调试的那一刻,会直接进行至main函数处;与此同时上文中提到,在main函数前是有函数对其进行了调用的。因此,在进入main函数时,在堆栈之中就已经为其前置函数分配了空间,也就是invoke_main函数。(如图)
然后开始main函数的执行:
我们先看程序的前十行:
第一行:将ebp压栈
第二行:将esp中存储的值(地址)赋给ebp
第三行:对esp中所存地址进行减法计算,减去0E4h(16)个字节,由于栈中的地址是从高地址向低地址进行占用,因此减法计算会使esp中的地址远离栈底
第四行:将ebx进行压栈
第五行:将esi进行压栈
第六行:将edi进行压栈
第七行:lea是load effective address的缩写,即装填有效地址,装填的地址会在第十行的指令中用到。这行将[ebp-24h]这个地址存入edi中
第八行:将9存入ecx寄存器中,用途为控制循环次数,即共循环9次
第九行:将0CCCCCCCCh存入eax寄存器中,该值是准备向目标地址中存入的数值
第十行:结合前三行一起看,其操作指令为:从[ebp-24h]这个地址向下(向高地址)存入0CCCCCCCCh这个值,每个分配四字节的空间,并重复九次。其中dword的意思为double word,即两个word,又因为一个word占两个字节,因此dword为四个字节
后四行的代码可以等价于以下四行代码段:
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
*(int*)edi = eax;
}
这十行代码的内存变动情况如下:
而后执行我们所写的c语言指令所对应的汇编代码:
第一行: 将0Ah(16)即十进制的10存入 ebp-8所对应的地址中,共占四个字节
第二行: 将14h(16)即十进制的20存入 ebp-14h所对应的地址中,占四个字节
第一行: 将0存入 ebp-20h所对应的地址中,占四个字节
内存演示如下:
接下来就是Add函数部分的代码了:
第一行:将ebp-14h所指的变量赋值给eax寄存器,ebp-14h中的值为20,所以eax中现存的值为20
第二行:将eax寄存器压栈
第三行:将ebp-8所指的变量赋值给ecx寄存器,ebp-8中的值为10,所以ecx中现存的值为10
第四行:将ecx寄存器压栈第五行
第五行:call指令的作用为调用函数,它包含两个步骤:1、压入返回地址,即007E10B4 2.、转入目标函数。因此这里我们把call后面的地址压栈,而后进入Add函数
内存的变动情况如下:
此时我们按F11进入函数时会出现以下界面:
其中,jmp的功能为通过修改eip,转入目标函数,进行调用。(其中eip也是一个寄存器,用来存储CPU要读取指令的地址)因此,我们再次点击F11即可进入Add函数
第一行:将ebp寄存器压栈
第二行:将esp中存储的地址赋值给ebp
第三行:对于esp中所存地址进行减法计算,使esp中的地址向低地址进行移动,共移动0CCh(16)个字节(是不是很眼熟?其实就是再给Add函数申请空间,和main函数同理(¬‿¬))
第四行:将ebx寄存器压栈
第五行:将esi寄存器压栈
第六行:将edi寄存器压栈
进入Add函数前的准备工作以及Add函数运行前的准备工作的内存情况如下:
之后我们进入Add函数的主体部分:
第一行:向ebp-8的地址处存储数字0,它占有四个字节。该地址即变量z的存储地址
第二行: 将ebp+8所在地址中的数据赋值给eax,从下图中可以直观看出ebp+8为原来的ecx,所以此步骤为将原来的ecx中的数值(也就是10)赋值给eax
第三行:在eax处执行加法计算,两个操作数为eax中所存的值(10)以及ebp+0Ch地址中所存的数值,看下图可知,该地址中的值为原eax中所存储的值,即20,算出加和为30。(其实博主在这里也有所疑惑,因为eax的值已经被赋予过10了,然而在取回ebp+0Ch地址中的值时返回的仍为20,所以博主在这里大胆猜测,最后这里存储加和的eax并不是ebp+0Ch这个地址,而是另有其“人”)
第四行:将eax寄存器中的数值赋予ebp-8地址所指向的变量,书接第一行,将计算出的30存入变量z所在之处
第五行:也就是Add函数正文的最后一个指令,将ebp-8处所存数据赋予eax寄存器,该数据占四个字节
第六行:edi寄存器出栈
第七行:esi寄存器出栈
第八行:ebx寄存器出栈
第九行:将ebp中所存地址赋予esp寄存器
第十行:ebp寄存器出栈
第十一行:ret即恢复返回地址,压入eip,这就用到了ebp寄存器出栈之后下面的这个地址了,可以帮我们找到回去的路~
内存变动情况如下:
回来之后就是完成返回值的传递,并完成打印任务:
第一行(call指令的下一行):对esp中的地址进行加法运算,使其加八(更靠近栈底)
第二行:将eax中存储的值赋予ebp-20h地址所指向的变量,也就是主函数中的变量c,使c得到Add函数的返回值
第三至七行:打印函数的操作指令,这里就不在进入函数重复讲解了。同为函数,不难看出与Add函数的执行流程是一致的,均为先将实参进行拷贝,拷贝后进行压栈传参,压入返回地址,进入函数,在返回时将esp指针+8使其指向返回值
第八行:xor指令为按位逻辑异或操作,由于传入了两个相同的参数,所以返回0,main函数主体到此结束
第九行:edi寄存器出栈
第十行:esi寄存器出栈
第十一行:ebx寄存器出栈
第十二行:esp中地址进行加运算,使其加0E4h(16),即向内存退回main函数调用时所申请的空间
第十三行:调用cmp函数,对于栈顶栈底两个寄存器的地址进行减法操作,以判断是否全部内存已经归还,并且将差值返回给invoke_main函数
对应内存的操作情况如下:
四、结束语:
到此为止已经给大家完整的演示了main函数栈帧以及Add函数栈帧的创建和销毁的过程,相信大家已经能够基本理解函数的调用过程以及函数传参的方式。接下来我们回答一下文章开始的几个问题:
①局部变量是如何创建的?
答:首先为函数先分配好栈帧,然后再在栈帧中合适的地方分配合适的空间来存放局部变量,例子中我们用寄存器来存储。
②为什么局部变量的值是随机值?
答:在为函数创建好栈帧之后,我们会先为栈帧中可能存放数据的地方先赋予随机值,在这个例子中我们的随机值就是0CCCCCCCCh,也就是我们遇到过的“烫烫烫烫”~,如果不进行进一步人为初始化的话,里面存储的值看起来就会是随机值。
③函数是怎样传递参数的?顺序如何?
答:在调用对应函数之前,也就是在做准备工作的时候,我们就会通过压栈的方式(例子中所用的是eax和ecx两个寄存器存储参数并传递)将函数参数先存到内存之中,并且遵循从右向左进行压栈的原则。(在例子中我们可以看到,eax是先被压入栈中,eax中存储的是变量b的数值,因此压栈的顺序为从右向左的)
④形式参数和实参是什么关系?
答:在调用函数时,形式参数确确实实是对原数据进行复制后压入栈中的,因此可以确定形式参数是实际参数的一份临时拷贝!
⑤函数调用是如何实现的?
答:上述的例子中已经剖析清楚,主要步骤包括但不限于:
1、先将实参进行拷贝
2、拷贝后进行压栈传参
3、压入返回地址
4、进入函数并完成函数功能
5、在返回时将esp指针进行+8运算使其指向返回值的地址
⑥函数调用结束后如何返回?
答:在进行函数调用时,会优先进行一次ebp的压栈,当函数调用结束后,我们就能够准确高效的找到这个地址并且将占用的内存释放掉,也就实现了函数调用结束后的返回。
以上内容便是对于函数栈帧的创建与销毁原理的详细解读,如有错误或者不足之处还请各位大佬批评指正,小弟不胜感激。
完。