目录
💫只有认知的突破 💫才能带来真正的成长 💫编程技术的学习 💫没有捷径 💫一起加油💫

🍁感谢各位的观看 🍁欢迎大家留言 🍁咱们一起加油 🍁努力成为更好的自己🍁
这篇博客的收获
这篇博客的内容很重要,因为它能帮我们从更底层且深刻的理解函数是怎样创建的。同时,它也是在面试中被常问的问题。所以,我会详细的写这篇博客。这篇博客能解答如下问题:
**Q1:**局部变量是如何创建的?
Q2: 为什么局部变量不初始化内容是随机的?
Q3: 函数调用时参数时如何传递的?传参的顺序是怎样的?
Q4: 函数的形参和实参分别是怎样实例化的?
Q5: 函数的返回值是如何带会的?
我会把这些问题穿插在整个的讲解过程,我会边结合代码和画图讲解。最后,再对以上的问题进行总结讲解。我能理解大家在开始学习这个知识点的时候,会有点懵逼。因为我也是这样过来的,所以我希望大家能耐住性子继续往后看。我建议大家这篇文章可以多看几遍。
前期知识储备
函数栈帧的创建和销毁是建立在汇编语言层面上。我相信大多数都没有学过汇编语言,包括博主我也是没学过汇编。但是,对于理解这个知识点,我们不需要深入了解汇编。我们只需要了解个别基本的汇编指令就OK了。下面所列的几个指令是我们讲解这个知识点要用到的,也就是说大家知道以下几个指令的意思就已经够用了。当然,如果大家对汇编感兴趣的话,也可以自学一下汇编。
常用到的汇编指令:
- 寄存器 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
**ebp:**是栈底寄存器,用来存储栈底的地址
**esp:**是栈顶寄存器,用来存储栈顶的地址
**eax , ebx , ecx :**这几个寄存器不需要知道什么作用,只需要知道它们是用来存储数据就OK
- 指令- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
**push(压栈):**申请一块内存,把寄存器里面的数据给存放到内存里面。压栈的同时,esp里面会存储新申请的这块内存地址
mov(移动):把一个寄存器里面的值给赋值到另一个寄存器里面
**pop(出栈):**弹出内存里面的数据,回收内存。出栈的同时,esp里面存放的地址会增大
**add(相加):**把两个寄存器里面的值相加,或两数值相加。
形成共识:
如果大家看了我以往的博客话,大家对于内存的大概划分是有印象的。内存分为: 栈区,堆区和静态区。数组,结构体和函数的创建,这些都是在栈上开辟的空间。内存是以1个字节为最小为单位进行划分的。**而且在栈上使用内存有个默认规则:优先使用高地址内存。**我们在讲解的时候,所画的图是以4个字节为单位的。因为函数开辟的空间很大,如果我们以1个字节画图的话,所画的图太大了。因为我们的示例代码也是int类型,占有4个字节。所以以4个字节为单位画图,比较好一些。虽然画图的单位不一样,但是对于我们理解这个知识点没有丝毫影响的。
开始讲解
示例描述:
写一个加法函数,这个函数被main函数调用。这个代码的所有数据的数据类型为int。
代码示例:
cpp
#include <stdio.h>
int Add(int x,int y)
{
int z=0;
z=x+y;
return z;
}
int main()
{
int a=3;
int b=5;
int ret=0;
ret=Add(a,b);
printf("%d\n",ret);
return 0;
}
汇编代码:
我教大家怎样转化,转化的步骤如下所示:
我们先把程序进入调试模式(ctrl+F5),鼠标放在界面任意空白处。然后鼠标右键,点击转为反汇编,就可以转成功了。如图所示:
以下就是转化后的汇编代码。**提示:**下面版本的汇编代码是vs2019版本的,因为2019之前的版本对于汇编的封装比较简单,便于我们观察。vs2022版本的封装就比较复杂一些,不利于我们观察。所以,我们就以vs2019这个版本为主。如下所示的汇编代码:
cpp
int Add(int x, int y)
{
00BE1760 push ebp //将main函数栈帧的ebp保存,esp-4
00BE1761 mov ebp,esp //将main函数的esp赋值给新的ebp,ebp现在是Add函数的ebp
00BE1763 sub esp,0CCh //给esp-0xCC,求出Add函数的esp
00BE1769 push ebx //将ebx的值压栈,esp-4
00BE176A push esi //将esi的值压栈,esp-4
00BE176B push edi //将edi的值压栈,esp-4
int z = 0;
00BE176C mov dword ptr [ebp-8],0 //将0放在ebp-8的地址处,其实就是创建z
z = x + y; //接下来计算的是x+y,结果保存到z中
00BE1773 mov eax,dword ptr [ebp+8] //将ebp+8地址处的数字存储到eax中
00BE1776 add eax,dword ptr [ebp+0Ch] //将ebp+12地址处的数字加到eax寄存中
00BE1779 mov dword ptr [ebp-8],eax //将eax的结果保存到ebp-8的地址处,其实就是放到z中
return z;
00BE177C mov eax,dword ptr [ebp-8] //将ebp-8地址处的值放在eax中,其实就是把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。
}
00BE177F pop edi
00BE1780 pop esi
00BE1781 pop ebx
00BE1782 mov esp,ebp
00BE1784 pop ebp
00BE1785 ret
int main()
{
//函数栈帧的创建
00BE1820 push ebp
00BE1821 mov ebp,esp
00BE1823 sub esp,0E4h
00BE1829 push ebx
00BE182A push esi
00BE182B push edi
00BE182C lea edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
//main函数中的核心代码
int a = 3;
00BE183B mov dword ptr [ebp-8],3
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
00BE1853 push eax
00BE1854 mov ecx,dword ptr [ebp-8]
00BE1857 push ecx
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00BE1863 mov eax,dword ptr [ebp-20h]
00BE1866 push eax
00BE1867 push 0BE7B30h
00BE186C call 00BE10D2
00BE1871 add esp,8
return 0;
00BE1874 xor eax,eax
}
我们就按照上面汇编代码的顺序进行讲解。我们之前也有提到过,main函数也是被调用。在vs2022中,main函数是被__tmainCRTStartup函数调用的,__tmainCRTStartup函数是被tmainCRTStartup函数调用。如下所示的三者关系:
图文并茂讲解
第一步:
esp(栈顶指针)和ebp(栈底指针),这俩指针(寄存器)是用来维护一段函数的空间的。main函数也是被别的函数调用的,也就是说在创建main函数之前,就已经先创建好了__tmain...和tmain...函数。如图所示:
接下来我们就按照汇编代码来进行画图讲解,我们先从main函数的创建开始。如图所示:
main函数是被__tmainCRTStartup函数调用的,所以在创建main函数之前,就已经创建好了__tmainCRTStartup的函数空间,也就是esp和ebp所维护的栈帧空间。创建main函数空间前,先执行push ebp指令,会把ebp里面的值给压栈,同时esp会指向新开辟的空间地址。也就是如上图所示的结果。
第二步:
指令mov ebp,esp。把esp里面的值赋值到ebp,那么ebp指针就和esp指针指向了同一块空间。如图所示的结果:

