探秘函数栈帧:『 揭开函数栈帧创建与销毁的神秘面纱 』

..

目录

知识点回顾

一、什么是栈帧(堆栈帧)?

1.内存布局

2.常用寄存器

3.汇编指令

👇👇对于栈的详细介绍 :

👇👇函数栈帧的介绍:

二、函数调用中的栈帧

1.探究main函数栈帧的创建

2.对main函数中的代码进行分析

3.探究Add函数栈帧的创建 

三、函数栈帧的销毁过程

博客引用相关文献:1.《程序员的自我修养——链接、装载与库》

2.西安比特教育科技.《C语言进阶_动态内存管理》

👻内容专栏:《C/C++学习专栏》

🐨本文概括:讲解函数栈帧创建与销毁的具体过程

🐼本文作者:花 碟

🐸发布时间:2023.4.19

知识点回顾

前期我们学习的时候,对许多知识可能有很多困惑。

比如:

  • 局部变量是怎么创建的?
  • 为什么局部变量创建后默认是随机值?
  • 函数是怎么传参的?传参的顺序是怎么样的?
  • 形参和实参的关系是怎么样的?
  • 函数调用是怎么做的?
  • 函数调用结束后是怎么返回的?

还弄不清?没关系,相信聪明的小伙伴们学习此篇章的函数栈帧知识,以上问题就会迎刃而解啦!

OK,让我们来揭开函数栈帧的创建与销毁的神秘面纱吧~~

一、什么是栈帧(堆栈帧)?

在了解函数栈帧之前,我们不得不先了解一下内存布局、寄存器、汇编指令相关概念。

1.内存布局

C/C++内存布局中的几个区域:

1.栈区(stack): 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。栈是向下增长的。栈区是向下生长的,也就是说,栈顶的地址是最小的。栈区的大小是有限制的,如果超出了栈的大小,就会发生栈溢出错误。

2.堆区(heap):堆区是由程序员手动分配和释放的内存区域,用于存储动态分配的内存。堆区是向上生长的,也就是说,堆顶的地址是最大的。堆区的大小是没有限制的,但是如果没有及时释放内存,就会导致内存泄漏。

3.数据段(静态区static):存放全局变量、静态数据。程序结束后由系统释放。

4.代码段:存放函数体(类成员函数和全局函数)的二进制代码。

2.常用寄存器

eax、ebx、ecx、edx、esp、ebp是x86架构CPU中比较常见的寄存器,它们的含义和作用如下:

  1. eax寄存器:又称为累加器寄存器(Accumulator Register),通常用于存储算术计算的操作数和结果。在函数调用时,EAX寄存器也用于保存函数返回值。

  2. ebx寄存器:又称为基址寄存器(Base Register),通常用于存储内存地址信息。在处理数据时,EBX寄存器可以作为一个指针来访问内存。

  3. ecx寄存器:又称为计数器寄存器(Counter Register),通常在循环中使用,用于对循环次数进行计数,并控制循环的结束。

  4. edx寄存器:又称为数据寄存器(Data Register),通常用来存储数据,例如两个操作数的乘积,以及一些特定系统调用的参数。

  5. esp寄存器:又称为栈指针寄存器(Stack Pointer Register),用于指向当前栈顶的位置,也就是最后压入栈中的数据的地址。在函数调用时,ESP寄存器也用于保存当前函数的栈帧信息。

  6. ebp寄存器:又称为基址指针寄存器(Base Pointer Register),用于指向当前栈帧的基地址,也就是当前函数栈帧在栈中的起始位置。在函数调用时,EBP寄存器可以用来定位本地变量和函数参数。

  7. esi寄存器:又称为源索引寄存器(Source Index Register),通常用于存放源数据地址,在计算机复制、移动、传输等操作中扮演着重要角色。

  8. edi寄存器:又称为目标索引寄存器(Destination Index Register),通常用于存放目标数据地址,在计算机复制、移动、传输等操作中也扮演着重要角色

3.汇编指令

  1. MOV:将数据从一个地方移动到另一个地方,例如将一个寄存器中的值移动到另一个寄存器、内存地址或立即数中。

  2. ADD/SUB:加法和减法指令,可以将两个操作数相加或相减,并将结果保存在目标寄存器或内存位置中。

  3. CMP:比较两个操作数并设置标志位,用于支持条件跳转等操作。

  4. JMP:无条件跳转指令,跳转到指定的代码位置执行。

  5. CALL/RET:用于函数的调用与返回,CALL指令将当前程序计数器(PC)入栈并跳转到指定位置,RET指令从堆栈中弹出PC并跳转回调用函数的位置。

  6. PUSH/POP:用于堆栈操作,PUSH指令将数据压入堆栈顶,POP指令将数据从堆栈顶弹出。

  7. NOP:空指令,不执行任何操作,通常用于占位或调试。

  8. XOR/OR/AND:逻辑运算指令,XOR进行异或运算,OR进行或运算,AND进行与运算。 

