在之前学习C语言的过程中,我们或多或少的会遇到一些问题和困惑,比如:为什么没有初始化的局部变量是随机值?局部变量是怎么创建的?函数是怎么传参的?传参的顺序是怎么样的?形参存储在内存的哪里?
在本篇文章中,我们将对函数栈帧的创建和销毁的方式和细节有一个全面的了解,同时也会解答上述可能存在的问题。本章的学习看似用处不大,实际上就像修炼内功一样,可以帮助我们在未来的学习中更好的理解知识。
函数栈帧的创建和销毁涉及的知识贴近底层,想要讲好十分不易。本文将文字代码截图画图相结合希望能够给各位一个良好的阅读体验,如有错漏欢迎指出。
本章重点位于第三部分:函数栈帧的创建和销毁解析
我们在写C语言代码的时候,常常会将一些功能封装成一个函数,那么函数是怎么调用的?函数的返回值是怎么返回的?传参是怎么一回事?这些问题都和函数栈帧有关。
函数栈帧(stack frame)就是函数调用过程在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
要学习函数栈帧的创建和销毁,首先得知道什么是栈。
栈(stack)是现代计算机结构中最为重要的概念之一,没有栈就没有函数,没有局部变量,也就没有我们如今的所有计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,我们可以把数据压入栈中(入栈,push),也可以将已经入栈的数据弹出(出栈,pop)。栈的原则之一:最先入栈的数据最后出栈。就像把书叠成一叠,最先叠的书在最底下,所以最后才取出。
而在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,使栈增大;也可以将数据从栈顶弹出,使栈减小。
在学习函数栈帧的创建和销毁之前,我们先脸熟一下待会要见到的一些寄存器和汇编命令
相关寄存器:
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
相关汇编命令:
mov:数据转移指令
push:数据入栈,同时改变栈顶寄存器esp的位置
pop:数据弹出至指定位置,同时改变栈顶寄存器esp的位置
sub:减法命令
add:加法命令
call:函数调用命令,先压入调用完毕返回的地址,再转入目标函数
jump:通过修改eip,转入目标函数进行调用
ret:恢复返回地址并压入eip,类似于pop eip
看不懂也没关系,接下来我们开始对函数栈帧的创建和销毁进行细致的讲解
在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。本文中使用的是vs2019
首先我们要知道,寄存器是集成在CPU中的,有eax、ebx、ecx、edx、ebp和esp等,其中:
ebp、esp这两个寄存器中存放的是地址,用来维护函数栈帧。
什么意思呢?我们先写一段代码作为例子并配图方便理解
#include
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上并调试,这里推荐跟着我一起动手一步步尝试
函数调用堆栈是用来反馈函数调用逻辑的。光打开还不够,我们在调用堆栈窗口中右键勾选显示外部代码
打开后我们会看到:
现在我们可以清晰的观察到,main函数调用之前,是由 invoke_main 函数来调用main函数的,再之前的函数我们先不做考虑
那我们就知道,在main函数之前,nvoke_main 也开辟了自己的函数栈帧
我们看到,main函数转化的汇编代码如下图所示
上方是低地址,下方是高地址,所以作减法的时候esp向上移动
接下来这四步,我们放在一起讲解
上面四行汇编代码,等价于下方的这段伪代码,是用来初始化栈帧空间的
edi = ebp - 0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for (; ecx = 0; --ecx, edi += 4)
{
*(int *)edi = eax;
}
我们打开内存窗口,可以观察到ebp指向的位置上方刚好有36个字节被初始化为了0XCC
梗知识:烫烫烫
为什么会输出这么多个烫呢?因为我们没有对arr数组进行初始化,从上面可以知道,main函数调用时,在栈区开辟的每一个字节都被初始化为0xCC,而0xCCCC的汉字编码就是“烫”,所以就会输出这么多“烫”字
这三步,先将0x0a存储到ebp-0x08的位置处,再将0x14存储到ebp-0x14的位置处,再将0存储到ebp-0x20的位置处,我们观察内存
可以看到,此时三个位置已经存放了对应的数值
搭配图片,方便理解
这三个位置实际上就是分配给三个局部变量的空间,这就是局部变量的创建和初始化,局部变量的是在所在函数的栈帧空间中创建的
我们继续接下来的分析
这两个操作实际上就是实参a和b的传参过程,我们搭配图片方便理解
接下来的三步(call,add,mov)是Add函数的调用过程,前面曾提到过call指令的函数调用逻辑:在执行call指令之前会先把call指令的下一条指令的地址压栈,这个操作是为了函数调用结束后能够找到位置,继续执行call指令的下一条指令
继续执行,将call的下一步指令的地址压栈,然后进入Add函数内部
可以看到下一步指令的地址(0x002F4337)已经压入栈中
经过观察,我们发现在Add函数中创建函数栈帧的方法和在main函数中是类似的,只是栈帧空间的大小略有不同而已,步骤为:
所以我们省略Add函数创建栈帧的部分,进入核心代码
第一个mov,将0保存在ebp-8的位置,此处作为局部变量z的空间
第二个mov,将ebp+8处的值存储到eax中
下一步add,将ebp+0Ch处的值与eax中的值相加并保存到eax中
还记得ebp+8和ebp+0Ch处的位置吗?
我们发现,正好就是a和b传参的保存位置,也就是形参的位置,所以:
函数的形参并不是存储在函数的栈帧空间的!
现在,我们就可以很好的理解a和b的传参顺序,以及为什么对形参的修改不会影响实参了
接下来是函数栈帧的销毁,我们直接在Add函数内部进行讲解
当函数调用完要结束返回的时候,之前创建的函数栈帧也开始销毁
具体是怎么销毁的呢?我们看一下后续的代码并挑选关键指令讲解。
第一个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:本篇创作不易,我尽可能用较简单易懂的语言来讲解,各位从我的文章中有收获也是对我的鼓励,如果文中有讲错的地方或者能够改进的地方也希望各位能在评论区提出( ̄︶ ̄)↗