函数栈帧的创建和销毁

一、引言

在之前学习C语言的过程中,我们或多或少的会遇到一些问题和困惑,比如:为什么没有初始化的局部变量是随机值?局部变量是怎么创建的?函数是怎么传参的?传参的顺序是怎么样的?形参存储在内存的哪里?

在本篇文章中,我们将对函数栈帧的创建和销毁的方式和细节有一个全面的了解,同时也会解答上述可能存在的问题。本章的学习看似用处不大,实际上就像修炼内功一样,可以帮助我们在未来的学习中更好的理解知识。

函数栈帧的创建和销毁涉及的知识贴近底层,想要讲好十分不易。本文将文字代码截图画图相结合希望能够给各位一个良好的阅读体验,如有错漏欢迎指出。

本章重点位于第三部分:函数栈帧的创建和销毁解析


二、什么是函数栈帧

我们在写C语言代码的时候,常常会将一些功能封装成一个函数,那么函数是怎么调用的?函数的返回值是怎么返回的?传参是怎么一回事?这些问题都和函数栈帧有关。

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

  • 函数参数和函数范围值
  • 临时变量(包括函数的非静态的局部变量和编译器自己产生的其他临时变量)
  • 保存上下文的信息(包括下一条指令的地址或函数前后需要保持位置不变的寄存器等)

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

3.1 什么是栈

要学习函数栈帧的创建和销毁,首先得知道什么是栈。

栈(stack)是现代计算机结构中最为重要的概念之一,没有栈就没有函数,没有局部变量,也就没有我们如今的所有计算机语言。

在经典的计算机科学中,栈被定义为一种特殊的容器,我们可以把数据压入栈中(入栈,push),也可以将已经入栈的数据弹出(出栈,pop)。栈的原则之一:最先入栈的数据最后出栈。就像把书叠成一叠,最先叠的书在最底下,所以最后才取出。

而在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,使栈增大;也可以将数据从栈顶弹出,使栈减小。

3.2 认识相关寄存器和汇编指令

在学习函数栈帧的创建和销毁之前,我们先脸熟一下待会要见到的一些寄存器和汇编命令

相关寄存器:

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

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

ebp:栈底寄存器

esp:栈顶寄存器

eip:指令寄存器,保存当前指令的下一条指令的地址

相关汇编命令:

mov:数据转移指令

push:数据入栈,同时改变栈顶寄存器esp的位置

pop:数据弹出至指定位置,同时改变栈顶寄存器esp的位置

sub:减法命令

add:加法命令

call:函数调用命令,先压入调用完毕返回的地址,再转入目标函数

jump:通过修改eip,转入目标函数进行调用

ret:恢复返回地址并压入eip,类似于pop eip

看不懂也没关系,接下来我们开始对函数栈帧的创建和销毁进行细致的讲解

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

在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。本文中使用的是vs2019

首先我们要知道,寄存器是集成在CPU中的,有eax、ebx、ecx、edx、ebp和esp等,其中:

ebp、esp这两个寄存器中存放的是地址,用来维护函数栈帧。

什么意思呢?我们先写一段代码作为例子并配图方便理解

#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", c);
	return 0;
}

 在上面这段代码中,我们有main函数,有Add函数实现相加功能,创建了几个变量并且把每一段代码都拆分的尽量细致,方便我们后续观察

每一次的函数调用,都要在栈区创建一个空间,也就是函数栈帧的空间。esp寄存器和ebp寄存器分别位于main函数的函数栈帧顶部和底部,记录着栈顶和栈底的地址,称为栈顶指针和栈底指针

了解了这点之后,我们将代码复制到vs2019上并调试,这里推荐跟着我一起动手一步步尝试

(1)开始调试后,我们打开调用堆栈

函数调用堆栈是用来反馈函数调用逻辑的。光打开还不够,我们在调用堆栈窗口中右键勾选显示外部代码

打开后我们会看到:

现在我们可以清晰的观察到,main函数调用之前,是由 invoke_main 函数来调用main函数的,再之前的函数我们先不做考虑

那我们就知道,在main函数之前,nvoke_main 也开辟了自己的函数栈帧

(2)接下来我们调试到main函数开始执行的第一行代码,右键打开反汇编进行观察

我们看到,main函数转化的汇编代码如下图所示

(3)接下来我们一行行拆解汇编代码,先从函数栈帧的创建部分开始

  • 第一步——push ebp:把ebp寄存器中的值进行压栈,此时esp-4,指向新的位置

上方是低地址,下方是高地址,所以作减法的时候esp向上移动

  • 第二步——mov ebp,esp:把esp的值存放到ebp中
  • 第三步——sub esp,0E4h:将esp中的地址减去一个16进制数字0xe4,产生新的esp,此时ebp寄存器和esp寄存器之间维护了一块栈空间,这就是main函数的栈帧空间

  • 第四、五、六步:和第一步类似,将ebx,esi,edi压入栈中

