【C语言加油站】函数栈帧的创建与销毁 #保姆级讲解

封面

导言

本篇内容为函数的补充知识点——函数栈帧的创建和销毁。
在本篇内容中,我们将会学习在函数篇章中未提到的一些知识点:

  • 局部变量是如何创建的?
  • 为什么创建局部变量时如果不初始化,局部变量的值会是随机值?
  • 函数是怎么传参的?传参的顺序又是什么?
  • 形参和实参有什么关系?
  • 函数是如何调用的?
  • 调用结束后又是如何返回的?

如果你对这些问题还是比较模糊的话,可以好好阅读一下本篇文章。本篇文章的内容会帮助大家进一步学习和理解C语言的相关知识点。

今天介绍的环境是VS2019,如果有朋友电脑上有安装VS2013、VS2010甚至是VC6.0的话,能够更加容易学习和观察函数栈帧的创建与销毁这一过程。

在不同的编译器下,这个过程会略有差异,具体的细节是取决于编译器的实现。我们只需要通过这一篇内容学习到这个过程实现的逻辑就OK了。接下来我们就开始进入正题吧!

在数组篇章我们有提到过一个概念——寄存器。当时我们只是简单提及了一下CPU在读取数据时的顺序是: 寄存器— > 高速缓存— > 内存 寄存器—>高速缓存—>内存 寄存器>高速缓存>内存寄存器究竟是个什么东西?为什么CPU在读取时优先读取它呢?下面围绕这两个问题我们来探讨一下;

一、计算机硬件

1.冯•诺依曼机基本思想

冯•诺依曼在研究 EDVAC 机时提出了“存储程序”的概念。1

“存储程序”的基本思想:

将实现编制好的程序和原始数据送入主存后才能执行,一旦程序被启动执行,就无序操作人员干预,计算机会自动逐条执行指令,直至程序执行结束。

“存储程序”的思想奠定了现代计算机的基本结构,以此概念为基础的各类计算机通称为冯•诺依曼机。

2.冯•诺依曼机的特点:

冯•诺依曼机有以下几个特点:

  • 采用“存储程序”的工作方式;
  • 计算机硬件系统由运算器、存储器、控制器、输入设备和输出设备5大部件组成;
  • 指令和数据以同等低位存储在存储器中,形式上没有区别,但计算机应能区分它们;
  • 指令和数据均用二进制代码表示。指令由操作码和地址码组成,操作码指出操作的类型,地址码指出操作数的地址。

3.存储器

3.1 分类

存储器分为主存储器(内存)和辅助存储器(外存)。

CPU能够直接访问的存储器是主存储器。

辅助存储器用于帮助主存储器记忆更多的信息,辅助存储器中的信息必须调入主存后,才能为CPU所访问。

3.2 内存的工作方式

主存储器的工作方式是按存储单元的地址进行存取,这种存取方式称为按地址存取方式。

3.3 内存的组成

内存最基本的组成是由 MAR 、存储体和 MDR 组成。
存储器的组成

  • 寄存器是用来存放二进制数据的,这里的MAR存放的是地址信息、MDR暂存的是从存储器中读或写的数据信息;
  • MAR和MDR虽然是存储器的一部分,但是在现代计算机中确实存在于CPU中的;另外前面提到的高速缓存(Cache)也存在于CPU中。

4.寄存器

寄存器的功能是存储二进制代码,它是由具有存储功能的触发器组合起来构成的。一个触发器可以存储1位二进制代码,故存放n位二进制代码的寄存器,需用n个触发器来构成。

按照功能的不同,可将寄存器分为基本寄存器和移位寄存器两大类。基本寄存器只能并行送入数据,也只能并行输出。移位寄存器中的数据可以在移位脉冲作用下依次逐位右移或左移,数据既可以并行输入、并行输出,也可以串行输入、串行输出,还可以并行输入、串行输出,或串行输入、并行输出,十分灵活,用途也很广。

4.1 基本含义

寄存器2是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果

寄存器是中央处理器(CPU)内的组成部分。寄存器是有限存储容量的高速存储部件,它们可用来暂存指令、数据和位址。

在计算机领域,寄存器是CPU内部的元件,包括通用寄存器、专用寄存器和控制寄存器。寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快。

4.2 寄存器的功能

寄存器最起码具备以下4种功能:

  1. 清除数码:将寄存器里的原有数码清除。
  2. 接收数码:在接收脉冲作用下,将外输入数码存入寄存器中。
  3. 存储数码:在没有新的写入脉冲来之前,寄存器能保存原有数码不变。
  4. 输出数码:在输出脉冲作用下,才通过电路输出数码。