👇👇对于栈的详细介绍 :

🏷️在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(简称:压栈push),也可以将已经压入栈中的数据弹出(简称:出栈pop),但栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out,FIFO),多多少少像叠成一摞的书籍📚:先叠上去的书在最下面,因此要最后才能取出。

🏷️ 栈是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而出栈操作使栈减小。

👇👇函数栈帧的介绍:

esp(Extended Stack Pointer)为扩展栈指针寄存器,是指针寄存器的一种,用于存放函数栈顶指针。与之对应的是ebp(Extended Base Pointer),扩展基址指针寄存器,也被称为帧指针寄存器,用于存放函数栈底指针

我们知道,在操作系统里,栈总是向下增长的(高地址向低地址增长)。在i386下,栈顶由esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。观察下方描述图,这里栈底指针的地址是0xbfffffff,而esp栈顶指针标明了栈顶,地址为0xbffffff4。在栈上压入数据会导致esp减小,弹出数据使得esp增大。相反,直接减小esp的值也等效于在栈上开辟空间,直接增大esp的值等价于在栈上回收空间。栈保存了一个函数调用所需要的维护信息,通常由esp以及ebp两个寄存器来维护,常被称为栈帧(Stack Frame) 

二、函数调用中的栈帧

🔖我们在VS2013上进行观察比较方便,因为较高编译器版本底层的封装逻辑太严密,函数调用过程中的栈帧的创建是略有差异的,所以不同的编译器具体细节是取决于编译器的。

我们常常以main函数开始编写代码,调用自己写的函数,那么main函数会被其他函数调用吗?答案是有的。

为了方便查看函数栈帧调用过程的细节问题,我们直接把代码划分的足够细致。

⌨️以下用C语言代码编写:

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

🔖 我们简单写一个Add函数的代码程序,按「F10」进行调试起来,在【调试】->【窗口】->【调用堆栈】里就可以看到,(如果看不到,继续尝试往下调试),main函数是被__tmainCRTStartup函数调用的,而__tmainCRTStartup函数其实又被mainCRTStartup函数调用。

 每个函数调用,都会为此分配一块栈空间,需要函数栈帧来维护。

那么在调用main函数之前,有一块函数栈帧空间用来维护__tmainCRTStartup的。

1.探究main函数栈帧的创建

接下来我们还是在【调试】->【窗口】打开反汇编,通过分析汇编指令来具体研究函数栈帧创建与销毁的逻辑。

⚠️注意:在分析汇编指令之前,最好右键取消显示符号名,不然有些代码不方便观察。

在观察第一行 push ebp之前,我们打开监视,先观察一下esp的值为0x008ffba8、ebp的值为0x008ffbf4

 

🔔观察上图,第一行的 push ebp指令,进行压栈操作

📌图形展示:

按 「F10」调试走一步,

发现esp的地址减小了,由原来的0x008ffba8 变为了0x008ffba4 

查看内存,esp的值被修改为0x008ffbf4 说明ebp压栈成功。

 

🔔再次观察第二行mov ebp,esp  这条语句可以译为将esp的值给ebp,说明ebp此时应该指向esp所指向的位置。

📌图形展示:

不信的话,我们「F10」往下走一步,通过监视1,我们可以发现esp的值确实达到了与ebp的值相等的效果。

 

🔔我们继续往下走 sub esp,0E4h  即将esp减去一个0E4h(0E4h,h表示HEX,十六进制的意思,实际表示的是0xe4)的值,说明esp的地址减小了,esp指向的位置就会跑向更低的地址去了。

F10继续走一步,发现esp的值确实减少了许多。

📌图形展示: 

 我们可以预料到,此时esp与ebp之间的空间就是为main函数预开辟好的函数栈帧空间了。

🔔接下来,有三次压栈操作:push ebx、push esi、push edi

🔻对ebx进行压栈操作,esp栈顶指针指向了ebx

🔻对esi进行压栈操作,esp栈顶指针指向了esi

🔻对edi进行压栈操作,esp栈顶指针指向了edi

📌图形展示: 

