1.提出问题
2.讲解函数栈帧的创建与销毁
2.1.讲解须知
2.2.大致步骤概述
2.3.详细步骤讲解
3.解释提出的问题
4.完结撒花
——————————————————————————————————————
1.提出问题
在我们学习C语言函数调用的那章节时,我们可能有很多疑问
比如:
*局部变量是怎么创建的?
*为什么局部变量不初始化的值是随机值?
*函数是怎么传参的?传参的顺序是怎么样?
*形参和实参是什么关系?
*函数调用是怎么做的?
*函数调用结束后的结果是怎么返回的?
这些问题在我们学懂函数栈帧的创建和销毁后就都会明白,在往后的学习中也能弄懂更多的知识。
2.讲解函数栈帧的创建和销毁
2.1.讲解须知
函数栈帧是记录函数在内存空间是如何创建及创建的位置等等,每一次函数的调用都会在栈区创建一个空间,对标之前所学习的知识,函数栈帧是对C语言更深层次的理解,解释C语言是如何在内存中存储。
我们要知道,基于编译器的不同,函数栈帧的创建和销毁可能略有差异,下面我基于VS2013给大家进行讲解。
在函数栈帧的创建和销毁我们会用到很多寄存器,寄存器是集成到CPU中。在电脑上有几个存储空间,硬盘,内存还有寄存器,这三个是独立互不干扰的储存空间,寄存器含多种,如:eax,ebx,ecx,edx等,在函数栈帧的创建和销毁中,重点是ebp和esp两个寄存器,两个寄存器里面存放的是地址,是用来维护所调用的函数栈帧的。
2.2.大致步骤概述
下面我们简单敲一个加法调用函数的代码作为例子为大家进行讲解:
在该代码中,代码是先调用了main函数,再调用Add函数,每一次函数调用都会在栈区开辟出一块空间。
我们再根据下面图片进行大致步骤的讲解:
红色长方形代表的是栈区内存空间,在内存中下面是高地址上面是低地址,栈区内存空间先由高地址读取到低地址,代码先调用main函数,所以会在下面先开辟一块main函数的函数栈帧区域,那么如何在栈区内存空间来表示main函数的栈区呢?这时我们就要使用ebp和esp两个寄存器来存放main函数栈帧边界处的两个地址,来维护main函数栈帧。如图所示,esp为栈顶指针,存放main函数栈顶地址,ebp是栈底指针,存放main函数栈底的地址。
main函数调用进入Add函数,调用Add函数后会在栈区main函数栈区的上面创建Add函数的栈区空间,用来存放Add函数。
main函数和Add函数调用完毕后便开始函数栈帧的销毁,基于函数栈帧的创建,反向步骤的拆解就是函数栈帧的销毁,下面我们来详细分析函数栈帧是如何创建和销毁的。
2.3详细步骤讲解
我们在VS2013中,对代码进行调式,再在调试中点击窗口,再点击反汇编,出现的便是我们代码在栈区开辟空间的详细步骤,我们找到main函数的位置给大家一行一行进行讲解:
建议将这张图保存下来观看,下面会围绕这张图进行讲解。
在反汇编中,其实main函数也是被__tmainCRTStartup函数调用的,在反汇编里面可以找到,这里就不展示了,那么我们进行画图讲解:
既然main函数是被__tmainCRTStartup函数调用,那么在栈区内存空间中是要先对__tmainCRTStartup函数进行空间开辟,寄存器esp和edp存储__tmainCRTStartup函数边区域的地址。
我们对反汇编进行讲解,箭头所指第一行为 push,ebp。push表示压栈,栈区的使用是从高地址到低地址,即将ebp在__tmainCRTStartup函数栈区的上面进行压栈,如图所示,同时寄存器esp的地址也应减小到ebp的顶部如下图:
esp的地址减小,此时esp里面存的是ebp的地址,这就是push压栈的一个全过程。
接下来看第二行 mov ebp,esp,意思是将ebp移动到esp的位置,即将esp的地址存给ebp。
然后看第三行 sub esp,0E4h,意思是将esp的地址减0E4h,0E4h为十六进制换算成十进制为228,其目的是在为main函数开辟出一块空间,图像去下图所示:
现在便是调用了main函数,esp,ebp维护的便是main函数的栈帧。
继续像下面走,下三行都是push,对于这三个寄存器大家目前并不需要了解,知道这里压了三个寄存器就行,那么栈区内存空间中的图像见下图:我们继续下一行 lea edi,[ebp-0E4h],lea的意思是load effecitve address叫做下载有效数据,这里如果出现[ebp+FFFFFF1Ch],只要右键把显示符号名勾选上就会和上面我传的反汇编的图片一样,那么这一行的意思是把ebp的地址减0E4h到edi里面去,ebp-0E4h即是原本main函数栈帧的栈顶指针的位置。接着下一行 mov ecx,39h,意思是把39h放到ecx里面去。下一行mov eax,0CCCCCCCCh,意思是把0CCCCCCCCh放到eax里面去。是不是有点蒙了?不要急,上面三行做完以后真正起作用的在下一行,rep stos dword ptr es:[edi],这里一个word表示两个字节,dword表示四个字节,这一行的意思是和上面三行联系起来,即从edi的位置开始向下的39h个dword的数据全部改成CCCCCCCC一直到ebp的位置,这里我们可以在编译器的窗口中打开内存窗口便可以观察,图片如下图所示:
到此为止才是将main函数开辟完了,下面进行函数内部的操作,int a = 10,对应下面一行汇编代码 mov dword ptr [a],0Ah,对于这行汇编代码,我们可以将刚刚提到的显示符号名取消勾选,得到的便是下图所示汇编代码:
我们再看int a = 10下面那行的汇编代码,mov dword ptr [ebp-8],0Ah,现在便好理解了,这行汇编代码的意思是把0Ah(转化成十进制为10),存放在ebp-8的位置上,我们假设所画的图中main函数里面,一行CC CC CC CC为四个字节,那么ebp-8的位置见下图:
图中所标示的10—a便是代码中定义的a元素在栈帧内存空间的位置。
这里我们顺便一提,代码中是将a初始化成了10,如果我们创建了a但没有对a进行初始化,那么main相应栈区里面存放的是CC CC CC CC,我们最常见的随机值便是打印出来一串“烫烫烫烫”,这个原因就是因为main相应栈区里面存放的是CC CC CC CC。
我们接着下一行汇编代码,这行汇编代码与上行汇编代码一样,这里的14h为十六进制(转换为十进制是20),那么其所显示位置见下图:
接下来同样步骤将c放入栈区内存空间,如下图:
这就是在函数栈帧中局部变量创建的基本方式。
下面我们进入Add函数的调用(敲死我啦,麻烦点赞收藏一下,拜托了🙏)
我把图片给大家截过来:
下面一条指令 mov eax,dword ptr [ebp-14h],意思是将ebp-20位置的值也就是b的值存进寄存器eax里面去,然后再将eax压栈压到edi上边,第三行 mov ecx,dword ptr [ebp-8]和第四行push ecx与前两行一样,是将ebp-8的值也就是a的值存到ecx里面再压栈ecx上去,对应压栈内存空间图片如下图所示:
没错,这一步就是调用函数传参的基本步骤。这里可以注意一下,函数传参的形式是先传的b的值,再传a的值,也就是函数传参是从右往左进行传参运算。
我们继续学习下面的汇编代码:
下一行是call 00C210E1,这行指令的意思是将下一条指令add esp,8,的地址,即:00C21450,进行压栈,在ecx寄存器上面把Add的地址压上去,这一步的作用是为栈帧的销毁做铺垫,我们继续往下面学就会明白,那么这行指令对应的栈帧内存空间图片为:
对不起,前面忘记了修改,现在main函数的栈帧范围如上图所示,每一次的push都会修改main函数栈帧的范围。
下一行汇编代码:add esp,8才是真正要进入Add函数的代码,我们按F11键进入Add函数的汇编代码:
我们可以看到除了第一行外,前十行的汇编代码与之前main函数开辟内存空间的代码指令是一样的,没错,这几行汇编代码就是在为Add函数开辟内存空间,第一行指令1是将现在的ebp寄存器的位置压栈到了esp寄存器里面,也是为调用函数完毕后销毁栈帧做的铺垫,下面我们就将前十行的指令所创建的Add函数的栈帧内存空间画出来,如下图所示:
大家也可以调试找到内存的窗口观察,结果是与上图对应的。
下面是在Add函数栈区创建临时变量z,汇编代码是mov eax,dword [ebp-8] 0,这行就是将0存放在ebp-8的位置寄存在寄存器eax里面,如下图所示:
接下来进行z = x+y,下面三行汇编代码就是对传过来参数的运算,第一行 mov eax,dword ptr[ebp+8],我们可以根据上图观察,ebp+8的位置就是先前所传过来的a的值的位置,所以我们就可以将其认为是x,将其值寄存到eax里面。下行汇编代码意思是将dword ptr [ebp+0Ch]的值加进寄存器eax里面,也是对应上图,ebp+0Ch的值就是先前所传过来的b的值,所以我们就可以把ebp+8和ebp+0Ch看做传过来的参数x和y,至此便完成了x+y的运算。第三行的汇编代码意思就是将eax寄存器里面的值移动到(即mov)ebp-8的位置里,而ebp-8就是所创建的局部变量z的位置,eax里面存放的就是x+y的值。所以这三行汇编代码便完成了所需的运算。对应栈区内存内存空间如下图所示:
下面就该return z,将所算好的值返回给main函数,具体是如何返回我们看下面的汇编代码是如何执行的:
return z下一行汇编代码:mov eax,dword ptr [ebp-8],其意思是将ebp-8的位置,也就是z的值寄存到eax寄存器里面去,因为在函数栈帧销毁的过程中,所创建的临时变量z会被销毁,但寄存器肯定不会销毁,所以要将z的值存到寄存器里面进行return返回。
下面就到了函数栈帧的销毁阶段,pop的意思是出栈,就是与push压栈相反的操作,连续三个pop出栈的指令,就是将edi,esi,ebx进行出栈操作,这步可以抽象看成高楼拆解一般,同时esp寄存器的地址增加,执行完指令栈帧内存空间变化如下图所示:
下一步汇编代码 mov esp,ebp,经过上面许多代码的学习,我想对于这个代码,大家应该是可以知道其意思,就是将esp移动到ebp的位置,将ebp的地址赋给esp,那么现在寄存器esp和ebp应该指向的是同一个位置,之后下一行代码pop ebp就是将寄存器ebp进行出栈,这里的ebp是我们之前所存的main函数栈底得地址,ebp出栈的同时ebp会指向先前ebp在main函数中的位置而esp地址则增加到下一个所存地址的位置,栈区内存空间去下图所示:
Add函数最后一行汇编代码指令 ret 其所指令的行为就是读取下一栈地址并返回,之前我们在main函数中执行的最后一条指令call指令,其意思是将call指令下一条指令的地址进行了存储,现在我们读取所存储的地址便可返回到main函数汇编代码call指令的下一行指令继续进行代码的完成,main函数接下来的汇编代码和栈帧内存空间的变化如下图所示:
到此为止,我们完成了Add函数的创建和销毁,这时可能有人会问,所传的参数呢?我们之前传参是在Add函数栈帧的创建之前完成的,所以我们依据步骤,所传参数的销毁也是在Add函数栈区销毁之后进行的,根据上图下一条指令为add esp,8,意思就是将esp寄存器的地址加8,这样就直接到了edi寄存器的位置,这样所传参数就被销毁了,栈区内存空间如下图所示:
此时esp和ebp再次维护了main函数的栈区,下面指令mov dword ptr[ebp-20h],eax,意思就是将eax的值存放到ebp-20h中,而ebp-20h就是我们在main函数中创建的局部变量ret的位置。
讲到这里我们讲解了main函数和Add函数栈帧的创建,和Add函数栈帧的销毁,当然main函数栈帧的销毁和Add函数栈帧的销毁是一样的,这里我就不在过多阐述了(真的要敲死了/(ㄒoㄒ)/~~)。
3.解释提出的问题
*局部变量是如何创建的?
首先我们要为函数创建栈帧空间,初始化栈帧空间后,在栈帧空间为局部变量再分配一定空间来创建局部变量。
*为什么局部变量不初始化的值是随机值?
因为在初始化函数栈帧时往里面放的是随机值,如果局部变量内有初始化,那么其值就是原先函数栈帧里所放的随机值。
*函数是怎么传参的?传参顺序是怎样?
函数传参是在所传函数栈帧创建之前就进行压栈push传参了,当我们真正进入所调用的函数时通过对指针地址的增加减少来使用参数。参数传参的顺序是从右到左传。
*形参和实参是什么关系?
通过上面的学习,我们可以知道形参和实参值是相同的但内存空间是独立的,所以形参就是实参的一份临时拷贝。
*函数调用是怎么做的?
函数调用通过call指令进行调用后,在栈帧区间再进行函数栈帧的创建。
*函数调用后的结果是怎么返回的?
在call指令后是记下来了下一条指令的地址,在从所调用的函数返回回来通过所记下来的地址返回到原先的函数中,所计算的值通过寄存器进行了返回。
4.完结撒花
真没力气敲了,如果以上知识对你有所帮助,不妨点个赞和关注,以后我也会分享更多所学过的知识,我们一起进步,谢谢啦。