仅具有以上功能的寄存器称为数码寄存器;有的寄存器还具有移位功能,称为移位寄存器。

4.3 工作原理

在计算机及其他计算系统中,寄存器是一种非常重要的、必不可少的数字电路构件,它通常由触发器(D触发器)组成,主要作用是用来暂时存放数码或指令。一个触发器可以存放一位二进制代码,若要存放N位二进制数码,则需用N个触发器。

寄存器应具有接收数据、存放数据和输出数据的功能,它由触发器和门电路组成

  • 只有得到“存入脉冲”(又称“存入指令”、“写入指令”)时,寄存器才能接收数据;
  • 在得到“读出”指令时,寄存器才将数据输出。

寄存器存放数码的方式有并行和串行两种

  • 并行方式是数码从各对应位输入端同时输入到寄存器中;
  • 串行方式是数码从一个输入端逐位输入到寄存器中。

寄存器读出数码的方式也有并行和串行两种

  • 在并行方式中,被读出的数码同时出现在各位的输出端上;
  • 在串行方式中,被读出的数码在一个输出端逐位出现。

4.4 分类

4.4.1 通用寄存器组

通用寄存器组包括AX、BX、CX、DX4个16位寄存器,用以存放16位数据或地址

可用作8位寄存器。用作8位寄存器时分别记为AH、AL、BH、BL、CH、CL、DH、DL。只能存放8位数据,不能存放地址
它们分别是AX、BX、CX、DX的高八位和低八位。若AX=1234H,则AH=12H,AL=34H。

通用寄存器通用性强,对任何指令,它们具有相同的功能。为了缩短指令代码的长度,在8086中,某些通用寄存器用作专门用途。例如,串指令中必须用CX寄存器作为计数寄存器,存放串的长度,这样在串操作指令中不必给定CX的寄存器号,缩短了串操作指令代码的长度。下面一一介绍:

AX(AH、AL):累加器

有些指令约定以AX(或AL)为源或目的寄存器。输入/输出指令必须通过AX或AL实现,例如:端口地址为43H的内容读入CPU的指令为INAL,43H或INAX,43H。目的操作数只能是AL/AX,而不能是其他的寄存器。

BX(BH、BL):基址寄存器

BX可用作间接寻址的地址寄存器和基地址寄存器,BH、BL可用作8位通用数据寄存器。

CX(CH、CL):计数寄存器

CX在循环和串操作中充当计数器,指令执行后CX内容自动修改,因此称为计数寄存器。

DX(DH、DL):数据寄存器

除用作通用寄存器外,在I/O指令中可用作端口地址寄存器,乘除指令中用作辅助累加器。

4.4.2 指针和变址寄存器
  • BP( Base Pointer Register):基址指针寄存器。

  • SP( Stack Pointer Register):堆栈指针寄存器。

  • SI( Source Index Register):源变址寄存器。

  • DI( Destination Index Register):目的变址寄存器。

这组寄存器存放的内容是某一段内地址偏移量,用来形成操作数地址,主要在堆栈操作和变址运算中使用。

BP和SP寄存器称为指针寄存器,与SS联用,为访问现行堆栈段提供方便。

  • 通常BP寄存器在间接寻址中使用,操作数在堆栈段中,由SS段寄存器与BP组合形成操作数地址即BP中存放现行堆栈段中一个数据区的“基址”的偏移量,所以称BP寄存器为基址指针。

  • SP寄存器在堆栈操作中使用,PUSH和POP指令是从SP寄存器得到现行堆栈段的段内地址偏移量,所以称SP寄存器为堆栈指针,SP始终指向栈顶。

注:今天我们研究的函数栈帧的创建与销毁就与BP和SP这两个寄存器密切相关。

寄存器SI和DI称为变址寄存器,通常与DS一起使用,为访问现行数据段提供段内地址偏移量。

在串指令中,其中源操作数的偏移量存放在SⅠ中,目的操作数的偏移量存放在DI中,SI和DI的作用不能互换,否则传送地址相反。在串指令中,SI、DI均为隐含寻址,此时,SI和DS联用,Dl和ES联用。

4.4.3 段寄存器

8086/8088CPU可直接寻址1MB的存储器空间,直接寻址需要20位地址码,而所有内部寄存器都是16位的,只能直接寻址6KB,因此采用分段技术来解决。将1MB的存储空间分成若干逻辑段,每段最长64KB,这些逻辑段在整个存储空间中可浮动。

