..
目录
知识点回顾
一、什么是栈帧(堆栈帧)?
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中比较常见的寄存器,它们的含义和作用如下:
eax寄存器:又称为累加器寄存器(Accumulator Register),通常用于存储算术计算的操作数和结果。在函数调用时,EAX寄存器也用于保存函数返回值。
ebx寄存器:又称为基址寄存器(Base Register),通常用于存储内存地址信息。在处理数据时,EBX寄存器可以作为一个指针来访问内存。
ecx寄存器:又称为计数器寄存器(Counter Register),通常在循环中使用,用于对循环次数进行计数,并控制循环的结束。
edx寄存器:又称为数据寄存器(Data Register),通常用来存储数据,例如两个操作数的乘积,以及一些特定系统调用的参数。
esp寄存器:又称为栈指针寄存器(Stack Pointer Register),用于指向当前栈顶的位置,也就是最后压入栈中的数据的地址。在函数调用时,ESP寄存器也用于保存当前函数的栈帧信息。
ebp寄存器:又称为基址指针寄存器(Base Pointer Register),用于指向当前栈帧的基地址,也就是当前函数栈帧在栈中的起始位置。在函数调用时,EBP寄存器可以用来定位本地变量和函数参数。
esi寄存器:又称为源索引寄存器(Source Index Register),通常用于存放源数据地址,在计算机复制、移动、传输等操作中扮演着重要角色。
edi寄存器:又称为目标索引寄存器(Destination Index Register),通常用于存放目标数据地址,在计算机复制、移动、传输等操作中也扮演着重要角色
3.汇编指令
MOV:将数据从一个地方移动到另一个地方,例如将一个寄存器中的值移动到另一个寄存器、内存地址或立即数中。
ADD/SUB:加法和减法指令,可以将两个操作数相加或相减,并将结果保存在目标寄存器或内存位置中。
CMP:比较两个操作数并设置标志位,用于支持条件跳转等操作。
JMP:无条件跳转指令,跳转到指定的代码位置执行。
CALL/RET:用于函数的调用与返回,CALL指令将当前程序计数器(PC)入栈并跳转到指定位置,RET指令从堆栈中弹出PC并跳转回调用函数的位置。
PUSH/POP:用于堆栈操作,PUSH指令将数据压入堆栈顶,POP指令将数据从堆栈顶弹出。
NOP:空指令,不执行任何操作,通常用于占位或调试。
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],0Ah:mov指令,将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+8:将ebp+8空间里的值移动到eax当中去。ebp+8得到的地址值增大了,我们在图形中往下寻找,找到ebp+8指向的位置,咦,不就是我们之前将ecx寄存器进行压栈操作压入main函数栈帧上面的吗,ecx寄存器里放的就是10啊,那此时eax里面的值放的就是10
🔔add eax,dword ptr ebp+0Ch:ebp+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函数的栈帧销毁过程就不再赘述了~
我们在开头留的一些问题,通过理解函数栈帧的创建与销毁的过程,相信各位道友们,有所更清晰的认识吧~~文章来源:https://www.toymoban.com/news/detail-455328.html
🤗🤗 好啦,本篇文章就到此为止啦~ 感谢大家的支持!希望对你有帮助,如有什么疑问,可以在评论区or私信告诉我~~ 🥰🥰😉文章来源地址https://www.toymoban.com/news/detail-455328.html
到了这里,关于探秘函数栈帧:『 揭开函数栈帧创建与销毁的神秘面纱 』的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!