深入理解内存 —— 函数栈帧的创建与销毁

简介: 深入理解内存 —— 函数栈帧的创建与销毁

栈与栈帧的概念        

首先,什么是栈?

       在数据结构中我们学过  “栈” 这种结构,在数据结构中, 栈是限定仅在表尾进行插入或删除操作线性表。栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。

在计算机系统中,栈也可以称之为栈内存是一个具有动态内存区域,存储函数内部(包括  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语言代码

#define _CRT_SECURE_NO_WARNINGS 1
#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;
}

2.反汇编代码

       我们完成上述代码后,按 F10 进行调试,然后鼠标右键单击 “转到反汇编”,然后我们就可以看到反汇编代码了

主函数:

int main()
{
001818D0  push        ebp  
001818D1  mov         ebp,esp  
001818D3  sub         esp,0E4h  
001818D9  push        ebx  
001818DA  push        esi  
001818DB  push        edi  
001818DC  lea         edi,[ebp-24h]  
001818DF  mov         ecx,9  
001818E4  mov         eax,0CCCCCCCCh  
001818E9  rep stos    dword ptr es:[edi]  
001818EB  mov         ecx,18C008h  
001818F0  call        0018132F  
  int a = 10;
001818F5  mov         dword ptr [ebp-8],0Ah  
  int b = 20;
001818FC  mov         dword ptr [ebp-14h],14h  
  int c = 0;
00181903  mov         dword ptr [ebp-20h],0  
  c = add(a, b);
0018190A  mov         eax,dword ptr [ebp-14h]  
0018190D  push        eax  
0018190E  mov         ecx,dword ptr [ebp-8]  
00181911  push        ecx  
00181912  call        00181023  
00181917  add         esp,8  
0018191A  mov         dword ptr [ebp-20h],eax  
  printf("%d", c);
0018191D  mov         eax,dword ptr [ebp-20h]  
00181920  push        eax  
00181921  push        187B30h  
00181926  call        001810D7  
0018192B  add         esp,8  
  return 0;
0018192E  xor         eax,eax  
}
00181930  pop         edi  
00181931  pop         esi  
00181932  pop         ebx  
00181933  add         esp,0E4h  
00181939  cmp         ebp,esp  
0018193B  call        00181253  
00181940  mov         esp,ebp  
00181942  pop         ebp  
00181943  ret  

add函数:

int add(int x, int y)
{
00181870  push        ebp  
00181871  mov         ebp,esp  
00181873  sub         esp,0CCh  
00181879  push        ebx  
0018187A  push        esi  
0018187B  push        edi  
0018187C  lea         edi,[ebp-0Ch]  
0018187F  mov         ecx,3  
00181884  mov         eax,0CCCCCCCCh  
00181889  rep stos    dword ptr es:[edi]  
0018188B  mov         ecx,18C008h  
00181890  call        0018132F  
  int z = 0;
00181895  mov         dword ptr [ebp-8],0  
  z = x + y;
0018189C  mov         eax,dword ptr [ebp+8]  
0018189F  add         eax,dword ptr [ebp+0Ch]  
001818A2  mov         dword ptr [ebp-8],eax  
  return z;
001818A5  mov         eax,dword ptr [ebp-8]  
}
001818A8  pop         edi  
001818A9  pop         esi  
001818AA  pop         ebx  
001818AB  add         esp,0CCh  
001818B1  cmp         ebp,esp  
001818B3  call        00181253  
001818B8  mov         esp,ebp  
001818BA  pop         ebp  
001818BB  ret 

函数栈帧的创建

       我们知道,我要使用某一个函数,就要去调用他,一般常见的情况是在函数里面调用别的函数,就比如上面写的那一段很简单的代码,我们在 main 函数里面调用了 add 函数来实现了将俩个数相加的操作, main  函数是我们人为写的上去的,本身编译器是不会自带 main 函数的,当我们的代码写完了准备编译的时候,编译器得先扫描整个代码,找到 main 函数,然后从 main 函数开始执行代码,换言之 main 函数也是函数,也是需要被调用的。


 那么编译器用什么来拿到 main 函数,并且成功的调用他的呢?关于这一点,不同的编译器的实现是不一样的,比如在VS编译器中是使用的 _tmainCRTStartup 这样的内置函数来调用的。