8086/8088CPU内部设置了4个16位段寄存器,它们分别是:
代码段寄存器CS、数据段寄存器DS、堆栈段寄存器SS、附加段寄存器ES
由它们给出相应逻辑段的首地址,称为“段基址”。段基址与段内偏移地址组合形成20位物理地址,段内偏移地址可以存放在寄存器中,也可以存放在存储器中。

例如:代码段寄存器CS存放当前代码段基地址,IP指令指针寄存器存放了下一条要执行指令的段内偏移地址,其中CS=2000H,IP=001AH。通过组合,形成20位存储单元的寻址地址为2001AH。

代码段内存放可执行的指令代码,数据段和附加段内存放操作的数据,通常操作数在现行数据段中,而在串指令中,目的操作数指明必须在现行附加段中。

堆栈段开辟为程序执行中所要用的堆栈区,采用先进后出的方式访问它。

各个段寄存器指明了一个规定的现行段,各段寄存器不可互换使用

程序较小时,代码段、数据段、堆栈段可放在一个段内,即包含在64KB之内,而当程序或数据量较大时,超过了64KB,那么可以定义多个代码段或数据段、堆栈段、附加段。

现行段由段寄存器指明段地址,使用中可以修改段寄存器内容,指向其他段。有时为了明确起见,可在指令前加上段超越的前缀,以指定操作数所在段。

4.4.4 指令指针寄存器

IP8086/8088CPU中设置了一个16位指令指针寄存器IP用来存放将要执行的下一条指令在现行代码段中的偏移地址

程序运行中,它由BIU自动修改,使IP始终指向下一条将要执行的指令的地址,因此它是用来控制指令序列的执行流程的,是一个重要的寄存器。

8086程序不能直接访问IP,但可以通过某些指令修改IP的内容

例如,当遇到中断指令或调用子程序指令时,8086自动调整IP的内容,将IP中下一条将要执行的指令地址偏移量入栈保护,待中断程序执行完毕或子程序返回时,可将保护的内容从堆栈中弹出到IP,使主程序继续运行。
在跳转指令时,则将新的跳转目标地址送入IP,改变它的内容,实现了程序的转移。

4.4.5 标志寄存器

FR标志寄存器FR也称程序状态字寄存器。

FR是16位寄存器,其中有9位有效位用来存放状态标志和控制标志

  • 状态标志共6位,CF、PF、AF、ZF、SF和OF,用于寄存程序运行的状态信息,这些标志往往用作后续指令判断的依据。
  • 控制标志有3位,IF、DF和TF,用于控制CPU的操作,是人为设置的。

在简单了解了寄存器的相关知识点后,接下来我们就要开始介绍函数栈帧了。

二、函数栈帧的创建

函数栈帧的创建与维护是通过 bpsp 这两个寄存器实现的,在汇编语言中,这两个寄存器被称为 ebpesp
这两个寄存器存放的是地址,所以ebp又被称为栈底指针esp又被称为栈顶指针

接下来我们就通过下面这个代码来介绍一下 ebpesp 它们是如何创建和维护函数栈帧的;

int Add(int x, int y)
{
	int z = 0;
	z = x + y;
	return z;
}
int main()
{
	int a = 2;
	int b = 3;
	int c = 0;
	c = Add(a, b);
	printf("%d\n", c);
	return 0;
}

在这个代码中我们把每一步都细致的拆分出来了,下面我们就来通过这个代码来分析;

1.调试窗口与调用堆栈

为了更加清楚直观的看到main函数的调用过程与函数栈帧的创建过程,我们需要对这个代码进行调试。步骤如下:

  • 第一步:打开调试窗口

F 10 — > 调试— > 窗口— > 调用堆栈 F10—>调试—>窗口—>调用堆栈 F10—>调试>窗口>调用堆栈
调用堆栈窗口
按照上述步骤我们可以从堆栈窗口中看到三行内容,第一行显示的是现在调试的代码行,第二行显示的是外部代码,第三行不用管它;

  • 第二步:显示外部代码

单击鼠标右键— > 显示外部代码 单击鼠标右键—>显示外部代码 单击鼠标右键>显示外部代码
显示外部代码
这里建议大家可以同时勾选外部代码下面的帧状态,不勾选也没关系,不影响我们接下来的操作。在勾选完显示外部代码后我们会看到如图所示内容:

外部代码
可以看到我这里框选的代码在帧状态栏提示的是非用户代码,接下来,我们可以将鼠标移动到这四行内容的任意一行上来查看它的源代码;

  • 第三步:转到源代码

单击鼠标右键— > 转到源代码 单击鼠标右键—>转到源代码 单击鼠标右键>转到源代码
转到源代码
在做完上述步骤后,我们就能看到下图所示的窗口了:

源代码窗口
这里有一点需要注意,我们通过前三行内容打开的窗口和第四行内容打开的窗口是有区别的,至于为什么会有区别,咱们接着往下看;

2.main函数的调用

我们为了更好的观察main函数的调用过程,这里我们从第四句内容的源代码来进行分析:

main函数的调用1
在这个窗口中我们可以看到对于mainCRTStartup这个函数来说,它在函数体内调用了__scrt_common_main并将这个函数的值返回给自己。那这个__scrt_common_main函数的值又是什么呢?我们接着观察;
选择函数名— > 单击鼠标右键— > 转到定义 选择函数名—>单击鼠标右键—>转到定义 选择函数名>单击鼠标右键>转到定义
main函数的调用2
在完成了这一步操作后,我们就会看到这个界面:

main函数的调用3
在这个界面中我们可以看到,此时的__scrt_common_main函数它的值是__scrt_common_main_seh函数的值,我们接着重复刚才的操作,来观察一下__scrt_common_main_seh函数,看看它的值是什么;

main函数的调用4
这里代码比较多,我们不用关心其它内容是什么,我们先看这两个返回值,返回的都是这个局部变量的值,这个局部变量的值是invoke_main这个函数的值,接下来我们继续观察invoke_main这个函数;

main函数的调用5
现在我就可以看到了,对于incoke_main函数,它的值是main函数的返回值,那我们现在就清楚了原来一个main函数是经过这种层层调用来实现的,有细心的朋友就会发现,在调用堆栈窗口中已经给我们把这个调用关系给展示出来了:

main函数的调用
到这里我们就已经弄清楚了main函数的调用过程。在介绍函数递归时我们有介绍过,在进行函数调用时,函数就会在栈区申请一块空间,对于main函数也同样如此。如图所示:

main函数的函数栈帧
现在我们知道了main函数是被经过层层调用的,也就是说它在栈区中实际情况应该如下所示:

main函数的函数栈帧
现在我们已经了解了main函数的调用过程,接下来我们需要继续通过调试窗口来观察main函数的栈帧是如何创建的;

3.main函数的函数栈帧的创建

3.1反汇编

为了观察这个过程,接下来我们需要通过反汇编窗口来进行介绍,可以通过下述步骤进入反汇编窗口:
单击鼠标右键— > 转到反汇编 单击鼠标右键—>转到反汇编 单击鼠标右键>转到反汇编
反汇编窗口

在完成上述操作后,我们就可以进入到反汇编界面了:

反汇编
在这个界面这些反汇编语句我们应该如何理解呢?别着急,我们先复习一下前面提到的寄存器的内容:

通用寄存器组

  • AX(AH、AL):累加器。有些指令约定以AX(或AL)为源或目的寄存器。
  • BX(BH、BL):基址寄存器。BX可用作间接寻址的地址寄存器和基地址寄存器,BH、BL可用作8位通用数据寄存器。
  • CX(CH、CL):计数寄存器。CX在循环和串操作中充当计数器,指令执行后CX内容自动修改,因此称为计数寄存器。
  • DX(DH、DL):数据寄存器。除用作通用寄存器外,在I/O指令中可用作端口地址寄存器,乘除指令中用作辅助累加器。

指针和变址寄存器

  • BP( Base Pointer Register):基址指针寄存器。
    通常BP寄存器在间接寻址中使用,操作数在堆栈段中,由SS段寄存器与BP组合形成操作数地址即BP中存放现行堆栈段中一个数据区的“基址”的偏移量,所以称BP寄存器为基址指针。

  • SP( Stack Pointer Register):堆栈指针寄存器。
    SP寄存器在堆栈操作中使用,PUSH和POP指令是从SP寄存器得到现行堆栈段的段内地址偏移量,所以称SP寄存器为堆栈指针,SP始终指向栈顶。

  • SI( Source Index Register):源变址寄存器。

  • DI( Destination Index Register):目的变址寄存器。
    寄存器SI和DI称为变址寄存器,通常与DS一起使用,为访问现行数据段提供段内地址偏移量。在串指令中,其中源操作数的偏移量存放在SⅠ中,目的操作数的偏移量存放在DI中,SI和DI的作用不能互换,否则传送地址相反。在串指令中,SI、DI均为隐含寻址,此时,SI和DS联用,Dl和ES联用。