接下来这四步,我们放在一起讲解 

  • lea edi,[ebp-24h]:lea是load effective address的简称,将ebp减去16进制数字24h之后的地址放在edi中
  • mov ecx,9:把9放在ecx中
  • mov eax,0CCCCCCCCh:把0xCCCCCCCC放在eax中
  • rep stos  dword ptr es:[edi]:将目标内存段中的每个字节都初始化为0xCC

上面四行汇编代码,等价于下方的这段伪代码,是用来初始化栈帧空间的

edi = ebp - 0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for (; ecx = 0; --ecx, edi += 4)
{
	*(int *)edi = eax;
}

我们打开内存窗口,可以观察到ebp指向的位置上方刚好有36个字节被初始化为了0XCC

梗知识:烫烫烫

为什么会输出这么多个烫呢?因为我们没有对arr数组进行初始化,从上面可以知道,main函数调用时,在栈区开辟的每一个字节都被初始化为0xCC,而0xCCCC的汉字编码就是“烫”,所以就会输出这么多“烫”字

(4)下面的call指令我们后面会讲到,这里直接开始分析main函数中的核心代码

这三步,先将0x0a存储到ebp-0x08的位置处,再将0x14存储到ebp-0x14的位置处,再将0存储到ebp-0x20的位置处,我们观察内存

可以看到,此时三个位置已经存放了对应的数值

搭配图片,方便理解

这三个位置实际上就是分配给三个局部变量的空间,这就是局部变量的创建和初始化,局部变量的是在所在函数的栈帧空间中创建的

我们继续接下来的分析

  • 第一个mov和push:将ebp-14h处(也就是变量b的位置)的值放在eax寄存器中,并将eax的值压栈,同时esp-4
  • 第二个mov和push:将ebp-8处(也就是变量a的位置)的值放在ecx寄存器中,并将ecx的值压栈,同时esp-4

这两个操作实际上就是实参a和b的传参过程,我们搭配图片方便理解

接下来的三步(call,add,mov)是Add函数的调用过程,前面曾提到过call指令的函数调用逻辑:在执行call指令之前会先把call指令的下一条指令的地址压栈,这个操作是为了函数调用结束后能够找到位置,继续执行call指令的下一条指令

继续执行,将call的下一步指令的地址压栈,然后进入Add函数内部

可以看到下一步指令的地址(0x002F4337)已经压入栈中

(5)跳转到Add函数内部,观察其内部的汇编代码

经过观察,我们发现在Add函数中创建函数栈帧的方法和在main函数中是类似的,只是栈帧空间的大小略有不同而已,步骤为:

  1. main函数的ebp压栈
  2. 计算新的ebp和esp
  3. 将ebx,esi,edi寄存器的值保存

所以我们省略Add函数创建栈帧的部分,进入核心代码

第一个mov,将0保存在ebp-8的位置,此处作为局部变量z的空间

第二个mov,将ebp+8处的值存储到eax中

下一步add,将ebp+0Ch处的值与eax中的值相加并保存到eax中

还记得ebp+8和ebp+0Ch处的位置吗?

我们发现,正好就是a和b传参的保存位置,也就是形参的位置,所以:

函数的形参并不是存储在函数的栈帧空间的!

现在,我们就可以很好的理解a和b的传参顺序,以及为什么对形参的修改不会影响实参了

接下来是函数栈帧的销毁,我们直接在Add函数内部进行讲解

3.4 函数栈帧的销毁

当函数调用完要结束返回的时候,之前创建的函数栈帧也开始销毁

具体是怎么销毁的呢?我们看一下后续的代码并挑选关键指令讲解。

第一个pop,在栈顶弹出一个值,存放到edi中,并且esp+4

第二个pop,在栈顶弹出一个值,存放到esi中,并且esp+4

第三个pop,在栈顶弹出一个值,存放到ebx中,并且esp+4

然后到mov指令,将Add函数的ebp赋值给esp,这个操作相当于回收了Add函数的栈帧空间

第四个pop,弹出栈顶的值并赋给ebp,此时栈顶的值是main函数的ebp,也就恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向main函数栈帧的栈底

接着到ret指令,先从栈顶弹出一个值,此时栈顶的值就是call指令的下一步指令的地址,接着就通过这个地址跳转到call指令的下一个指令,继续向下执行

于是回到了call的下一条指令处

这里我们发现,add指令中esp直接+8,相当于跳过了两个形参;然后mov指令将eax的值保存到ebp-20h处,也就是保存到局部变量c的位置。之前我们知道eax中存储着两个形参的和,所以现在我们就知道了,本次函数的返回值是由eax寄存器带回来的,也就是说程序在函数调用返回之后会从eax中读取返回值。

本文到这里就结束了,如果能把文中的知识消化吸收的话,引言中提到的问题也就迎刃而解了。

PS:本篇创作不易,我尽可能用较简单易懂的语言来讲解,各位从我的文章中有收获也是对我的鼓励,如果文中有讲错的地方或者能够改进的地方也希望各位能在评论区提出( ̄︶ ̄)↗ 

  • 34
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值