- 魔王的介绍:😶🌫️一名双非本科大一小白。
- 魔王的目标:🤯努力赶上周围卷王的脚步。
- 魔王的主页:🔥🔥🔥大魔王.🔥🔥🔥
❤️🔥大魔王与你分享:莫泊桑说过,生活可能不像你想象的那么好,但是也不会像你想象的那么糟。人的脆弱和坚强都超乎了自己的想象。有时候可能脆弱的一句话就泪流满面,有时候你发现自己咬着牙已经走过了很长的路。
文章目录
- 一、前言
- 二、问题举例
- 三、介绍
- 1.寄存器
- 2.汇编指令
- 四、讲解
- 1.main函数并不是最终函数
- 2.函数栈帧的创建
- 3.函数调用
- 开辟空间前执行的操作:
- 开辟空间及销毁前的操作:
- 销毁操作:
- 4.调用函数后的汇编指令:
- 五、总结
一、前言
我们在编译代码时会有很多不清楚的地方,例如我们创建变量时我们只知道会开辟空间,却不知道要在哪开辟,怎么开辟,在我们调用函数时,我们也知道要在栈区开辟空间,但是依然不知道怎么开辟,参数如何拷贝,为什么值传递不会改变原数值。本篇博客带你理解栈区(局部变量和函数调用开辟空间的地方)是怎样工作的。
二、问题举例
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数时怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用时怎么做的?
- 函数调用时结束后怎样返回的?
- 通过下面的讲解你将全部明白这些问题
三、介绍
- 首先说明一些比较陌生的东西,以便后续的理解。
1.寄存器
- 寄存器:中央处理器内的组成部分。寄存器是有限存贮量的高速存贮部件,它们可以来暂存指令、数据和地址。寄存器是独立于内存的,不会因为栈区函数空间的销毁而销毁。
- 寄存器举例:
eax、ebx、ecx、edx、ebp、esp等
其中三个寄存器比较重要:
1.eax:接收函数返回值,使得函数的返回值不会因为栈区的销毁而消失。
2.ebp:栈底寄存器(高地址),和esp共同维护栈区新开辟的空间。
3.esp:栈顶寄存器(低地址),和ebp共同维护栈区新开辟的空间。
2.汇编指令
push:压栈,放入栈顶一个元素(寄存器的内容,并不是寄存器)。
pop:出栈,把栈顶数据弹出,并把弹出的元素赋值到寄存器中。
move:给一个寄存器赋值。
sub:减法指令。
add:加法指令。
lea:即load effective address,加载有效地址。
call:调用函数,并且把该函数之后的地址进行压栈(目的是为了在函数销毁后可以回到函数之后的下一步)。
dword:即double word,可以理解为两倍的字的大小,我们知道一个汉字两个字节,那么两倍的就是四个字节。
rep stors:重复拷贝寄存器中的数据。
注意:esp永远指向栈顶,每次压栈后esp就会自动减去4个字节(因为栈区的使用是高地址向低地址使用),每次出栈后esp就会加上4个字节。
四、讲解
- 以此代码为标准进行详细说明:
#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;
}
1.main函数并不是最终函数
我们一直认为main函数结束,程序就会结束,main函数是程序的最后一个函数,你是否想过这样一个问题:为什么main函数会有返回值呢?那自然是因为main函数并不是最终函数,main函数也是被其他函数所调用的。我们转到调用堆栈就会观察到,如图:
- 那么到底是什么函数调用的main函数呢?
是__tmainCRTStartup函数调用的main函数。__tmainCRTStartup函数又是被mainCRTStartup函数调用的。
- 也就是main函数后其实还有两个函数,如图:
所画的图只表示调用顺序和栈帧使用顺序(先使用高地址在使用低地址)。
2.函数栈帧的创建
函数栈帧怎样创建,我们可以通过反汇编观察得到。要进入反汇编,首先要进入调试,然后按照图片操作即可进入,如图:
- main函数的栈帧创建
函数栈帧创建的逻辑基本一样,所以详细说一个怎么创建,其他就基本也是这样,这里以主函数举例。通过图一点点进行分析,如图:
所画的15个指令是在Add函数之前的汇编指令,前10个指令是创建主函数的栈帧空间并赋值为CCCCCCCC,这就是当我们不给一个变量赋值时,会打印出烫烫烫烫的原因。第11、12这两个指令是新增的,在VS2013上并没有,所以无视就行。那么从第13个指令开始,就该使用所开辟好的空间了。详细如下:
- 用ebp的值进行压栈。然后esp-4(没个元素都是4个字节,栈区的使用是从高地址向低地址使用的)。
- 把esp的值赋给ebp。
- 让esp减去0E4h这个十六进制数字。(目的是给main函数开辟空间,esp是维护栈顶的,所以要让esp移动到新开辟的栈顶位置)
- 用ebx的值进行压栈。
- 用esi的值进行压栈。
- 用edi的值进行压栈。
- load effective address,加载有效地址。放入edi中。
- 把9赋值到ecx中。
- 把CCCCCCCC赋值到eax中。
- 重复拷贝:从edi(加载的那个有效地址)开始,拷贝ecx次(9),每次拷贝eax(CCCCCCCC),每次拷贝的大小为dword(4字节)。每拷贝一次,edi(存放有效地址的这个寄存器都加4),ecx(存放拷贝次数的寄存器都减1),直到ecx减到0,停止拷贝,执行下一个汇编指令。
前十个是为main函数开辟空间并赋值为CCCCCCCC的汇编码。接下来才开始让我们写的程序分配空间。- 在ebp-8位置处赋值0Ah(也就是10).
- 在ebp-14h(ebp-20)位置处赋值14h(也就是20)
- 在ebp-20h(ebp-32)位置处赋值0
- 如图:
第一个图其实__tmainCRTStartup函数是被mainCRTStartup函数调用的,不过这里就不画了,我们主要是理解main函数及main函数内的函数调用如何在栈区开辟空间就好了,main函数之前的那两个函数知道有就行了。
这个图如果在VS2013是正确的,开辟的空间全部先初始化,但是在VS2022中,其实开辟的主函数的空间并没有全部赋值,如图:
- 最后一次拷贝后的内存展示:
3.函数调用
开辟空间前执行的操作:
- 函数调用的时候,我们都知道会开辟空间,但是他是怎么开辟的呢?
- 我们知道形参是实参的临时拷贝,那么它是怎样拷贝的呢?
- 我们知道函数结束后该函数所处的栈帧空间就要被销毁回收,那么如果该函数有返回值,明明已经被销毁了,它是怎样再返回去的呢?
接着往下看,你就会全部知道。
- 在调用函数这个操作中,刚开始进行的是让实参(实参地址中的内容那就是实参的数值)赋给eax,ecx,从右向左压栈,也就是先压栈b的值,再压栈a的值,这就是反汇编的前四行。其实这四行目的就是拷贝实参。第五行call指令是调用Add这个函数并且把调用Add函数后的下一个操作指令进行压栈,目的是为了在调用函数结束后,可以回到Add函数之后的下一步指令。
开辟空间及销毁前的操作:
- 汇编指令如图:
红色框不含黄色部分:
还是和之前main函数开辟一样,先是压栈ebp,然后让ebp移动到esp位置,然后esp减去一个数值(即开辟的空间大小),然后进行(部分)(不同编译器可能不一样,有可能使部分赋值,有可能是整个空间赋值)赋值,赋值为CCCCCCCC,然后就是为Add函数中的变量分配空间(如下图)。
红色框中的黄色部分:
这个部分是关于函数返回值的汇编指令,两个图结合看,ebp+8的值赋给eax(是寄存器,独立于内存,不会因为栈帧空间销毁而销毁),然后让eax的值加上ebp+0Ch这个地址的值,这个地址转换为十进制也就是ebp+12,再让eax的值赋给该函数中的变量z,所以函数的返回值也就暂时储存在了寄存器eax中(因此当z被销毁后,eax里还存着函数的返回值,等待之后的指令),那么你是否发现了,ebp+8其实就是在开辟函数空间前拷贝的10,ebp+12就是开辟空间前拷贝的20,所以eax就变成了30,也就是说函数使用的实参其实在自己内部空间根本就没有,他们只是通过地址访问了在开辟函数空间前压栈的那些形参,仅仅是使用而已,因此值传递不改变原数值。
销毁操作:
- 按照老编译器(老编译器中没有划掉的那三行)来说:
- 第1步:把z的值赋给eax,因为z要销毁了,但是返回值要被保留,所以要借助寄存器保留下来。
- 2~4步:pop指令,也就是出栈,最上面的三个元素,并把元素的值分别赋给edi、esi、ebx,其实这三个pop指令中的赋值操作没用,因为弹出的是edi,edi里边本来存的就是edi的值。其他两个也是这样。但是对于倒数第二行的pop指令中的赋值操作就用处很大了,接着看,等会会说到(第6步)。
- 第5步:把ebp的值赋给esp,那就是说esp离开了正在维护的函数的栈帧空间,那么这一部分就让函数栈帧空间被销毁了。
- 第6步:出栈,并且把出栈的值(是ebp指向main函数底部时压栈上去的那个地址,也就是说出栈的这个元素其实存放的是main函数的栈底指针)赋给寄存器ebp。那么ebp就回到原来的位置了。(main函数的栈底指针处)
- 第七步:ret,即返回,这一步也很重要,它的意义是弹出并回到弹出的这个地址所指向的指令,因为第六步我们只是让ebp回到了main的栈底位置,此时esp和ebp位置都就绪了,但是指令却还没有返回,而这一步其实就是弹出了调用函数之后的那个指令的地址(之前压栈上去的那个),并且回到弹出这个指令的位置,也就是调用函数的下一个指令的位置,那么一切便结束了,后面就是调用函数之后的操作了。
4.调用函数后的汇编指令:
- 最后两行指令:
- 目前的栈帧图:
第一个指令esp加8,那么就是说esp跳过了临时拷贝的变量,也就是销毁了临时拷贝形参的栈帧空间。
第二个指令就是把eax寄存器中存的函数的返回值赋到main函数中接收Add函数返回值的地址中,也就是变量c的位置,往上翻看看前面你就会发现变量c的地址就是eax要赋的地址处。
- 那么相信你看完函数栈帧的创建于销毁后对栈帧空间的运行原理有了更深层次的理解。
五、总结
✨请点击下面进入主页关注大魔王
如果感觉对你有用的话,就点我进入主页关注我吧!