细谈函数栈帧的创建与销毁

简介: 我们在写C语言代码时,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数如何调用?函数的返回值如何返回的?函数参数是如何传递的?这些问题都与函数栈帧有关系。

函数栈帧是如何创建的?又是如何销毁的?涉及到内存空间的本质问题,我们大多数人可能是一头雾水,栈是什么?有什么用?接触细节原理,印象才能更加深刻,带你深入的学习栈是如何创建与销毁能帮助你理解更多深层的问题,局部变量是如何创建的,为什么一开始要从main函数开始呢?函数的形参与实参有和关系,返回值又怎么返回的等等问题。理解这些问题你对编程的理解将会大大提升的。


一. 什么是函数栈帧?


我们在写C语言代码时,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。

那函数如何调用?函数的返回值如何返回的?函数参数是如何传递的?这些问题都与函数栈帧有关系。


函数栈帧(stack frame)就是函数调用过程中在程序的调用栈所开辟的空间,这些空间是用来存放:


  • 函数参数和函数返回值
  • 局部变量
  • 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。


二. 理解函数栈帧能解决什么问题呢?


理解函数栈帧有什么用呢?

只要理解了函数栈帧的创建和销毁,一下问题就能很好的理解了

  • 局部变量是如何创建的?
  • 为什么局部变量不初始化内容是随机值?
  • 函数调用时参数是如何传递的?传参的顺序是怎样的?
  • 函数的形参与实参分别是怎么实例化的?
  • 函数的返回值是如何带回来的?


等讲完函数栈帧的创建于销毁的过程后,这些问题我会一一解释的。

三.剖析函数栈帧的创建和销毁


1.什么是栈


栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。

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

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


在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。


在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。


2.认识相关寄存器和汇编指令


寄存器:寄存器是独立于内存是集成到CPU上的

硬盘

内存

寄存器

三者都是存放内存的是相互独立的。


2.1相关寄存器


eax: 通用寄存器,保留临时数据,常用于返回值。

ebx:通用寄存器,保留临时数据

ebp:栈底寄存器

esp:栈顶寄存器

eip:指令寄存器,保存当前指令的下一条指令的地址。


2.2相关汇编命令


mov:数据转移指令

push:数据入栈,同时esp栈顶寄存器也要发生改变

pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变

sub:减法命令

add:加法命令

call:函数调用,1.压入返回值2.转入目标函数

jump:通过修改eip,转入目标函数,进行调用

ret:恢复返回地址,压入eip,类似pop eip命令


3.解析函数栈帧的创建和销毁


3.1相关知识


首先我们需要知道一些基本的知识才能有效的帮助我们理解,函数栈帧的创建和销毁。


1.函数每次调用的时候,都要为本次函数开辟空间,就是函数栈帧的空间


2.这块函数栈帧空间是由两个寄存器维护的:esp ,ebp,ebp是维护记录栈底的地址,esp是维护记录栈顶的地址。所谓栈顶,栈底,是根据栈的使用方式来确定的,栈是先使用的后弹出,就像书本从上往下叠,而书本的下面是栈底,书的上面是栈顶。


例如:


e9fcc8b59c734f78a944fa9a072ef1fc.png


3.函数栈帧的创建和销毁在不同的编译器的实现方法大同小异,本次演示的是VS2019。


4.栈空间的使用方式是先使用高地址的空间,再使用低地址的空间


5.push(压栈):给栈顶放一个元素。pop(出栈)从栈顶删除一个元素。


ef496cfbf3ac418092ee41e14d360dbc.png


3.2函数的调用堆栈


当我们按F10调试,进入函数的调用堆栈里发现main函数被调用了。可是main函数又被谁调用的呢?


595820cedc204e9c9fdfb02d88199130.png


函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,main函数调用之前,是由谁调用main函数的。


main函数是由一个_…main函数调用的。


在_…main函数之前还有函数调用,我们先不管。


那我们可以确定,_…main函数应该也会有自己的函数栈帧,main函数和Add函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp,esp来维护栈帧空间。


80ccf72906eb48e188e46953a9e15289.png


bd47d4ec547a44129fddb66235723c5e.png

2a96bbb48c9945b2bf75913489b35012.png


所以:


a28cabcde39a403fb50ae11e8109add5.png


在main函数调用之前已经有函数在开辟空间了。


4492056bc0a44634b8865f94ba7b5013.png


好的理解这个,我们就从main函数的栈帧创建开始将:


3.3转到反汇编


调试到main函数开始执行的第一行,右击鼠标转到反汇编上。


c107265550c747859212a146e05b5be1.png