第三步:
指令 sub esp,0E4h。意思就是:esp里面的地址减去0E4h,然后再赋值给esp。esp里面的地址减少后,就会往上移动到新的空间(移动到低地址),它和ebp之间相差了228个空间。如图所示:
228个地址空间太多了,我就简单画了几个空间表示一下。
第四步:
没什么可说的,就是依次对图示的指令进行操作就OK了。如图所示:
第五步:
指令 lea edi,[ebp-24h] 。意思是:把ebp-24h后的地址给存储到edi寄存器里面。指令 mov ecx,9。意思是:把9给存储到ecx寄存器里面。指令 mov eax,0CCCCCCCCh。意思是:把0CCCCCCCCh值存储到eax寄存器。指令 rep stos dword ptr es:[dei] 意思是:rep就是repeat(重复)的意思,也就是说这条语句会被重复执行。至于重复多少次是由ecx里面的值决定的。也就是说,会从edi对应的地址开始,一直循环9次,在循环的过程中,会对每个空间内存进行0CCCCCCCCh值的初始化。每个空间的大小是4个字节,执行9次,刚好会循环36个空间。而24h==36。所以刚好循环到ebp就结束。其实,上面所说的循环过程,可以进行如下所示的代码:
cpp
edi=ebp-0x24h;
ecx=9;
eax=0CCCCCCCCh;
for(;ecx>0;--ecx,edi+=4)
{
*(int*)edi=eax;
}
如下图所示:
第六步:
指令 mov操作,依次把数值放入对应的空间里面。如图所示:
从上面的图,我们可以得出一个我们之前讲到的一个习惯------我们创建变量的时候,最好是初始化,要不然就会随机值。上面的图就可以解释这一现象。
第七步:
这几个指令的操作就是为了创建形参,然后把实参的值传给形参。也就是我们之前所说的,形参是实参的一份拷贝。所以,在调用函数之前,先创建形参,再传给形参数值。
第八步:
call指令就是调用函数的意思。在调用函数之前,会先把下一条指令的地址给压栈。目的:在调用函数之后能找到下一条指令。如下图所示:
第九步:
接下来的操作就是创建Add函数,它和创建main函数一样的过程。如下图所示:
第十步:
mov ...[ebp-8],0 把0赋值给z对应的空间。mov eax...[ebp+8] 把x形参对应的数值3给存储到eax寄存器。 add eax...[ebp+0ch] 把ebp+0ch对应的空间里面的值------也就是y形参对应的数值5和eax里面的值进行相加,再存储到eax寄存器里面。此时eax寄存器里面存储的值就是x+y的最后结果8。最后再把eax里面的数值给存储到ebp-8对应的空间,也就是说z对应的空间,此时z=8。如图所示:
第十一步:
再把z=8存储到eax寄存器里面。如图所示:
cpp
return z;
00BE177C mov eax,dword ptr [ebp-8] //将ebp-8地址处的值放在eax中,其实就是把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。
第十二步(栈帧的销毁):
依次对edi,esi和ebx空间进行pop弹出(内存空间回收)。pop的同时,esp指针也要向下移动。如下图所示:

回收内存后, 如图所示:
第十三步:
mov esp,ebp 把ebp里面的值复制给esp,esp就和ebp指向同一块空间了。0cch个空间会被直接销毁(回收)。然后 pop ebp。Add创建的栈帧空间就彻底被回收干净了。如图所示:
第十四步(返回值):
cpp
00BE1785 ret
从第十一步到第十三步,可以看出来,再函数栈帧销毁之前,会先把最后的返回值给保存在寄存器里面。最后,函数栈帧销毁之后,才返回返回值。这也就是我们之前提到过的------在函数销毁之前,会把返回值先存储到寄存器里面,然后返回寄存器里面的数值。
总结
步骤总结
以上十四步就是核心步骤,至于Add后面的指令操作都是重复的。大家可以按照我给的方法和思路进行画图操作。
问题解答
经过前面的讲解,对于开头的几个问题,我们就可以进行解答了。
Q1:局部变量是如何创建的?
局部变量是在函数栈帧里面创建,经过一些列的push(压栈)操作,并会进行初始化。
Q2:为什么局部变量不初始化内容是随机的?
会执行 rep指令,会对其一部分空间进行0cccccccch值的初始化(具体是什么值依据编译器),所以不初始化的话,会被赋值0cccccccch值(随机值)。
Q3:函数调用时参数时如何传递的?传参的顺序是怎样的?
先进行形参的空间压栈,然后再把实参的值,赋值给形参压栈的空间内存里面。
从右向左依次进行传参。
Q4:函数的形参和实参分别是怎样实例化的?
实参是在函数内部进行实例化的,形参是在函数栈顶开辟的空间,然后由实参进行传值实例化 。
Q5:函数的返回值是如何带会的?
先把返回值存放在寄存器里面,然后再进行栈帧的销毁。
彩蛋时刻!!!
每章一句:"山有顶峰,湖有彼岸,在人生的漫漫长途中,万物皆有回转,当我们觉得余味苦涩,请你相信,一切皆有回甘"。