接下里我们来看一下这些操作指令的含义:

  • push——压栈:给栈顶放一个元素
  • mov——移动:将第二个对象的值赋值给第一个对象
  • sub——减:将第一个对象减少第二个对象的值
  • lea——load effective address——加载有效地址——将第二个对象的地址赋值给第一个对象
  • rep stos——进行重复的存储操作
  • call——调用

有了这些知识来做支撑,现在我们是不是就跟容易理解这些反汇编代码了。

3.2过程理解

现在我们通过监视窗口和内存窗口来同步理解这些汇编语句,打开这些窗口的步骤是一样的:
调试— > 窗口— > 监视 / 内存— > 监视 1 / 内存 1 调试—>窗口—>监视/内存—>监视1/内存1 调试>窗口>监视/内存>监视1/内存1
监视与内存窗口
我们打开这两个窗口之后还需要进行一步操作——取消显示符号名:

取消显示符号名
如上图所示。在执行完这些操作后我们就可以在监视窗口来观察我们需要观察的对象了。首先我们先观察 espebp

反汇编解读1
从监视窗口我们可以看到此时的 esp 指向的地址是0x00fcfbbcebp 指向的是0x00fcfbd8;按照前面我们分析的,此时的 espebp 指向的应该是incoke_main函数的栈顶和栈底:

反汇编解读2
接下来我们需要执行的是压栈操作,在压栈完, esp 指向的地址会发生变化:

反汇编解读4
从这里我们可以看到,此时 esp 指向的地址从0x00fcfbbc变成了0x00fcfbb8,它们之间的差值为4,也就是说这里我在栈顶放置了一个大小为4个字节的元素:

反汇编解读5
我们继续执行下一步操作。下一步进行的是mov,将esp的值赋值给ebp,也就是说此时ebp执行的地址会变成0x00fcfbb8,那是不是这样呢?我们接着往下运行:

反汇编解读6
可以看到,此时确实如我们理解的这样,栈底指针的地址现在执行的是栈底指针的地址,此时两个指针是重合的,紧接着我们可以看到,它的下一步执行的是sub操作,也就是将栈顶指针 esp 减少0E4h这么大的距离,我们也可以通过监视窗口来观察这个距离的大小:

反汇编解读7
可以看到,这个0E4h的值为228,也就是栈顶指针指向的值要减少228个字节,减少之后的地址就是栈顶指针新指向的地址:

反汇编解读8
可以看到 esp 新指向的地址为0x00fcfad4。那我们就能得到一块由 espebp 所指向的新空间:

反汇编解读9

这块新指向的空间是什么呢?别着急,我们接着往下看。
在得到新空间后,紧接着做了三次压栈操作,压入的对象分别是 ebxesiedi

我们通过监视窗口来看一下它们是谁:

反汇编解读10

可以看到它们三个的值分别是 ebx = 0x011a9000 esi = 0x00c41023 edi = 0x00c41023
也就是说,现在会在栈顶将这三个元素按顺序进行压栈操作,下面我们就来观察一下;

反汇编解读11
可以看到,这些值依次被压入栈顶,压入的空间大小为4个字节,也就是说此时 espebp 所指向的空间又增加了12个字节:

反汇编解读12
接下来我们继续来观察后面的步骤。
之后程序依次进行了lea——加载有效地址,这里是将ebp-24h这个地址赋值给了 edi 。这地址是多少呢?我们通过监视窗口来观察一下:

反汇编解读13
从监视窗口中我们可以看到24h的值转换成十进制其实是36,根据这里的空间为4个字节来看,总共相隔9个地址,从内存窗口中我们可以看到,确实与 ebp 的地址相隔9个地址的距离,我们来看一下执行之后会是什么效果;

反汇编解读14
紧接着程序执行了两次mov和一次rep stos。

  • 第一次mov——将9这个值给了 ecxecx 此时存放的空间数量为9;
  • 第一次mov——将0CCCCCCCCh这个值给了 eax
  • 随后执行了rep stos——将从edi开始的空间的值全部修改成0CCCCCCCCh,总共修改ecx个空间;

下面我们来接着往下运行看一下程序执行后是什么效果:

反汇编解读15
可以看到,此时这9个空间内的值都被修改成了0CCCCCCCCh
接着程序执行了mov——将0C4C006h这个值赋值给了 ecx
随后程序执行的是一次call——调用指令,它此时调用的是谁呢?我也不知道,所以我们可以通过F11来进一步观察一下:

反汇编解读16
此时我们可以看到,此时的call指令调用的是0x00C4131B这个地址中的指令,此时这个地址中的指令为jmp——跳转指令,它会跳转到哪里呢?我们继续观察:

反汇编解读17
原来call指令调用的是函数__CheckForDebuggerJustMyCode(unsigned char *JMC_flag)

这个函数我们可以简单的理解为只是对我们自己的代码进调试。在前面我们介绍main函数的调用时,我们有看到,一个main函数是需要经过层层调用的,这个函数的用途就是将调用main函数的这些源代码给屏蔽掉,只对我们自己的代码进行调试,所以这里我就不过多介绍了。现在我们回到我们的代码:

反汇编解读18
现在代码运行到了int a = 2;这一行,也就是说前面的过程都是在对main函数的栈帧进行创建,创建好的main函数的栈帧情况如下所示:

main函数的函数栈帧
现在main函数的栈帧也开辟好了,接下来就是要开始创建局部变量a、b、c了,这些局部变量又是如何创建的呢?我们接着往下看;

三、局部变量的创建

对于局部变量的创建,就没有像开辟main函数的空间那么复杂了:

	int a = 2;
00C418D5  mov         dword ptr [ebp-8],2  

	int b = 3;
00C418DC  mov         dword ptr [ebp-14h],3  

	int c = 0;
00C418E3  mov         dword ptr [ebp-20h],0 

从代码中我们可以看到,对于这三个局部变量,在创建时分别执行了一次mov指令,我们来看一下这个指令时是如何执行的:

  • 变量a
dword ptr [ebp-8],2
//dword——四个字节
//dword ptr——内存单元为四个字节的长度
//ebp-8——地址名
//2——移动对象

这里的意思就是将2这个值移动到ebp-8这个地址上;

  • 变量b
dword ptr [ebp-14h],3
//dword——四个字节
//dword ptr——内存单元为四个字节的长度
//ebp-14h——地址名
//3——移动对象

这里的意思就是将3这个值移动到ebp-14h这个地址上;

  • 变量c
dword ptr [ebp-20h],0
//dword——四个字节
//dword ptr——内存单元为四个字节的长度
//ebp-20h——地址名
//0——移动对象

这里的意思就是将0这个值移动到ebp-20h这个地址上;

从这个操作来看,局部变量的创建就是在main函数的函数栈帧中分配一个地址,并在这个地址中进行赋值操作,那具体是不是这样呢?我们现在来观察一下此时的内存空间的情况;

局部变量的创建
此时我们还未创建局部变量,我们现在可以观察到的是对应的这些地址的值为cccccccc
紧接着我们开始运行代码完成三个变量的创建,此时会发生什么情况呢?

局部变量的创建2
可以看到,经过mov操作后,这三块地址存储的信息就变成了2/3/0,此时这三个空间就是给变量a、b、c各自分配的空间;

四、随机值的由来

现在大家来思考一下,我们此时是给变量赋予了初始值,所以才有了这三个空间中的值被修改了,如果我要是在定义变量是不进行初始化,那此时变量的值又会是什么呢?
我相信现在大家都已经猜到了,没错就是cccccccc,这个值是我们在对main函数开辟空间是通过下述指令完成的:

00C418BC  lea         edi,[ebp-24h]  //将ebp-24h的地址赋值给edi
00C418BF  mov         ecx,9  //将9赋值给ecx
00C418C4  mov         eax,0CCCCCCCCh  //将0CCCCCCCCh赋值给eax
00C418C9  rep stos    dword ptr es:[edi] //从edi开始往下走ecx个地址,并将这些地址赋值eax的值

现在大家就知道这些随机值是怎么来的了吧。我们继续往下看;

五、函数传参

此时我们的变量也创建好了,接下来就是要调用Add函数了,我们来看一下此时程序做了哪些操作:

00C418EA  mov         eax,dword ptr [ebp-14h]  
00C418ED  push        eax  
00C418EE  mov         ecx,dword ptr [ebp-8]  
00C418F1  push        ecx  

它先通过mov指令将[ebp-14h]的值赋值给了 eax ,紧接着就进行了压栈操作;
随后又通过mov指令将[ebp-8]的值赋值给了 ecx ,紧接着又进行了压栈操作;
那具体是不是这样呢?我们来看一下运行结果:

函数传参1
从这个运行结果中可以看到,确实和我们分析的一样,此时的函数栈帧图像如下所示:

函数传参2

可以看到这个传参的过程其实是在main函数的栈帧中完成的,传参的顺序是从变量b开始,再到变量a。对应的代码c = Add(a, b);也就是说函数在传参时是按从右到左的顺序进行传参的;

六、函数调用

我们接着往后看:

函数调动1
可以看到此时要执行的是call指令也就是函数调用指令,这里我们通过F11来细致的观察:

函数调用2
我们现在已经进入了Add函数的内部,此时通过观察 esp 指向的地址我们可以发现,这个地址此时存储的值是call指令的下一条指令add的地址。为什么会这样呢?别着急,我们继续往下看;

在Add函数中我们可以看到此时执行的操作与main函数前面的操作一模一样,通过前面分析main函数可知,此时我们需要进行的操作时为Add函数开辟一块空间,这里我就不再重复演示开辟的过程了,我们直接来到创建临时变量z这一行;

函数调用3
此时我们就完成了Add函数的函数栈帧的创建,接下来我们就要进行局部变量z的创建与函数形参的使用了;

七、形参与实参

这里我们来分析一下代码:

	int z = 0;
00C417A5  mov         dword ptr [ebp-8],0  
//在Add函数栈帧中给变量z分配一块空间
//空间大小为4个字节
//空间内存放的内容为0
	z = x + y;
00C417AC  mov         eax,dword ptr [ebp+8]  
//将[ebp+8]的值赋值给eax
00C417AF  add         eax,dword ptr [ebp+0Ch]  
//将[ebp+0Ch]的值累加到eax
00C417B2  mov         dword ptr [ebp-8],eax 
//将eax的值赋值给[ebp-8]

此时的函数栈帧图像如下所示:

函数的形参与实参
通过它这几步的指令,我们现在可以理解了,原来前面将变量a和b的值赋值给 ecxeax 的操作原来是在给形参x、y在main函数的函数栈帧中分配空间啊。也就是说此时的形参x、y只是实参的一份临时拷贝而已,对形参的修改并不会影响实参。

我们在Add函数中使用它们的值的时候只是通过eax这个寄存器,将它们运算的值临时存储起来,然后将这个值赋值给在Add函数栈帧中创建的局部变量z。

接下来就是要回到main函数了;

八、函数栈帧的销毁

在回到main函数之前,程序执行了如下操作:

00C417B5  pop         edi  
00C417B6  pop         esi  
00C417B7  pop         ebx  
00C417B8  add         esp,0CCh  
00C417BE  cmp         ebp,esp  
00C417C0  call        00C41244  
00C417C5  mov         esp,ebp  
00C417C7  pop         ebp  
00C417C8  ret  

这里涉及到的操作含义如下;

  • pop——出栈:从栈顶删除一个元素
  • add——累加:将第二个对象的值累加到第一个对象中
  • cmp——cmp是比较指令, cmp的功能相当于减法指令,只是不保存结果。cmp指令执行后,将对标志寄存器产生影响。其他相关指令通过识别这些被影响的标志寄存器位来得知比较结果。
  • call——调用函数指令
  • mov——移动:将第二个对象的值赋值给第一个对象
  • ret——子程序的返回指令

我们应该怎么理解这些指令呢?
对于pop指令,我们可以理解为释放空间,或者说是销毁空间,这里我们可以看到连续三个pop指令它的命令对象分别是 ediesiebx ,我们来看一下执行完这三条指令会发生什么现象:

函数的销毁1
现在我们还没有执行指令,我们先将下列信息记录下来:

  • 栈顶地址:0x00FCF9E0
  • esi 地址:0x00FCF9E4
  • ebx 地址:0x00FCF9E8
  • edi 地址:0x00FCFAB8
  • ebp 地址:0x00FCFAB8

接下来我们开始执行pop操作:

函数的销毁2
可以看到,此时的栈顶释放掉了一个空间,edi的值也发生了变化。继续进行pop;

函数的销毁3
在经过第二次pop后,此时的栈顶再一次释放掉了一个空间,我们继续执行pop;

函数的销毁4
经过第三次pop后,此时的栈顶再一次释放掉了一个空间,也就是说pop指令时来释放空间的,此时的函数栈帧情况如下:

函数的销毁5
从图中我们可以看到,现在的Add的函数栈帧只剩下最开始的204个字节大小的空间了,我们来看一下这个空间又是如何释放的;