1.创建 _tmainCRTStartup 的栈帧

编译器拿到一段完整的程序后首先会在栈区开辟一块空间,如下图所示:


2.创建 main 的栈帧

从这里开始结合反汇编代码进行观察

首先将 edp 押栈

001818D0  push        ebp

然后改变 edp 的指向

001818D1  mov         ebp,esp

然后移动 esp 移动 0e4h 个单位

001818D3  sub         esp,0E4h

到这里,其实就已经完成了对 main 函数栈区的创建,如图所示:

3.main函数数据的初始化

然后我们再继续结合反汇编代码 进行观察:

在这里连续押了3个元素入栈

001818D9  push        ebx  
001818DA  push        esi  
001818DB  push        edi

如图所示:

然后对刚才开辟的空间进行了初始化,并且全部赋值为 cccccccc ,这也解释了为什么平常没有初始化的数据的随机值是 ccccccccc

001818DC  lea         edi,[ebp-24h]  
001818DF  mov         ecx,9  
001818E4  mov         eax,0CCCCCCCCh  
001818E9  rep stos    dword ptr es:[edi] 

在完成初始化后,初始化 a=10,在这里一个 word 是 2 个字节,一个 dword是 4 个字节

  int a = 10;
001818F5  mov         dword ptr [ebp-8],0Ah  

       

       我们可以成功的观察到,在 edp-8 这个位置,已经存放了 a=10,其余位置的 cccccccc 还是保留不变,这也就解释了平常随机值的大小为 cccccccc 的情况


同理的,对 bc 都做初始化

自此我们就完成了对数据的全部初始化,接下来就 add 函数了

4.add函数传参


在这里我们可以注意,传入的地址

  • edp-14h  就是之前初始化的 b=20
  • edp-8    就是之前初始化的 a=10

       也就是进行了函数传参的操作,通过下面的代码,我们更加可以理解函数的形参是实参的一份临时拷贝

  c = add(a, b);
0018190A  mov         eax,dword ptr [ebp-14h]  
0018190D  push        eax  
0018190E  mov         ecx,dword ptr [ebp-8]  
00181911  push        ecx

5.创建add函数的栈帧

这里的 call 就是调用的意思

00181912  call        00181023  
00181917  add         esp,8  
0018191A  mov         dword ptr [ebp-20h],eax

 

按 F11 进入函数观察,我们会发现,这里的操作和上述 main 函数栈帧的操作几乎一模一样,也就是说,这里实际上是在创建 add 函数的栈帧