继续往下走,

🔔lea   edi,[ebp-0E4h]  lea指令的意思是Load effective address,译为加载有效地址,把ebp-0E4h的地址加载到edi中,咦?看到这里,乍一看,我们0E4h这个值怎么这么眼熟?,对,这个值在前面出现过:“esp减去了0E4h”,原来如此,ebp-0E4就是esp在三次压栈操作之前指向的位置。

🔔 move ecx,39h:  将十六进制的39赋值给ecx寄存器中,这里其实表示的是39h次,这里的多少次并不是固定的,需要根据编译器确定。

🔔move eax,0CCCCCCCCh: 将0xcccccccc这个十六进制的数字赋值给eax中

继续走到rep stos  这个指令,才是正儿八经的改变栈帧里的数据了,rep指令的目的是重复其上面的指令ecx的值是重复的次数。stos指令的作用是将eax中的值拷贝到edi所指向的地址处。dword:表示double word(4个字节),1个word表示2个字节。

以上过程完整叙述就是:将edi位置开始,向下的ecx次,也就是39h次,这么多个空间(每个空间4个字节)全部修改为eax的值,即0xCCCCCCCC

我们继续调试一步,观察内存中,从 0x008FFAC0 开始,一共39h*4个字节大小的空间,直到0x008FFBA4 之前,都被修改为0xcccccccc   其实为当前main函数开辟的空间都被修改为cccccccc这样的值。

 📌图形展示:

 ok,到此为止,为main函数开辟的栈帧空间就准备完毕了。

2.对main函数中的代码进行分析

接下来,我们正式进入对代码进行分析了~~

 ​​🔔mov dword ptr [ebp-8],0Ahmov指令,将0Ah的值(0Ah转换十进制是10),赋给ebp-8指向的位置,此时ebp-8就是为a变量开辟的空间,值为10。(那么,我们在这里是不是就可以知道,如果局部变量没有初始化,那么它的值就是一个随机值,只不过在这里表示的是0xcccccccc),0xcccccccc如果打印成文本就是“烫烫烫”。

 📌图形展示:

我们再往下调试一步,

🔔mov dword ptr [ebp-14h],14h将14h(14转换成二进制是20),赋给ebp-14h指向的位置,ebp-14h指向的空间就是为变量b开辟的一块空间,值为20

 📌图形展示:

 🔔mov dword ptr [ebp-20h],0 :将0(0转换成二进制是0),赋给ebp-20h指向的位置,ebp-20h指向的空间就是为变量b开辟的一块空间,值为0

 

 📌图形展示: 

到此,我们应该就明确了局部变量是怎么创建和初始化的吧~,接下来我们继续往下看, 

来到调用Add函数的部分

🔔mov eax,dword ptr [ebp-14h] 又是mov指令,将ebp-14h的值,也就是b的值给到eax,鼠标悬停到eax上面,我们可以看到eax的值为0x00000014 ,eax的值就是20

🔔push eax 对eax进行压栈操作,esp往上走一步

🔔mov ecx,dword ptr [ebp-8] 又是mov指令,将ebp-8的值,也就是a的值给到ecx,鼠标悬停到ecx上面,我们可以看到ecx的值为0x0000000a ,ecx的值就是10

🔔push ecx 对ecx进行压栈操作,esp再往上走一步

📌图形展示: 

走到这里的时候,想必大部分读者都会认为这是函数的传递参数吧,答案的确是的,那么后面的结果如何呢?我们继续往下看,

🔔call 00C210E1 接下来就是调用Add函数,此时需要按 「F11」键

调用Add函数指令之后,我们再次观察esp减少了,变成0x008ffaab 这个地址里面放进了00c21450这个值,这个值恰好是call指令下面一条指令的地址,为什么呢,其实这里就是Add函数调用结束需要回到call指令下一条继续执行,所以需要记录call下面的一条指令。

 📌图形展示: 

3.探究Add函数栈帧的创建 

 继续「F11」之后,进入Add函数,此时就是准备为Add函数创建函数栈帧空间

🔔push ebp对ebp进行压栈操作,把指向main函数的ebp寄存器压入栈顶

🔔mov ebp,esp把esp寄存器中的值移动到ebp,此时由原来ebp指向main函数栈帧空间移动到esp指向的空间位置,

📌图形展示: 

🔔sub esp,0CCh:将esp减去0CCh,esp寄存器此时再次向下增长,此时esp与ebp之间的空间就是为Add函数预开辟好的栈帧,