int main()
{
//函数栈帧的创建
00C21410   push                   ebp
00C21411   mov                    ebp,esp 
中00C21413  sub                    esp, 0E4h
00C21419   push                   ebx
00C2141A   push                   esi
00C2141B   push                   edi                   
00C2141C  lea                     edi, [ebp+FFFFFF1Ch]
00C21422  mov                     ecx, 39h
00C21427  mov                     eax, OCCCCCCCCh
00C2142C  rep stos                dword ptr es:[edi]
//main函数中的核心代码
inta=10;
00C2142E   mov                    dword ptr [ebp-8], 0Ah
intb=20;
00C21435   mov                    dword ptr [ebp-14h],14h
intC=0;
00C2143C   mov                    dword ptr [ebp-20h],0
C = Add(a, b);
中00C21443 8B 45 EC      mov        eax, dword ptr [ebp-14h]
00C21446 50             push    eax
00C21447 8B 4D F8   mov     ecx,dword ptr [ebp-8]
00C2144A 51       push    ecx
00C2144B E8 91 FC FF FF call    00C210E1
00C21450 83 C4 08     add     esp,8
00C21453 89 45 E0   mov     dword ptr [ebp- 20h],eax


3.4剖解函数栈帧的创建


上面是main函数转化而来的汇编代码


接下来我们就一行一行的拆解汇编代码。


int main()
{
//函数栈帧的创建
00C21410   push                   ebp   把ebp寄存器中的值进行压栈,此时的ebp中存放的是_tmainCRTStartup函数栈帧的ebp,ebp-4
00C21411   mov                    ebp,esp move指令会把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是_tmainCRTStartup函数栈帧的esp
中00C21413  sub                    esp, 0E4h//sub会让esp中的地址减去一个16进制数字0xe4h,产生新的
//esp,此时的esp是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一
//个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量
00C21419   push                   ebx//将寄存器ebx的值压栈,esp-4
00C2141A   push                   esi//将寄存器esi的值压栈,esp-4
00C2141B   push                   edi//将寄存器edi的值压栈,esp-4         
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄
//存器原来的值,以便在退出函数时恢复。      
//下面的代码是在初始化main函数的栈帧空间。
//1. 先把ebp-0e4h的地址,放在edi中
//2. 把9放在ecx中
//3. 把0xCCCCCCCC放在eax中
//4. 将从edp-0e4h到ebp这一段的内存的每个字节都初始化为0xCC   
00C2141C  lea                     edi, [ebp+FFFFFF1Ch]
00C21422  mov                     ecx, 39h
00C21427  mov                     eax, OCCCCCCCCh
00C2142C  rep stos                dword ptr es:[edi]
//main函数中的核心代码
inta=10;
00C2142E   mov                    dword ptr [ebp-8], 0Ah
intb=20;
00C21435   mov                    dword ptr [ebp-14h],14h
intC=0;
00C2143C   mov                    dword ptr [ebp-20h],0
C = Add(a, b);
中00C21443 8B 45 EC      mov        eax, dword ptr [ebp-14h]
00C21446 50             push    eax
00C21447 8B 4D F8   mov     ecx,dword ptr [ebp-8]
00C2144A 51       push    ecx
00C2144B E8 91 FC FF FF call    00C210E1
00C21450 83 C4 08     add     esp,8
00C21453 89 45 E0   mov     dword ptr [ebp- 20h],eax


f0b4ede4aa5843c5af3d9cd9d753f99f.png

39831903a70d4602b129fc4c48e85828.png

bf459c39d6b54d2c820c1be46a21fe3c.png

5dfebb3f6d7b4c139575ad39f6dddb04.png


所以你知道为什么局部变量不初始化会变成随机值了吗?


因为在函数栈帧开辟的时候,栈帧里面已经被初始化成cccccccc,,是不是有时你还会莫名其妙的打印出


烫烫烫烫烫烫烫烫烫~就是因为这个原因。


b7592da964764295bef31b65c6895ce8.png


之所以上面的程序输出“烫”这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。


以上就是main函数栈帧的开辟过程,没错,这只是开辟main函数栈帧空间,main函数的功能还没实现,接下来再来看看main函数是如何实现功能的。


//main函数中的核心代码--main函数功能的实现
inta=10;
00C2142E   mov                    dword ptr [ebp-8], 0Ah
//将Ah存储到ebp-8的地址处,ebp-8的位置其实就a变量
intb=20;
00C21435   mov                    dword ptr [ebp-14h],14h
//将14h存储到ebp-14h的地址处,ebp-14h的位置就是b变量
intC=0;
00C2143C   mov                    dword ptr [ebp-20h],0
//将0存储到ebp-20h的地址处,ebp-20h的位置其实是ret变量


以上汇编代码就表示局部变量a,b,ret的创建和初始化


所以局部变量的创建是在栈帧空间中创建的


a4feb124bbcb4bf89835eac382d4b858.png


好了main函数的局部变量也创建好了,要准备进入Add函数了,也就是要调用Add函数了,不过Add函数是怎么传参的呢?

请往下面接着看:


//调用Add函数
C = Add(a, b);
//调用Add函数时的传参
//其实传参就是把参数push到栈帧空间中
中00C21443 8B 45 EC      mov        eax, dword ptr [ebp-14h]
//传递b,将ebp-14h处放的20放在eax寄存器
00C21446 50             push    eax//将eax的值压栈,esp-4
00C21447 8B 4D F8   mov     ecx,dword ptr [ebp-8]
//传递a,将ebp-8处放的10放在ecx寄存器中
00C2144A 51       push    ecx//将ecx的值压栈,esp-4
//跳转调用函数
00C2144B E8 91 FC FF FF call    00C2145083C408//看这是什么,这是call指令下一条的地址。为什么这样呢?
//因为函数调用后,还要回来继续执行下面的代码,所以将下面的指令的地址记住,如何等函数结束时能找回来
00C21450 83 C4 08     add     esp,8
00C21453 89 45 E0   mov     dword ptr [ebp- 20h],eax


f78927a6713647daaac13247fb506201.png


call 指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。


call调用函数后,就会进入Add函数内部,下面就是Add函数的反汇编了,看看前面是不是跟main函数很像呢,没错,前面也是在为Add函数预开辟栈帧空间。我们再来好好顺一顺吧。


8e7e2ec44f0f4939b8369bfc3c54820c.png

f5180f0f417f4adc96ae4a62358a5bef.png


预栈帧空间


66c5fb22ec1d45c3bf74d76ad8238951.png

165422a6ac844c3799957b5bead57b72.png


图片中的 ecx 和 eax 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程,以及


函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。


402be14b03a242009f86c1e1b987bfb1.png


代码执行到Add函数的时候,就要开始创建Add函数的栈帧空间了。


在Add函数中创建栈帧的方法和在main函数中是相似的,在栈帧空间的大小上略有差异而已


1.. 将main函数的 ebp 压栈

2.计算新的 ebp 和 esp

3.将 ebx , esi , edi 寄存器的值保存

4.计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。

5.将求出的和放在 eax 寄存器尊准备带回


以上就是main函数和Add函数栈帧的创建。接下来我们来看函数栈帧是如何销毁的呢?


3.5函数栈帧的销毁


当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。


那具体是怎么销毁的呢?我们看一下反汇编代码


ee5133a463a0467381aac2837bd271f9.png

671d10c227a3472fb6042e95bf841813.png

d2cef80c22e74f8bb728b17d05703ee4.png


所以最后就还剩下main函数的栈帧空间


8e377861a9c1409bb2b67a3f3c0bd4a2.png


3.6.拓展了解:


其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。

四.总结:


我们来总结一下以上问题已经相关注意点。


1.局部变量是如何创建的?


1.首先先为函数分配栈帧空间

2.初始化部分栈帧空间ccccccc

3.然后在函数栈帧里给局部变量分配一些空间

2.为什么局部变量不初始化内容是随机值?


因为变量是我们自己放进去的,如果初始化就会把随机值覆盖了,不初始化就会保留原来的随机值

3.函数调用时参数是如何传递的?传参的顺序是怎样的?


1.在调用函数之前系统就先push push 把这两个参数从右向左开始压栈进去。

2.而真正到函数调用的时候我们是通过指针的偏移量来找到形参

4.函数的形参与实参分别是怎么实例化的?


形参确实是在我们压栈时开辟的空间,它和形参只是值上相同,但空间是独立的是不同的空间

故形参是实参的临时拷贝,改变形参不会影响实参

5.函数的返回值是如何带回来的?


函数的返回值一般是通过存放在寄存器中然后带回来的

6.注意点:


1.我们在调用之前就把call指令的下一条指令地址记住了,存进去了压进去了

2.我们在调用的时候就把main函数指向栈底的地址记住下,存进去压进去了,然后最好pop弹出去,把这个地址放进edp中,edp就更新到mian函数栈底去了


3.epb和eps是存放维护当前栈空间函数的栈底地址,跟函数地址没有关系。它当有函数调用时就维护这个函数栈帧,有另一个函数调用时就维护另一个函数栈帧。

相关文章
|
6月前
|
编译器
函数栈帧的创建和销毁
函数栈帧的创建和销毁
32 0
|
7月前
|
存储 编译器 容器
函数栈帧的创建和销毁讲解
函数栈帧的创建和销毁讲解
48 0
|
存储
函数栈帧的创建和销毁(下)
函数栈帧的创建和销毁(下)
59 0
|
7月前
|
容器
函数栈帧的创建和销毁介绍
函数栈帧的创建和销毁介绍
44 0
|
7月前
|
存储 编译器
初识函数栈帧的创建与销毁(笔记)
初识函数栈帧的创建与销毁(笔记)
|
编译器 程序员 C语言
函数栈帧的创建与销毁(超详解)
函数栈帧的创建与销毁(超详解)
118 0
|
存储 缓存 编译器
函数栈帧的创建与销毁
函数栈帧的创建与销毁
47 0
|
存储 C语言 C++
你知道函数栈帧的创建和销毁吗?
你知道函数栈帧的创建和销毁吗?
78 0
|
编译器 C语言 容器
函数栈帧的创建和销毁(一)
函数栈帧的创建和销毁
120 1
|
存储 编译器
函数栈帧的创建和销毁(上)
函数栈帧的创建和销毁
59 0