从0开始----函数栈(zhan)帧的创建和销毁

概要

首先这篇文章会有点长有点晦涩难懂,并且这些是你一般在课堂上听不到的,这篇非常详细,所以大家感兴趣的给自己留点时间去理解,一但能理解了,c语言大部分就逻辑都可以了解了(此次使用的是小熊猫c++)
函数栈帧的创建和销毁是程序执行过程中的核心环节,它们直接影响了程序的运行效率和内存管理。在深入探讨这两个过程之前,我们需要先理解什么是函数栈帧。
函数栈帧,也可以称为函数调用栈帧,是计算机在执行函数时为其分配的一块内存区域。每当一个函数被调用时,一个新的栈帧就会被创建并压入调用栈中。这个栈帧包含了函数的局部变量、参数、返回地址等信息,为函数的执行提供了必要的环境。

预备知识

由于函数栈帧要从汇编的角度讲解,所以先介绍一些简单汇编指令以及一些通用寄存器

  • eax,通用寄存器,保存临时数据,常用于函数返回值

  • ebx,通用寄存器,保存临时数据

  • ebp,栈底寄存器,保存栈底的地址

  • esp,栈顶寄存器,保存栈顶的地址

  • eip,指令寄存器,保存需要执行的下一条指令

  • mov,数据转移指令

  • push,将数据入栈,同时修改esp的内容,使其始终指向栈顶

  • pop,将数据出栈,同时修改esp的内容,使其始终指向栈顶

  • sub,add,给数据做减法与加法

  • call,函数调用指令,将函数调用完成需要指向的下一条指令入栈,并且跳转到函数地址

  • jump,修改eip寄存器,使程序跳转到eip的地址处

  • ret,恢复返回地址,将当前栈顶的地址出栈并把数据保存到eip中,类似pop eip

单执行流的程序运行后,其ebp和esp之间就形成了一个栈帧结构,最开始的函数栈帧肯定是main函数的,之后main函数可能调用其他函数,此时就要重新建立栈帧结构,函数执行完会返回到调用它的函数中,向下继续执行其他代码,所以建立的栈帧结构需要被销毁。

一, 什么是函数栈帧

我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。 那函数是如何调用的?函数的返回值又是如何返回的?函数参数是如何传递的?这些问题都和函数栈帧有关系。

函数栈帧(stack frame)就是函数调用过程中程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:

  1. 函数参数和函数返回值
  2. 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量
  3. 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)

二,理解函数栈帧能解决什么问题

理解函数栈帧有什么用呢?
只要理解了函数栈帧的创建和销毁,以下问题就能够很好的额理解了:

  • 局部变量是如何创建的?
  • 为什么局部变量不初始化内容是随机的?
  • 函数调用时参数时如何传递的?传参的顺序是怎样的?
  • 函数的形参和实参分别是怎样实例化的?
  • 函数的返回值是如何带会的?

让我们一起走进函数栈帧的创建和销毁的过程中,去理解函数栈帧的真谛!

三,函数栈帧的创建和销毁解析

(一),什么是栈