函数的销毁6

接下来进行的是cmp和call操作,我们来观察一下此时会出现什么情况;

函数的销毁7
在这里call指令调用的是函数_RTC_CheckEsp(void),这个函数是用于验证 esp 的正确性。它被调用以确保esp的值再函数调用中保存。它的实现过程就是通过上图中的这些程序,这里我们就不展开讨论了,知道有这么个东西就行;

函数的销毁8
接下来我们继续执行就会发现,此时 espebp 指向的地址都发生了变化,此时ebp指向的地址大家还有印象吗?我们来看一下它们此时分别指向的是哪里:

函数的销毁9

从图中我们可以看到,原来刚刚释放的空间是存放main函数的栈底地址的空间,在这个空间被释放后,esp指向了存放0x00c418f7这个地址的空间,下一步就是通过ret指令返回main函数了。

大家还记得0x00c418f7这个地址的含义吗?我们继续执行来看一下此时函数会返回到哪里:

函数的销毁10
可以看到,此时又有一块空间被释放了,函数在经过ret指令后返回的地方就是刚才释放的空间中存储的地址,也就是call指令的下一条指令的地址。
大家现在有没有发现汇编语言的逻辑的严谨之处。

  • 在调用Add函数之前,先通过call指令将下一条指令的地址存放在一个新的空间中,这是为了在调用结束后能够通过ret正确返回而进行的一步操作;
  • 在创建Add函数之前,通过push指令将main函数的栈底地址存放在新的空间中,这样我们在调用完函数后能够通过pop指令在释放Add函数的函数栈帧时找到main函数的栈底地址。

以上就是函数栈帧销毁的整个过程,通过pop、ret指令来释放函数栈帧的空间。现在已经回到主函数了,我们还有一个问题没有解决,Add函数的值是如何返回到main函数中的?下面我们接着观察;

九、函数的返回值

在回到main函数后,我们可以看到此时的程序需要执行两个指令:

00C418F7  add         esp,8  //将esp增加8
00C418FA  mov         dword ptr [ebp-20h],eax//将eax的值赋值给[ebp-20h]

我们来看一下这两步的作用是什么:

函数的返回值1
此时还未执行这两步操作,我们可以从图中观察到此时的 esp 指向的空间是形参x的空间,通过 esp + 8,我们可以得到的地址是存放0x00c41023的空间地址;而mov操作中的 ebp - 20h找到的地址是变量c的地址。

函数的返回值2

此时函数栈帧的情况如下所示:

函数的返回值3

我们现在可以对这两步做个小结:

  • 经过add这一步,程序将形参的空间给释放了;
  • 再经由mov这一步,将存储在 eax 中的值成功赋值给了变量c。
  • 对于Add函数而言,它所谓的返回值其实并不是从局部变量z中进行返回的,而是通过 eax 这个寄存器对操作的值进行临时记录从而达到返回值的效果。所以在Add函数中对于这个局部变量c是可有可无的,这也解释了为什么我们可以在定义Add函数时可以直接写成return x + y;这种形式了,因为此时的返回值是直接存储在寄存器 eax 然后回到main函数后再使用 eax 存储的值,而不是存放在局部变量中,将局部变量的值返回给主函数了。

那到目前为止,函数栈帧的创建与销毁过程我就全部介绍完了,后面还有涉及到printf函数的调用与main函数的函数栈帧的销毁我就不过多叙述了。如果各位还有何疑问的话可以在评论区留言哦!

结语

今天的内容到这里就全部结束了,本篇内容是函数篇章的一个补充知识点,这一部分内容对各位在C语言学习的理解上也会有很大的帮助。感兴趣的朋友可以自己下去后按照文章内容从头到尾一步一步的走一遍,这样你能够更加容易理解文章中对于一些知识点的描述。
喜欢本文的朋友可以点赞、留言、转发来支持一下博主。最后感谢各位的翻阅,咱们下一篇再见!!!


  1. “存储程序”的概念:将指令以二进制代码的形式事先输入计算机的主存储器中,然后按其在存储器中的首地址执行程序的第一条指令,以后就按该程序的规定顺序执行其他指令,直至程序执行结束。 ↩︎

  2. 其实寄存器就是一种常用的时序逻辑电路,但这种时序逻辑电路只包含存储电路。寄存器的存储电路是由锁存器或触发器构成的,因为一个锁存器或触发器能存储1位二进制数,所以由N个锁存器或触发器可以构成N位寄存器。 ↩︎

  • 56
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值