函数栈帧的开辟与销毁
前言
一位优秀的程序员,必须对内存的分布有深刻的理解,在初学编程的时候,往往有诸如以下很多问题困扰着初学者,而通过今天的分享,我们就可以通过自己的观察,将这些问题统统解决掉
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序是怎么样的?
- 形参和实参是什么关系?
- 函数调用是怎么调用的?
- 函数调用后是怎么返回的?
栈与栈帧的概念
首先,什么是栈?
在数据结构中我们学过 “栈” 这种结构,在数据结构中, 栈是限定仅在表尾进行插入或删除操作的线性表。栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。
在计算机系统中,栈也可以称之为栈内存是一个具有动态内存区域,存储函数内部(包括 main 函数)的局部变量和方法调用和函数参数值,是由系统自动分配的,一般速度较快;存储地址是连续且存在有限栈容量,会出现溢出现象程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。 栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。
那什么是栈帧呢?
每一次函数的调用,都会在调用栈(call stack)上维护一个独立的栈帧(stack frame)。每个独立的栈帧一般包括:
- 函数的返回地址和参数
- 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 函数调用的上下文
栈是从高地址向低地址延伸,一个函数的栈帧用 ebp 和 esp 这两个寄存器来划定范围.ebp 指向当前的栈帧的底部,esp 始终指向栈帧的顶部;
ebp 指向当前的栈帧的底部
ebp 寄存器又被称为帧指针(Frame Pointer)
esp 始终指向栈帧的顶部
esp 寄存器又被称为栈指针(Stack Pointer)
另外,经过笔者的测试,这也与编译环境有关使用不同的编译器,或者不同的环境下,我们能直观看见的都是不一样的,但是俩者都是寄存器,只是体现不同罢了
- 32位机器(esp,ebp)
- 64位机器(rsp,rbp)
以下是笔者在VS2022上进行的测试:
栈帧是如何在电脑上运作的
要想搞懂这个问题,我们就需要结合编译器给我们提供的反汇编代码,结合上我们写的代码进行分析
我们先实现一个将俩个数相加的函数功能,然后在放进 main 函数中,并且进行调用,完成后输出结果,然后结束 main 函数。整个代码逻辑非常简单,具体实现如下:
1.c语言代码
2.反汇编代码
我们完成上述代码后,按 F10 进行调试,然后鼠标右键单击 “转到反汇编”,然后我们就可以看到反汇编代码了
主函数:
add函数:
函数栈帧的创建
我们知道,我要使用某一个函数,就要去调用他,一般常见的情况是在函数里面调用别的函数,就比如上面写的那一段很简单的代码,我们在 main 函数里面调用了 add 函数来实现了将俩个数相加的操作, main 函数是我们人为写的上去的,本身编译器是不会自带 main 函数的,当我们的代码写完了准备编译的时候,编译器得先扫描整个代码,找到 main 函数,然后从 main 函数开始执行代码,换言之 main 函数也是函数,也是需要被调用的。
那么编译器用什么来拿到 main 函数,并且成功的调用他的呢?关于这一点,不同的编译器的实现是不一样的,比如在VS编译器中是使用的 _tmainCRTStartup 这样的内置函数来调用的。
1.创建 _tmainCRTStartup 的栈帧
编译器拿到一段完整的程序后首先会在栈区开辟一块空间,如下图所示:
2.创建 main 的栈帧
从这里开始结合反汇编代码进行观察
首先将 edp 押栈
然后改变 edp 的指向
然后移动 esp 移动 0e4h 个单位
到这里,其实就已经完成了对 main 函数栈区的创建,如图所示:
3.main函数数据的初始化
然后我们再继续结合反汇编代码 进行观察:
在这里连续押了3个元素入栈
如图所示:
然后对刚才开辟的空间进行了初始化,并且全部赋值为 cccccccc ,这也解释了为什么平常没有初始化的数据的随机值是 ccccccccc
在完成初始化后,初始化 a=10,在这里一个 word 是 2 个字节,一个 dword 是 4 个字节
我们可以成功的观察到,在 edp-8 这个位置,已经存放了 a=10,其余位置的 cccccccc 还是保留不变,这也就解释了平常随机值的大小为 cccccccc 的情况
同理的,对 b 和 c 都做初始化
自此我们就完成了对数据的全部初始化,接下来就 add 函数了
4.add函数传参
在这里我们可以注意,传入的地址
- edp-14h 就是之前初始化的 b=20
- edp-8 就是之前初始化的 a=10
也就是进行了函数传参的操作,通过下面的代码,我们更加可以理解函数的形参是实参的一份临时拷贝
5.创建add函数的栈帧
这里的 call 就是调用的意思
按 F11 进入函数观察,我们会发现,这里的操作和上述 main 函数栈帧的操作几乎一模一样,也就是说,这里实际上是在创建 add 函数的栈帧
6.add函数数据的初始化
和上述 main 函数数据的初始化基本上是一样的
这里就不再赘述,结果就是对 edp 附近的字节进行操作,最终达到成功赋值的目的
7. add函数的返回
我们知道,函数使用的空间是临时的,在退出这个函数之后,他使用的这部分空间就被销毁了,那空间都被销毁了,该怎么样把返回值返回呢?
这是返回值 z 的创建位置: edp-8
这是返回时的语句
我们观察发现,编译器是将 edp-8 的值放在了 eax 中,那 eax 是什么呢? eax 其实是寄存器,寄存器不会因为 add 函数的销毁而销毁,他会持续的存在,用来保存 z 的值
函数栈帧的销毁
1.add函数栈帧的销毁
pop 是弹出栈的意思,连续从栈顶弹出三个寄存器,之后继续更改 esp 和 edp 指向的位置,最后,ret 会回到之前 call 指令留下的下一条指令的地址
如图所示:
此时的栈顶指针,栈底指针就可以做到重新维护 main 函数的栈帧空间,因为之前 call 指令留下的地址,我们就可以做到 “出去又可以回来” 这对于我们管理空间是非常高效稳定的+
2.add函数值的返回
这里实际上是更改栈顶指针的指向,通过这样的操作,我们就可以达到释放形参的目的,值得注意的是这段代码的最后一行
我们会发现,这里的 ebp-20h 和 eax 分别对应了前面对于 c 的初始化和对于 z 的值的保存,也就是说,这里就是将之前 eax 寄存器里放的 z 的值赋给 c,从而达到了
的语句效果
3.main函数栈帧的销毁
这里也是连续从栈顶弹出三个寄存器,之后继续更改 esp 和 edp 指向的位置,最后 ret 退回上一级调用 main 函数的内置函数中,具体过程同上,这里就不再继续赘述
以上就是本次分享的全部内容了,希望对您有所帮助,如有内容上的错误,欢迎指出
- 点赞
- 收藏
- 关注作者
评论(0)