要搞清楚这个概念,首先要明白“栈”原来的意思,如此才能把握本质。
栈,存储货物或供旅客住宿的地方,可引申为仓库、中转站,所以引入到计算机领域里,就是指数据暂时存储的地方,所以才有进栈、出栈的说法。
堆栈是计算机科学中的一种抽象数据类型,只允许在有序的线性数据集合的一端(称为堆栈顶端,top)进行插入数据(PUSH)和删除数据(POP)的运算。
首先,系统或者数据结构栈中数据内容的读取与插入(压入)PUSH和删除POP是两回事。 压入是增加数据,弹出是删除数据
,这些操作只能从栈顶即最低地址作为约束的接口界面入手操作 ,但读取栈中的数据是随便的,没有接口约束之说。
很多人都误解这个理念从而对栈产生困惑。 而系统栈在计算机体系结构中又起到一个跨部件交互的媒介区域的作用即CPU 与内存的交流通道
,CPU只从系统提供用户自己编写的应用程序所规定的栈入口线性地读取执行指令, 用一个形象的词来形容它就是pipeline(管道线、流水线)。
CPU内部交互具体参见 EU与BIU的概念介绍。 栈作为一种数据结构,是一种只能在一端进行插入和删除操作的特殊线性表。
它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。
栈具有记忆作用,对栈的插入与删除操作中,不需要改变栈底指针。 栈是允许在同一端进行插入和删除操作的特殊线性表。
允许进行插入和删除操作的一端称为栈顶(top),另一端为栈底(bottom);栈底固定,而栈顶浮动;栈中元素个数为零时称为空栈。
插入一般称为进栈(PUSH),删除则称为出栈/退栈(POP)。栈也称为先进后出表。
栈可以用来在函数调用的时候存储断点,做递归时要用到栈。 以上定义是在经典计算机科学中的解释。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。
在i386机器中,栈顶由称为esp的寄存器进行定位。压栈的操作使得栈顶的地址减小,弹出的操作使得栈顶的地址增大。
栈在程序的运行中有着举足轻重的作用。最重要的是栈保存了一个函数调用时所需要的维护信息,这常常称之为堆栈帧或者活动记录。堆栈帧一般包含如下几方面的信息:
1.函数的返回地址和参数 2. 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。

(二),解释函数栈帧的创建和销毁

函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2022(Debug下X_86环境)为例。(由于c++兼容绝大部分c语言的语法所以我这里就默认使用了编译器的.cpp)
在这里插入图片描述
代码用一个简单的整数加法程序如下:

#include <stdio.h>

int add(int i, int j)
{
	int m = 0;
	m = i + j;
	return m;
}
int main()
{
	int a = 10;
	int b = 20;
	int c = 0;
	c = add(a, b);
	printf("%d\n", c);
	return 0;
}
1.打上断点,按F5进入调试模式

在这里插入图片描述
这样就相当于进入调试模式了。

2.观察函数调用堆栈

函数调用堆栈是反馈函数调用逻辑的

  • 关于没有调用堆栈窗口的可以在调试那找,具体如下:
    在这里插入图片描述

