【函数栈帧的创建与销毁】函数是如何传参如何调用

一、相关基础知识介绍

1.压栈与出栈含义

push 压栈:给栈顶放一个元素。

pop 出栈:给栈顶删除一个元素。

2.寄存器是什么 

寄存器是CPU内部用于存放是数据的小型存储区域,用于存放暂时运算的数据和运算结果的。

3.寄存器的种类以及分类

寄存器一般寄存器AX累计存储器
BX基底存储器
CX计数存储器
DX资料存储器
索引寄存器SI来源索引存储器
DI目的索引存储器
堆叠、基底暂存器SP堆叠、指标存储器
BP基底、指标存储器

而这里我们所需要了解的是EAX、EBX、ECX、EDX、ESI、EDI、ESP、EBP这几类寄存器。这些都可以理解是上述寄存器的延伸。

EAX

累加(accumulator)寄存器,相对于其他寄存器,在运算方面比较常用。

EBX

基地址(base)寄存器,在内存寻址时存放基地址。

ECX

计数(counter)寄存器,用于循环操作,比如重复的字符存储操作,或者数字统计。

EDX

作为EAX的溢出寄存器,总是被用来放整数除法产生的余数。

ESI

源变址寄存器,主要用于存放存储单元在段内的偏移量。通常在内存操作指令中作为“源地址指针”使用。

EDI

目的变址寄存器,主要用于存放存储单元在段内的偏移量。在很多字符串操作指令中:DS:ESI指向源串;EDI指向目标串。

EIP

控制寄存器,存储CPU下次所执行的指令地址(存放指令偏移地址)。

ESP

栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,esp也就越来越小。在32位平台上,esp每次减少4字节。栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。是CPU机制决定的,push、pop指令会自动调整esp的值。

EBP

基址指针寄存器(extended base pointer),一般与esp配合使用,可以存取某时刻的esp,这个时刻就是进入一个函数内后,CPU会将esp的值赋给ebp,此时就可以通过ebp对栈进行操作,比如获取函数参数,局部变量等。其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

4.ESP(栈顶指针)与EBP(栈底指针)

在这里我们着重注意一下ESP与EBP之间的搭配使用。这两个寄存器中存放的是地址,是用来维护函数栈帧的。

那么它是如何来维护的呢?

我们知道,每一个函数调用,都要在栈区创建一个空间。

#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;
}

比如我们要调用main函数时,(如下图)那么ESP(栈顶指针)与EBP(栈底指针)就来维护main函数的函数栈帧。

而这两个寄存器是程序需要调用哪个函数,它们就会去调用哪个函数。例如,在调用Add(int x,int y)函数时,ESP(栈顶指针)与EBP(栈底指针)就会去维护Add函数的函数栈帧。

 5.整体函数调用栈区空间的思路轮廓

在调试过程中,我们可以观察到main函数其实也是被其他函数所调用的。那么main函数究竟是被谁调用的呢?进入main函数内部可以发现,main函数时被_tmainCRTStartup,而_mainCRTStartup是被mainCRTStartup调用的。

所以如下图,栈区有mainCRTStartup、_mainCRTStartup、main、Add函数的函数栈帧。其中EBP与ESP是动态变化的。

6.常见的汇编指令

push指令首先减少esp的值,再将源操作数复制到栈地址,在32位平台上,esp每次减少4字节。
pop指令首先把esp指向的栈元素内容复制到一个操作数中,再增加esp的值。在32位平台上,esp每次增加4字节。
mov指令用于将一个数据从源地址传送到目标地址,源操作地址的内容不变。
sub指令减操作指令,从寄存器中减去<shifter_operand>表示的数值,并将结果保存到目标寄存器中。
lea指令是“load effective address”的缩写,简单的说,lea指令可以用来将一个内存地址直接赋给目的操作数。
rep指令重复前缀指令,英文缩写 repeat。能够引发其后字符串指令被重复。
stos指令串存储指令,英文缩写 store string。
call指令将程序下一条指令的位置的IP压入堆栈中,并转移到调用的子程序
jmp指令无条件跳转指令。
add指令用于将两个运算子相加,并将结果写入第一个运算子。
ret指令用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。

二、举例演示函数栈帧的创建与销毁

温馨提示:函数栈帧的创建与销毁在不同的编译器条件下,实力有差异的,大体的逻辑是一致的,其具体的细节取决于编译器的实现。本次使用的环境是VS2022。