int add(int x, int y)
{
00181870  push        ebp  
00181871  mov         ebp,esp  
00181873  sub         esp,0CCh  
00181879  push        ebx  
0018187A  push        esi  
0018187B  push        edi  
0018187C  lea         edi,[ebp-0Ch]  
0018187F  mov         ecx,3  
00181884  mov         eax,0CCCCCCCCh  
00181889  rep stos    dword ptr es:[edi]  
0018188B  mov         ecx,18C008h 

6.add函数数据的初始化

和上述 main 函数数据的初始化基本上是一样的

int z = 0;
00181895  mov         dword ptr [ebp-8],0  
  z = x + y;
0018189C  mov         eax,dword ptr [ebp+8]  
0018189F  add         eax,dword ptr [ebp+0Ch]  
001818A2  mov         dword ptr [ebp-8],eax  

这里就不再赘述,结果就是对 edp 附近的字节进行操作,最终达到成功赋值的目的

7. add函数的返回

       我们知道,函数使用的空间是临时的,在退出这个函数之后,他使用的这部分空间就被销毁了,那空间都被销毁了,该怎么样把返回值返回呢?


这是返回值 z 的创建位置: edp-8

int z = 0;
00181895  mov         dword ptr [ebp-8],0  


这是返回时的语句

return z;
001818A5  mov         eax,dword ptr [ebp-8] 

   我们观察发现,编译器是将 edp-8 的值放在了 eax 中,那 eax 是什么呢? eax 其实是寄存器寄存器不会因为 add 函数的销毁而销毁,他会持续的存在,用来保存 z 的值

函数栈帧的销毁

1.add函数栈帧的销毁


  pop 是弹出栈的意思,连续从栈顶弹出三个寄存器,之后继续更改 esp edp 指向的位置,最后,ret 会回到之前 call 指令留下的下一条指令的地址

001818A8  pop         edi  
001818A9  pop         esi  
001818AA  pop         ebx  
001818AB  add         esp,0CCh  
001818B1  cmp         ebp,esp  
001818B3  call        00181253  
001818B8  mov         esp,ebp  
001818BA  pop         ebp  
001818BB  ret 

如图所示:

        此时的栈顶指针,栈底指针就可以做到重新维护 main 函数的栈帧空间,因为之前 call 指令留下的地址,我们就可以做到 “出去又可以回来” 这对于我们管理空间是非常高效稳定的+

2.add函数值的返回


这里实际上是更改栈顶指针的指向,通过这样的操作,我们就可以达到释放形参的目的,值得注意的是这段代码的最后一行

c = add(a, b);
0018190A  mov         eax,dword ptr [ebp-14h]  
0018190D  push        eax  
0018190E  mov         ecx,dword ptr [ebp-8]  
00181911  push        ecx  
00181912  call        00181023  
00181917  add         esp,8  
0018191A  mov         dword ptr [ebp-20h],eax  

我们会发现,这里的 ebp-20h 和 eax 分别对应了前面对于 c 的初始化和对于 z 的值的保存,也就是说,这里就是将之前 eax 寄存器里放的 z 的值赋给 c,从而达到了

c = add(a, b);

的语句效果

int c = 0;
00181903  mov         dword ptr [ebp-20h],0  
return z;
001818A5  mov         eax,dword ptr [ebp-8]  

3.main函数栈帧的销毁

       这里也是连续从栈顶弹出三个寄存器,之后继续更改 esp edp 指向的位置,最后 ret 退回上一级调用 main 函数的内置函数中,具体过程同上,这里就不再继续赘述

00181930  pop         edi  
00181931  pop         esi  
00181932  pop         ebx  
00181933  add         esp,0E4h  
00181939  cmp         ebp,esp  
0018193B  call        00181253  
00181940  mov         esp,ebp  
00181942  pop         ebp  
00181943  ret  

       以上就是本次分享的全部内容了,希望对屏幕前的您有所帮助,如有内容上的错误,欢迎指出,也欢迎积极讨论,内容制作不易,给个三连支持一下吧

目录
相关文章
|
28天前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
232 1
|
18天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
27天前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80
|
28天前
|
Java
JVM运行时数据区(内存结构)
1)虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧 (2)本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一 (3)程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
21 3
|
28天前
|
存储 缓存 监控
Elasticsearch集群JVM调优堆外内存
Elasticsearch集群JVM调优堆外内存
46 1
|
1月前
|
Arthas 监控 Java
JVM进阶调优系列(9)大厂面试官:内存溢出几种?能否现场演示一下?| 面试就那点事
本文介绍了JVM内存溢出(OOM)的四种类型:堆内存、栈内存、元数据区和直接内存溢出。每种类型通过示例代码演示了如何触发OOM,并分析了其原因。文章还提供了如何使用JVM命令工具(如jmap、jhat、GCeasy、Arthas等)分析和定位内存溢出问题的方法。最后,强调了合理设置JVM参数和及时回收内存的重要性。
|
2月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
81 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
2月前
|
存储 算法 Java
Java虚拟机(JVM)的内存管理与性能优化
本文深入探讨了Java虚拟机(JVM)的内存管理机制,包括堆、栈、方法区等关键区域的功能与作用。通过分析垃圾回收算法和调优策略,旨在帮助开发者理解如何有效提升Java应用的性能。文章采用通俗易懂的语言,结合具体实例,使读者能够轻松掌握复杂的内存管理概念,并应用于实际开发中。
|
2月前
|
存储 监控 算法
JVM调优深度剖析:内存模型、垃圾收集、工具与实战
【10月更文挑战第9天】在Java开发领域,Java虚拟机(JVM)的性能调优是构建高性能、高并发系统不可或缺的一部分。作为一名资深架构师,深入理解JVM的内存模型、垃圾收集机制、调优工具及其实现原理,对于提升系统的整体性能和稳定性至关重要。本文将深入探讨这些内容,并提供针对单机几十万并发系统的JVM调优策略和Java代码示例。
60 2
|
2月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
60 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配