按图片上的步骤去找,一定能找到,找不到的可能是盗版。

  • 然后右击调用堆栈窗口,找到显示外部代码,如图所示:
    在这里插入图片描述
  • 点击后显示如下(可以对照一下和上图的区别):
    在这里插入图片描述
  • 然后我们进行逐过程的调试进入到add函数内部,快捷键F11(不知道的可以看看这篇文章有详细解释vs常用快捷键
    在这里插入图片描述
    根据这个我们就知道add函数是由main函数调用的
    函数调用堆栈是反馈函数调用逻辑的,那除此之外我们可以清晰的观察到:
    main 函数调用之前,是由invoke_main 函数来调用main函数的。 invoke_main 是一个 Microsoft C/C++ 运行时库中的函数,用于调用程序的主函数(main函数),不过在 invoke_main 函数之前的函数调用我们就暂时不考虑了,但是我们可以确定, invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也有自己的栈帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间
    那接下来我们从main函数的栈帧创建开始讲解:

那对于函数栈帧创建和销毁过程的研究这里我们要借助反汇编来观察和分析:

  • 调试到main函数开始执行的第一行,右击鼠标转到反汇编。
    在这里插入图片描述
    这里值得注意的是VS编译器每次调试都会为程序重新分配内存,我们下面展示的反汇编代码是一次调试代码过程中数据,每次调试略有差异。
    在这里插入图片描述
    (为了方便分析和观察,我们可以做这样一件事情: 右击反汇编窗口把显示符号名取消勾选)
    在这里插入图片描述

取消勾选后是这样的界面
在这里插入图片描述

因为我们接下来要去观察具体的地址、内存的布局
在这里插入图片描述

(三),main函数栈帧的创建

根据上面调用堆栈我们可以了解到main函数也是由其他函数调用的,main函数是被invoke_main 调用的。
所以在main函数被调用之前,invoke_main 的栈帧就应该是这样的:
在这里插入图片描述
函数栈由高地址端向低地址端延伸,并且栈顶指针比栈基指针的地址低
接下来开始分析我们的代码:(按F10进入调试状态)
首先,我们看main函数里面的第一条汇编
在这里插入图片描述

是push ebp(前面的一串数字是该条汇编指令的地址) 那push ebp做了什么事情呢? push就是压栈,所以push ebp就是把ebp寄存器进行压栈,在栈顶放一个ebp。 那与此同时,栈里面多了一个数据,栈顶的位置就要发生变化,会多出一个ebp,如下所示
在这里插入图片描述
多出一个ebp同时esp的位置也会移动因为它要继续指向栈顶所以它往上走也就是esp的地址会减小,这时我们可以观察一下esp是否移动,在先按F10进入调试状态
在这里插入图片描述
先观察没有进行压栈下esp的地址
在这里插入图片描述

如果地址不是像我这样的16进制显示的可以右击然后点击以16进制显示

在这里插入图片描述
然后按F10下一步

在这里插入图片描述
我们发现esp的地址变了,变成0x008ff9dc ,大家可以算一下,差了4个字节,所以我们当前的平台下,其实esp寄存器的大小就是4字节。 当然我们还可以通过内存窗口看一下它是否真的压进去了: 因为现在esp指向的空间里前4个字节放到就是ebp的值
在这里插入图片描述
我们把esp的地址输进去然后就会发现(右边方框倒着读和edp一样证明把ebp压进去了)
在这里插入图片描述
在这里插入图片描述

看来我们预测的是没问题的,让我们继续往下分析,
在这里插入图片描述
mov ebp,esp move是把将第二个操作数的值给第一个操作数。 那这里就是把esp的值给ebp,那这样ebp和esp就指向同一个位置了(这里ebp的esp是重合的,但我们为了观察方便我们就弄成下图了,希望大家理解一下,并且原来的ebp是不会消失的),
在这里插入图片描述

没进行mov的时候ebp的地址如下:
在这里插入图片描述
进行mov后ebp的地址如下:
在这里插入图片描述我们可以看到esp和ebp的地址相同了,证明我们上面的猜测是正确的。然后我们继续分析下一句
在这里插入图片描述
sub esp,0E4h sub指令用于两个操作数相减,相减的结果保存到第一个操作数中。 所以这里就是给esp的值减去0E4h 它是一个8进制数字,我们可以在监视窗口中看一下0E4h的大小
我们先取消16进制显示在这里插入图片描述
观察完后记得重新勾上16进制显示
给esp减去一个值后,它的值发生变化,同时指向的位置也发生变化,我们先给出未变化的时候的值
在这里插入图片描述
然后再给出变化后的值
在这里插入图片描述
esp的地址减小就会指向地址更低的一个位置,如下所示
在这里插入图片描述
那现在我们看到,ebp和esp好像又维护了一块新的空间。 那这块空间是给谁用的呢? ,我们现在已经开始调用main函数了,所以ebp和esp新维护的这块空间其实就是给main函数开辟的空间,也就是main函数的栈帧

在这里插入图片描述
所以,我们的程序中正在调用哪个函数,ebp 和 esp维护的就是哪个函数的栈帧
总结一下: sub会让esp中的地址减去一个16进制数字0xe4,产生新的esp,此时新的esp就是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一块新的栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据以及调试信息等。
我们继续分析
在这里插入图片描述
接着再往下呢我们发现是3次push push ebx 将寄存器ebx的值压栈,push esi 将寄存器esi的值压栈, push edi 将寄存器edi的值压栈,这三次压栈大家可以不必纠结,只知道压进这三个东西就行啦(注意每压一次esp都会动一次这里就不一一证明了),现在让我们画出push后的图
在这里插入图片描述
我们继续往下分析
在这里插入图片描述

这几条我们放在一块看 首先lea edi,[ebp-24h] lea呢叫做Load Effective Address即加载有效地址。
所以这句指令呢其实就是把[ebp-24h]对应的地址放到edi里面
然后mov ecx,9即把9放在ecx中
接着mov eax,0CCCCCCCCh把0xCCCCCCCC放在eax中 上面这3步之后,这里真正起作用的其实就是rep stos dword ptr es:[edi]这一句: 这一句是干嘛呢? ,它是把从[ebp-24h]这个位置开始向上的9个dword(4字节)直到ebp的内容全部初始化为0CCCCCCCCh 可以带大家看一下
在这里插入图片描述
这4句汇编我们可以认为它做的事情就是初始化main函数的栈帧空间 当然我们当前在vs2022上它这里只初始化了9*4=36个字节的空间,不同的编译器上可能是不同的,比如vs2013上它这里初始化的这一块空间就比较大,这个不用太纠结。 当然如果main函数里定义的变量啥的不一样的话肯定也会有所不同。然后我们得到了接下来的图
在这里插入图片描述
事实上这里的0xcccccccc就是我们平常越界时打印的一堆烫烫烫烫

(四)main函数的核心代码的执行

在这里插入图片描述
这两句我们不要管这是编译器附加上的
为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,我们可以关闭下面的选项,让汇编代码中排除一些编译器附加的代码

  1. 右击项目名,选择属性
    在这里插入图片描述
    在这里插入图片描述
    然后退出调试然后重新进入调试我们可以看到那两句消失了
    在这里插入图片描述
    其实接下来分析的才是真正的main函数的代码,我们继续往下分析
    在这里插入图片描述

首先mov dword ptr [ebp-8],0Ah那就是把0Ah(8进制对应10进制就是整数10),放到地址为ebp-8位置的dword 双字(4字节,32位)内存单元中 [补充: x86 架构中 dword ptr:双字(4 字节) word ptr:字(2 字节) byte ptr:字节(1 字节)]
在这里插入图片描述
这时候就是把10以16进制的形式存进ebp-8这个空间里这个空间也就是变量a
具体如下所示
在这里插入图片描述

我们在内存里看一下
在这里插入图片描述
16进制a对应的就是十进制的10
那么接下来的两条指令也是同理,这里就不赘述了,直接画图,并验证
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(五)调用add函数

接下来就到了调用add函数的时候了
在这里插入图片描述

1.传参

调用add函数的时候肯定要先进行传参操作,我们分析如何进行传参的
在这里插入图片描述

首先 mov eax,dword ptr [ebp-14h] 那它其实就是把ebp-14h位置的dword即4字节的内容放到寄存器eax里面
在这里插入图片描述在这里插入图片描述
根据上面的两张图我们可以知道这个位置存的是b的值20,所以这一步就是把20这个整数值 放到eax里面 然后push eax即把eax压栈
在这里插入图片描述
那么接下来就知道了mov ecx,dword ptr [ebp-8],把ebp-8位置4个字节的值放到ecx寄存器里面 ebp-8位置存的是a变量的值10 然后push ecx把ecx压栈
上面两步进行后画出图像如下:
在这里插入图片描述

2.call指令调用函数

在这里插入图片描述
接下来我们继续分析
在这里插入图片描述
这里我们先注意call的地址
call 00EA10B4,其实就是去调用add函数 这时我们按F11(逐语句,这样才会进入函数)
在这里插入图片描述

注意当我们进行call指令时esp的位置变化了,所以当我们实行这个指令时压进了一个值,我们去观察esp的内存变化
在这里插入图片描述
而且我们会发现新push进去的这个值就是前面call 00EA10B4这条指令的下一条指令的地址
因此call 指令首先将当前call指令的下一条指令的地址入栈,然后无条件转移到由标签指示的指令
在这里插入图片描述
这里压进去一个地址是为了调用完函数后返回时,能够继续往下正常执行,而当把这个地址压进去后,返回时只要找到这个地址就可以继续进行啦
继续按下F11
在这里插入图片描述
这一次才算真正进入到了函数内部然后我们观察前几行指令,是不是与之前的main函数里的很像
我们把这些指令拿出来
在这里插入图片描述

具体就不讲解了与之前的差不多,这里就给出结果截图了
在这里插入图片描述

然后就是具体的计算了
在这里插入图片描述
下面一条指令就是对m进行初始化
在这里插入图片描述
接下来几条指令是用来进行计算的

在这里插入图片描述
首先mov eax,dword ptr [ebp+8]把ebp+8位置的值放到eax里面
在这里插入图片描述

根据上面我们可以知道ebp+8这个位置就是我们刚才传参的时候把实参a的值传过来放到这里了,那我们现在就是获取到传过来的参数了
再往下看 add eax,dword ptr [ebp+0Ch],就是把ebp+0Ch(10进制是12)位置的值和eax里面的值相加,结果放到eax里面 那eax里存的是传过来的实参a的值10,
在这里插入图片描述

ebp+0Ch位置放的就是传过来的实参b的值20嘛。(这就是形参访问) 那现在10+20,结果30就放到了eax里面。
然后第三条指令将eax的值赋值给m,这时m = 30,效果如下:
在这里插入图片描述
在这里插入图片描述
这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。形参并没有在add函数内部而是找回之前压进去的值,对形参的修改不会影响实参,那我们调用Add函数不就是要算a+b(对应形参是i、j)的和,这也就是为什么要交换值的时候要用指针了。

而这两个就是形参了
接下来要执行return语句了也就是要返回main函数了,让我们来看看怎么返回的
在这里插入图片描述
首先mov eax,dword ptr [ebp-8],ebp-8的结果是最终的结果m。 所以它又把m的值放到了eax寄存器里面。 那大家思考一下,为什么不直接返回m呢?为什么要要把结果放到寄存器eax里面呢? 因为: z是一个创建在Add函数栈帧上的局部变量啊,Add函数调用结束,栈帧销毁,我们还找得到m吗? 找不到了。 所以,先把m的值给一个寄存器保存起来,因为寄存器是存在于CPU内部的一组用于存储和处理数据的高速存储器,它是不会随着函数调用结束而销毁的。 这样即使,函数调用结束,回到main函数里面,我们也照样可以安全的拿到返回值。

3.Add函数栈帧的销毁

要返回的话肯定要销毁创建的add函数栈帧,仔细观察下面几条指令三个pop
在这里插入图片描述
把之前压到栈帧里面的3个寄存器pop(pop edi 弹出栈顶元素送到 edi)出去,恢复这3个寄存器之前存的值,同时esp指针变动(pop一次加一个4)
在这里插入图片描述
然后mov esp,ebp就是让esp指向ebp的位置,如下
在这里插入图片描述
然后再往下 pop ebp,弹出栈顶的值给ebp此时栈顶上是之前压上去的main函数对应的栈基指针的值 那现在把它pop掉,并把它的值给ebp。 那就变成这样了

在这里插入图片描述
我们发现此时ebp和esp又重新维护起了main函数的栈帧,这当然没问题,因为此时Add函数已经调用结束,就要回到main函数了。 所以,前面我们为什么要main函数的ebp栈基指针push存起来,其实就是为了函数调用结束回来的时候我们能获取到原来main函数对应的栈基指针的值,从而使ebp重新指向main函数的栈底,维护main函数的栈帧。

4.返回至main函数

然后下一句你会发现他是一个返回指令
在这里插入图片描述
ret指令实现子程序的返回机制,ret 指令pop弹出栈顶保存的call执行的下一条指令的地址,然后无条件转移到保存的指令地址处 所以ret之后main函数的栈帧就是这样的
在这里插入图片描述
在这里插入图片描述
接下来是add esp,8 给esp的值+8,就把形参x、y的空间也释放掉了
在这里插入图片描述

那我们再往下看 mov dword ptr [ebp-20h],eax,把eax(存的Add函数的返回值)的值复制到ebp-20h位置的4个字节的内存单元上 ebp-20h(10进制32)也就是把值传给了c了,在这里插入图片描述
然后就是打印出值,main函数销毁,具体的和add函数销毁差不多这里就不做过多的介绍了。

总结

我们根据这篇文章学习了

  • 局部变量是怎么创建的
  • 为什么局部变量是随机值
  • 函数是怎么传参的,传参的顺序是怎样的
  • 形参和实参是什么关系
  • 函数调用是怎么做的
  • 函数调用结束后怎么返回的
  • 35
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

码中游侠沐墨

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

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

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

打赏作者

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

抵扣说明:

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

余额充值