📌图形展示:   

 🔔push ebx、push esi、push edi:三次压栈操作,和main函数开辟栈帧时一样,这里就不多说了,直接上图~

📌图形展示:   

🔔 接下来lea指令、mov指令、rep stos指令是让Add函数的空间都初始化为0CCCCCCCC这样的值

📌图形展示:  

🔔mov dword ptr [ebp-8],8接下来就可以给局部变量z赋予空间了,mov指令就是将0赋值给ebp-8指向的空间里,

 

📌图形展示:   

 z = x + y,这个代码怎么分析呢?难道我们会再次赋予两个空间给x和y吗?其实不然,我们继续往下看

🔔mov eax,dword ptr ebp+8ebp+8空间里的值移动到eax当中去。ebp+8得到的地址值增大了,我们在图形中往下寻找,找到ebp+8指向的位置,咦,不就是我们之前将ecx寄存器进行压栈操作压入main函数栈帧上面的吗,ecx寄存器里放的就是10啊,那此时eax里面的值放的就是10

🔔add eax,dword ptr ebp+0Chebp+0Ch的值就是ebp+12,将ebp+12所指向的空间里的值,值为20加到eax寄存器中,寄存器中的值就是20了。

🔔mov dword ptr [ebp-8],eax该指令将eax的值放到ebp-8的位置,即将30赋予给局部变量Z空间里。

所以到这里我们知道,形参并不会在函数内部进行创建,逻辑其实是调用Add函数时,将a和b的值进行了压栈操作,然后进入到Add函数里面时,寄存器就会找到对应压栈时压的值,ebp+8、ebp+12 里的值就是a、b的一份临时拷贝,也就对应了形参x、y值。所以我们就能通透理解形参是实参的一份临时拷贝,修改形参并不会影响实参!

 📌图形展示: 

 那么看到这里,怎么将z的值进行返回呢?我们继续往下看

🔔  mov eax,dword ptr [ebp-8]这一操作指令的意思是将ebp-8空间里的值,也就是z的值,赋值给eax寄存器中,因为出函数作用域,局部变量z会被销毁!而eax寄存器中的数据是不会立马销毁。

ok,以上Add函数执行完毕,我们接下来执行返回操作了,也就是函数栈帧逐步销毁的过程了。

三、函数栈帧的销毁过程

🔔pop 出Add函数之后,有三次pop出栈操作,将edi、esi、ebx寄存器中的数据从栈顶弹出

 📌图形展示:

🔔mov esp,ebp将ebp寄存器中的值赋值到esp寄存器中,esp指向的位置就是ebp寄存器指向的位置

🔔pop ebp: ebp此时指向的位置是在为main函数开辟函数栈帧空间时压栈的ebp,在Add函数调用完毕之后,回到main函数的栈帧空间,那么main函数栈帧的栈底在哪里呢?这是对当前的ebp寄存器进行pop出栈操作,此时就返回到了main函数栈帧的栈底

🔽此时esp寄存器和ebp寄存器又正式开始维护main函数的栈帧空间

🔔接下来就是ret指令 ret就是返回呀,没错,根据00C21450这个地址返回,也就是当时调用Add函数call指令的一条指令的地址,将这个地址pop一下,就回到了call指令的下一条继续执行

📌图形展示: 

🔔add esp,8:  此时是esp的地址加8,地址增大,空间减小,相当于当时形参x、y两个变量的空间由操作系统回收了,

 🔔mov dword ptr [ebp-20h],eax: 这条指令的意思就是将eax的值赋给ebp-20h所指向的空间,我们观察图,可以看到ebp-20h不就是为局部变量c开辟的一块空间吗?c的值就是当初出Add函数作用域时,存放到eax存储器中的数据,这个值就是30!

📌图形展示: 

 

这样整个Add函数销毁的过程就很清晰啦,main函数的栈帧销毁过程就不再赘述了~

我们在开头留的一些问题,通过理解函数栈帧的创建与销毁的过程,相信各位道友们,有所更清晰的认识吧~~

🤗🤗 好啦,本篇文章就到此为止啦~ 感谢大家的支持!希望对你有帮助,如有什么疑问,可以在评论区or私信告诉我~~ 🥰🥰😉

版权声明:本文为博主作者:阿四啊原创文章,版权归属原作者,如果侵权,请联系我们删除!

原文链接:https://blog.csdn.net/qq_63320529/article/details/130168312

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
乘风的头像乘风管理团队
上一篇 2023年12月29日
下一篇 2023年12月29日

相关推荐