上面我们说了分别有四个函数调用,分别是mainCRTStartup、_mainCRTStartup、main、Add,那么我们现在就来详细的讲述一下,它们之间是如何调用的。

1.为main()函数开辟栈帧

写好代码之后,我们按F10进入到调试阶段,然后右击鼠标选择<反汇编>,这样我们就进入了汇编代码中。

为了方便观察地址,我们将这里的符号名不显示。

然后我们逐步阅读语句:

  • push ebp:将ebp压入栈

现在程序已经进入了main函数里面,那么_mainCRTStartup函数一定被创建好了它的栈帧,如图所示:

此时在运行之前监视观察到ebp与esp的值为下面:

现在我们按F10,进入第一条语句, push ebp之后,那么push ebp的意思就是将ebp压入栈,其次在64位中esp减少2个字节,再次观察ebp的值为:

 其中动画演示为:

  • mov    ebp,esp:将esp的值赋给ebp

此时我们可以看到ebp从原来的 0x00affeec 变成了与esp相同的 0x00affecc:

  •  sub    esp,0E4h:将esp减去0E4h的值,赋给esp

 此时我们可以看到ebp从原来的 0x00affecc 变成了 0x00affde8 。

上面两个过程的动图如下:

  •  push    ebx;push    esi;push    edi:将ebx、esi、edi依次分别压栈

这三句现在应该就比较易懂了。首先都是将ebx、esi、edi依次分别压栈,其中每push一次,esp就减少一次。

其中在运行之前ebx、esi、edi的值分别如下图:

 现在我们按F10三次,进入三条语句之后,每进行一个push,则esp就减少一次:

ebp从原来的 0x00affde8 变成了 0x00affddc 。

动图如下:

 为了看起来方便,在这里放一张变化之后的固定图:

  •  lea    edi,[ebp-24h]、mov    ecx,9、mov    eax,0CCCCCCCCh、rep stos    dword ptr es:[edi]:

 这四行作为一个有效程序:

 lea的意思:load effective address 加载有效地址

为了显示方便,单击右键,选择<显示符号名>,则会变为lea    edi,[ebp-0E4h]。

那么[ebp-0E4h]是什么呢?我们再来回顾一下刚刚的图:

根据上图,我们易得[ebp-0E4h]的位置。

其次,dword是double Word,一个Word是2个字节,而dword则是4个字节。

所以,翻译一下上面四句:将从ebp-24h(ebp-0E4h)赋值给edi,从edi开始向下的 9 位的4个字节都修改为是 CCCCCCCC ,如图:

 运行结果如下:

2.在main()函数中创建变量

上面所有目前只是在为main函数预备空间栈帧,接下来才是进行有效的代码。

  • mov    dword ptr [ebp-8],0Ah:将0Ah(10)存放到ebp-8中

  • mov    dword ptr [ebp-14h],14h:将14h(20)存放到ebp-14h中

  • mov    dword ptr [ebp-20h],0:将0存放到ebp-20h中

其中这三行的图示如下:

所以a、b、c的局部变量是如何创建的呢?

简单来说就是:先要为我的main函数的调用创建函数栈帧,有了这个函数栈帧之后,在它的函数栈帧里面找到一些空间,把a、b、c放进去。 

3.调用Add()函数前的准备

在局部变量创建好之后,我们就开始调用Add函数,函数调用时是需要传参的。那么它是如何来传参的呢?我们具体来看一下:

  • mov    eax,dword ptr [ebp-14h]:将ebp-14h的值赋给eax

  • push eax:将eax压入栈中

  • mov    ecx,dword ptr [ebp-8]:将ebp-8的值赋给ecx

  • push ecx:将ecx压入栈中

 我们先来看一下ebp-14在前面易得其实是b的值为20,而ebp-8其实是a的值是10。所以就是将b的值赋给eax,然后将eax压入栈中;将a的值赋给ecx,然后将ecx压入栈中。

其中动态图如下:

 为了看起来方便,在这里放一张变化之后的固定图:

4.为Add()函数开辟栈帧

现在我们真正的来到了Add函数之中:

当然与main函数一样,在调用函数之前需要创建栈帧,然而Add函数也不例外。其实从下面的图中,我们可以发现Add函数栈帧的创建其实与main函数的创建大同小异,基本都是相同的。简单解释一下:

  • push    ebp:将ebp压入栈中,伴随esp减少4个字节;

  • mov    ebp,esp:将esp的值赋给ebp,伴随esp减少4个字节;

  • sub    esp,0CCh:esp减去0CCh,意味着esp再次上移;

  • push    ebx:将ebx压入栈中;伴随esp减少4个字节;

  • push    esi:将esi压入栈中;伴随esp减少4个字节;

  • push    edi:将edi压入栈中;伴随esp减少4个字节;

  • lea    edi,[ebp-0Ch] :load effective address加在有效地址;

  • mov    ecx,3、mov    eax,0CCCCCCCCh:将其中的从edi开始到eax都放入CCCCCCCC

 动态图如下:

 为了看起来方便,在这里放一张变化之后的固定图:

5.在Add()函数中创建变量并运算

完成了Add函数栈帧的创建,现在我们进入到了Add执行函数的过程:

  • mov    dword ptr [ebp-8],0:建立z的临时变量;

  • mov    eax,dword ptr [ebp+8]  :将ebp+8的值赋值给eax;

  • add    eax,dword ptr [ebp+0Ch]  :给eax的值加上ebp+0Ch的值;

  • mov    dword ptr [ebp-8],eax  :将eax的值赋值给ebp-8。

由上图易得,ebp+8是10,ebp+0Ch是20,所以eax是30。然后将eax的值赋给ebp-8(Z),则计算出Z的值是30。

函数是如何传参的呢?

在进入Add函数之前,我们就将b与a的值(x与y的值)压入栈中,在处理好Add函数的栈帧之后,计算时,是找回之前压入栈中的值来进行计算。

所以,形参是实参的临时拷贝。

6.Add()栈帧的销毁

算出了最终的结果,那么我们要如何将结果带回来呢?

我们知道在函数调用之后栈帧就会销毁,那么z的值,我们要如何带回来呢?

  • mov    eax,dword ptr [ebp-8]:将ebp-8存储到eax寄存器中;

  • pop    edi:给栈顶删除edi,伴随esp增加4字节(esp向下)

  • pop    esi:给栈顶删除esi,伴随esp增加4字节(esp向下)

  • pop    ebx:给栈顶删除ebx,伴随esp增加4字节(esp向下)

其中动态图为:

  • ret:直接返回call指令的下一条指令

而这里的call的目的就是为了在完成Add函数调用之后,还能接着再回来接着进行。回来之后还可以从call指令的下一条指令继续执行。

为了看起来方便,在这里放一张变化之后的固定图:

7.返回main()函数栈帧

由上图得,现在就是main函数栈帧的销毁:

  •  add    esp,8:给esp加上8,相当于销毁形参x与y

  •  mov    dword ptr [ebp-20h],eax:将eax(寄存器的值)赋给ebp-20h

在上图我们可以清晰地发现ebp-20h其实就是c,所以将结果赋值给c。最后就是打印结果。main函数栈帧的销毁与Add函数栈帧销毁大同小异,逻辑都是相似的。

三、总结一下

1.局部变量是怎么创建?

首先为函数分配栈帧空间,栈帧空间初始化之后,然后给局部变量在这个栈帧里分配一点空间。

2.为什么局部变量的值是随机值?

随机值是因为,栈帧里的变量是初始化的,随机值是我们自行放进去的。

3.函数是怎么传参的?传参的顺序是怎么样的?

其实在还没有调用函数时,就已经push了参数,从右向左开始压栈,当真正进入形参函数Add时,通过指针的偏移量找到形参。

4.形参与实参的关系是什么?

形参是在压栈的过程中开辟的空间,与实参的值是相同的,空间是独立的,所以形参是实参的临时拷贝,改变形参不会影响实参。

5.函数调用是怎么做的?

当有多个函数相互调用时,按照后调用先返回的原则,函数之间信息传递和控制转移必须借助栈来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数,将在栈顶分配一个存储区,进行压栈操作,每当一个函数退出时,就释放他的存储区,进行出栈操作,当前运行的函数永远都在栈顶位置。

6.函数调用的结果是如何返回的?

在调用之前,就把call指令下一条指令压进去了,当往回返时,就可以跳转到call指令下一条指令的地址,让函数调用的值可以返回,返回值的带回方式是利用寄存器将它带回。

今天就学到这里,我们下次见啦~~~

千般荒凉,以此为梦;万里蹀躞,以此为归。

  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 17
    评论
评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

安心学编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值