【C语言】万字讲解函数栈帧的创建与销毁

目录

前言

一、什么是函数栈帧?

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

三、函数栈帧的创建和销毁解析

3.1 什么是栈?

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

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

3.3.1 esp寄存器与ebp寄存器的重要性

3.3.2 函数的调用堆栈

3.3.3 准备环境

3.3.4 转到反汇编

3.3.5 函数栈帧的创建

3.3.6 执行有效代码

3.3.7 回答理解函数栈帧能解决什么问题中的六个问题

总结


前言

函数为C语言中最基本的一个单位,但它是如何创建的您了解吗?它又是如何传参的呢?又是如何返回的呢?本文章将深入底层,详细讲解关于函数栈帧的创建与销毁一系列知识,看完本文,您将收获匪浅。


一、什么是函数栈帧?

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

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

  • 函数参数和函数返回值
  • 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
  • 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。

简单理解就是创建函数时,会在栈区创建一块空间,而这块空间正是函数栈帧。


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

理解函数栈帧有什么用呢?只要理解了函数栈帧的创建和销毁,以下问题就能够很好的理解了:

  1. 局部变量是如何创建的?
  2. 为什么局部变量不初始化内容是随机的?
  3. 函数调用时参数时如何传递的?
  4. 传参的顺序是怎样的?
  5. 函数的形参和实参分别是怎样实例化的?
  6. 函数的返回值是如何带回的?

函数栈帧的知识是偏向底层的,当理解透彻后,对理解变量的存储、静态变量的创建、动态内存的申请与销毁等等知识点有很大的帮助!


三、函数栈帧的创建和销毁解析

讲解函数栈帧之前,需要了解一些预备知识点。

3.1 什么是栈?

栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函 数,没有局部变量,也就没有我们如今看到的所有的计算机语言。 在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可 以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出 栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据 从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。 在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。 在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。

栈最主要的特性:先进后出。这里简单介绍,如想了解栈相关知识点,可以看《超详细之实现栈》


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

在函数栈帧的创建与销毁过程中,涉及到了寄存器和汇编指令知识点。

寄存器的功能是存储二进制代码,它是由具有存储功能的触发器组合起来构成的。一个触发器可以存储1位二进制代码,故存放n位二进制代码的寄存器,需用n个触发器来构成。 [1]

按照功能的不同,可将寄存器分为基本寄存器和移位寄存器两大类。基本寄存器只能并行送入数据,也只能并行输出。移位寄存器中的数据可以在移位脉冲作用下依次逐位右移或左移,数据既可以并行输入、并行输出,也可以串行输入、串行输出,还可以并行输入、串行输出,或串行输入、并行输出,十分灵活,用途也很广。详细可见百度百科:寄存器

汇编指令是汇编语言中使用的一些操作符和助记符,还包括一些伪指令(如assume,end),汇编指令同机器指令一一对应。每一种CPU都有自己的汇编指令集。 [1]

计算机是通过执行指令来处理数据的,为了指出数据的来源、操作结果的去向及所执行的操作,一条指令一般包含操作码和操作数两部分。详细可见百度百科:汇编指令

相关寄存器:

函数栈帧创建与销毁相关寄存器
eax通用寄存器,保留临时数据,常用于返回值
ebx通用寄存器,保留临时数据
ebp栈底寄存器
esp栈顶寄存器
eip指令寄存器,保存当前指令的下一条指令的地址

相关汇编命令:

函数栈帧创建与销毁相关汇编命令
mov数据转移指令
push数据入栈,同时esp栈顶寄存器也要发生改变
pop数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub减法命令
add加法命令
call函数调用,1. 压入返回地址 2. 转入目标函数
jump通过修改eip,转入目标函数,进行调用
ret恢复返回地址,压入eip,类似pop eip命令

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

3.3.1 esp寄存器与ebp寄存器的重要性

  • esp:栈顶指针。
  • ebp栈底指针。
  • 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
  • 要理解清楚函数栈帧,就要理解ebp与esp寄存器。
  • 这块空间的维护使用了2个寄存器:esp和ebp,ebp存储的是栈底的地址,ebp存储的是栈顶的地址。
  • ebp寄存器与esp寄存器中存放的是地址,这两个寄存器共同维护函数栈帧空间。

如图所示:

关于栈区的特点:

  • 栈区的分配习惯是先使用高地址,再使用低地址。
  • 栈区往往是从高地址开始,往上使用空间。

注意:函数栈帧的创建和销毁过程,在不同的编译器上实现的方式大同小异,主要掌握的是实现过程。本文将以VS2019为例。


3.3.2 函数的调用堆栈

演示代码:

#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}

在VS2019编译器上进行调试(F10进入调试),我们可以调用函数堆栈看看,如下图:

函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到, main 函数调用之前,是由invoke_main 函数来调用main函数。这也解释了为什么main函数总是有return 0了,因为main函数也是被其它函数调用的,这里不做过多讲解,只需要知道每个函数都会有自己的栈帧。


3.3.3 准备环境

为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,我们可以关闭下面的选项,让汇编代码中排 除一些编译器附加的代码:


3.3.4 转到反汇编

观察函数栈帧需要再返汇编内观看,里边用到了寄存器与汇编指令。

为了方便观看地址,我们鼠标右键,将显示外部符号取消勾选。

int main()
{
//函数栈帧的创建
002E18B0  push        ebp  
002E18B1  mov         ebp,esp  
002E18B3  sub         esp,0E4h  
002E18B9  push        ebx  
002E18BA  push        esi  
002E18BB  push        edi  
002E18BC  lea         edi,[ebp-24h]  
002E18BF  mov         ecx,9  
002E18C4  mov         eax,0CCCCCCCCh  
002E18C9  rep stos    dword ptr es:[edi]  
//main函数中的核心代码
	int a = 3;
002E18D5  mov         dword ptr [ebp-8],3  
	int b = 5;
002E18DC  mov         dword ptr [ebp-14h],5  
	int ret = 0;
002E18E3  mov         dword ptr [ebp-20h],0  
	ret = Add(a, b);
002E18EA  mov         eax,dword ptr [ebp-14h]  
002E18ED  push        eax  
002E18EE  mov         ecx,dword ptr [ebp-8]  
002E18F1  push        ecx  
002E18F2  call        002E10B4  
002E18F7  add         esp,8  
002E18FA  mov         dword ptr [ebp-20h],eax  
	printf("%d\n", ret);
002E18FD  mov         eax,dword ptr [ebp-20h]  
002E1900  push        eax  
002E1901  push        2E7B30h  
002E1906  call        002E10D2  
002E190B  add         esp,8  
	return 0;
002E190E  xor         eax,eax  
}

以上汇编代码为main函数这个函数栈帧中一系列操作,其实可以分成两部分:

  • 函数栈帧的创建(每个函数都有这部)
  • 执行有效代码

3.3.5 函数栈帧的创建

我们先来看看第一部分,如何创建函数栈帧。我们知道main函数也是被其它函数调用的,前文提到一个函数栈帧是被esp和ebp进行维护的,此时esp与ebp还在维护着invoke_main函数栈帧,我们看看图:

接下来,开始为main函数创建栈帧,我们逐条分析:

002E18B0   push     ebp  

push压栈,将ebp压栈,简单理解就是拷贝一份ebp,放到栈顶,此时栈顶存储着栈底的地址,push后esp的指向也会改变,看图:

002E18B1   mov   ebp,esp 

mov移动,相当于赋值操作,将esp的值赋给ebp,ebp一开始是指向栈底的,赋值后,ebp指向栈顶,即esp指向的位置,看图:

002E18B3   sub   esp,0E4h 

sub减操作,将esp减去0E4h,0E4h是一个8进制数字,如想知道具体值,可以打开监视查看,这条指令的意思就是将esp指向位置减0E4h,就是改变esp位置,esp会向上移动,因为栈区下面是高地址,上边是低地址,具体移动到了哪个位置,我们可以通过内存窗口看,下面是具体的图解:

此时esp与ebp各自指向的地址之间,就是共同维护的新一块空间。当然,这块空间有多大是由编译器决定的,我们也不知道,这块空间也可以说是编译器为某函数预开辟的空间。

002E18B9   push      ebx  
002E18BA   push      esi  
002E18BB   push      edi 

接下来,会进行三次push,也就是压栈三次,分别将ebx、esi、edi压栈。


接下来的4条指令,作用是为函数栈帧初始化:

  • 先把ebp-24h的地址,放在edi中
  • 把9放在ecx中
  • 把0xCCCCCCCC放在eax中
  • 将从ebp-24h到ebp这一段的内存的每个字节都初始化为0xCC
002E18BC   lea    edi,[ebp-24h] 

lea(load effecitve address 加载有效地址),将后面这个地址加载到edi中,其实就是将edi的值改为(ebp-24h)。其实在刚刚sub操作时,将esp-0E4h,让esp向上走,那时候esp的指向和ebp是一样的,sub后指向了新的地址,此时让edi指向(ebp-24h)的值,那此时ebi的值为在3次push之前esp指向的位置了.

002E18BF   mov   ecx,9

mov移动,将9赋值给ecx。

002E18C4   mov   eax,0CCCCCCCCh

mov操作,将0cccccccch赋值给eax。

002E18C9  rep stos   dword ptr es:[edi]

关键一步,这条指令的意思是将edi以下9次或ecx次的dword(double word,1个word为2字节,double word为4字节)每次初始化4个字节,全部改为0cccccccch的内容,到ebp结束。

以上操作后,此时为该函数的函数栈帧就开辟好了,接下来开始执行有效代码了。


3.3.6 执行有效代码

int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
int a = 3;
002E18D5  mov  dword ptr [ebp-8],3 

mov移动,意思为将3赋值给[ebp-8],dword为4字节,意思就是将3放在[ebp-8]4字节位置,[ebp-8]其实就是栈底向上8字节的位置,该位置用来存储3,该位置也是为局部变量a开辟的空间。

此时也能解是局部变量是如何创建的,为什么局部变量创建的时候建议初始化了。如果不初始化,那a的值就为0cccccccch,这就是为什么会打印出烫烫烫.....的原因了。

int b = 5;
002E18DC  mov  dword ptr [ebp-14h],5

同理,与上一条指令的操作思路是一样的。还需要注意的是,在哪个地方为局部变量开辟空间是不一定的,这由编译器决定。

int ret = 0;
002E18E3  mov   dword ptr [ebp-20h],0


很多人都不清楚,函数是如何传参的,接下来4条指令,就是函数的传参过程:

res = Add(a,b);
002E18EA  mov  eax,dword ptr [ebp-14h]

mov操作,将[ebp-14h]的值赋给eax,[ebp-14h]是什么呢?就是b所在的空间中的5。

002E18ED  push   eax

push压栈,将eax压栈。

002E18EE   mov  ecx,dword ptr [ebp-8]

mov操作,将[ebp-8]的值赋给ecx,[ebp-8]就是a所在的空间里的3。

002E18F1  push  ecx

push压栈,将ecx压栈。

eax与ecx分别存储了b与a的值,并且进行压栈操作,注意:此时还没有调用函数Add,那也就是说,函数的传参其实是在调用之前完成的,而形参是实参的一份临时拷贝这句话也能理解了,eax与[ebp-14h]、ecx与[ebp-8] 均不在一块空间,并且eax与ecx存储的值分别为b与a的值,这就是临时拷贝。关于函数传参,是从右到左执行的


接下来,就准备进行函数调用了。

002E18F2  call   002E10B4

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

002E10B4  jmp   002E1770 

这里不用过多关注,再按一次F11,就进入Add函数了。

int Add(int x, int y)
{
//函数栈帧的创建
002E1770  push        ebp  
002E1771  mov         ebp,esp  
002E1773  sub         esp,0CCh  
002E1779  push        ebx  
002E177A  push        esi  
002E177B  push        edi  
002E177C  lea         edi,[ebp-0Ch]  
002E177F  mov         ecx,3  
002E1784  mov         eax,0CCCCCCCCh  
002E1789  rep stos    dword ptr es:[edi]  
//执行有效代码
	int z = 0;
002E1795  mov         dword ptr [ebp-8],0  
	z = x + y;
002E179C  mov         eax,dword ptr [ebp+8]  
002E179F  add         eax,dword ptr [ebp+0Ch]  
002E17A2  mov         dword ptr [ebp-8],eax  
	return z;
002E17A5  mov         eax,dword ptr [ebp-8]  
}
//函数销毁并回到main函数内,销毁函数内部的局部变量
002E17A8  pop         edi  
002E17A9  pop         esi  
002E17AA  pop         ebx   
002E17B8  mov         esp,ebp  
002E17BA  pop         ebp  
002E17BB  ret

此时的逻辑跟创建main函数栈帧逻基本辑相同,但要分为三部分,前两部分与main函数逻辑相同,在执行有效代码部分,如何使用形参也是重点,第三部分为函数销毁、如何将返回值带回、如何回到main函数内、如何销毁形参。下面进行解析:

//Add函数栈帧的创建
002E1770  push        ebp  
002E1771  mov         ebp,esp  
002E1773  sub         esp,0CCh  
002E1779  push        ebx  
002E177A  push        esi  
002E177B  push        edi  
002E177C  lea         edi,[ebp-0Ch]  
002E177F  mov         ecx,3  
002E1784  mov         eax,0CCCCCCCCh  
002E1789  rep stos    dword ptr es:[edi]  

第一部分为创建Add函数栈帧指令,因为上文解释过了,这里不进行过多解释了,看看图解吧:


第二部分是执行有效代码,这里需要关注形参的使用。

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

与上文逻辑相同,为z开辟一个空间。不过多解释。

z = x + y;
002E179C  mov   eax,dword ptr [ebp+8]  
002E179F  add   eax,dword ptr [ebp+0Ch]  
002E17A2  mov   dword ptr [ebp-8],eax

以上3条指令的作用是使用形参x,y,并且相加赋给z。一条条解释吧:

002E179C  mov   eax,dword ptr [ebp+8]

mov操作,将[ebp+8]的值赋给eax,[ebp+8]是什么呢?看看下图:

一块内存空间就为4字节,那ebp+8的话,就是ebp向下8字节,那就是ecx内存空间,也就是a所在的空间,看到这里相信聪明的您明白了吧。函数内的形参并不会在它的函数栈帧中分配一块空间,而是当要使用时,寻找之前压栈的ecx与eax,所有严格上来说,函数的形参是创建在主调函数栈帧里的。

002E179F  add   eax,dword ptr [ebp+0Ch]

add增加指令,其实就是找到[ebp+0Ch],然后eax + [ebp+0Ch],而[ebp+0Ch]的值也就是eax空间,b的值了,找到后进行相加,也就对应着:x + y了。

这里有个点,就是当寻找形参时,是从左向右执行的,这点与函数传参是不同。

002E17A2  mov   dword ptr [ebp-8],eax

mov操作,将eax的值赋给[ebp-8],逻辑跟上文创建局部变量相同,eax的值就是刚刚相加后的值,[ebp-8]就是Add函数栈帧内的一处空间,为z开辟了一处空间,这块空间就是[ebp-8],里面的内容是eax,就是形参相加后的值。


继续执行,接下来是return返回语句,那函数中怎么带回返回值呢?

return z;
002E17A5  mov   eax,dword ptr [ebp-8]

我们知道,在函数返回后,也代表这个函数结束,进行销毁,而进行销毁,那z的值不也销毁了吗?那我们怎么带回去呢?

mov指令,将[ebp-8]的值赋给eax,这里非常巧妙,[ebp-8]为z,而eax是什么呢?eax是一个寄存器,寄存器是独立于内存之外的,不会随着函数的结束而销毁,因此将返回值放到eax中,等回到主调函数再拿出来。


此时Add函数内的有效代码全部执行完毕,但并还没有结束,接下来将进行函数栈帧的销毁、返回到主调函数。

002E17A8  pop     edi  
002E17A9  pop     esi  
002E17AA  pop     ebx

还记得在创建函数栈帧的时候push了3个寄存器到栈顶吗?而此时,又进行3次pop,将edi、esi、ebx依次弹出,esp的指向也改变。

002E17B8  mov    esp,ebp  
002E17BA  pop    ebp 

进行mov操作,将ebp的值赋给esp,此时esp指向ebp位置。

pop操作,将栈顶元素弹出并赋值,放到ebp中,而正好,此时的栈顶正好是刚开始执行的第一次指令push ebp,而此时栈顶的值就是上一个函数栈帧的栈底,因此ebp此时指向上一个函数的栈底位置,这就是精妙之处,因为上一个函数的栈底位置很难找到,那在创建函数栈帧时,就会push ebp,将上一个函数的栈底地址压栈,栈顶放置栈底的地址,方便回来的时候找到栈底,此时,因为pop了,esp与ebp执行均改变,esp执行下一个,ebp被pop赋值,而pop的位置正好存放了栈底的值,因此ebp又指向栈底了,现在esp与ebp又维护这main函数。

002E17BB  ret

最后ret指令,首先是从栈顶弹出一个值,此时栈顶的值就是call指 令下一条指令的地址,然后直接跳转到call指令下一条指令的地址处,继续往下执行。此时就真正的回到了main函数栈帧内了。这就能解释之前为什么将call指令的下一条指令的地址存储起来了,就是为了当调用函数结束后,返回后能继续执行代码。

此时我们观察上图,发现形参还没有销毁,因此说明,形参不是随着函数栈帧的结束而销毁的,而是要回到主调函数栈帧内,再进行销毁。

002E18F7  add   esp,8

add操作,让esp+8,就是让esp向下走8字节。


此时,函数调用结束,要继续执行main函数有效代码部分了。

002E18FA  mov   dword ptr [ebp-20h],eax

mov操作,将eax的值赋给[ebp-20h],eax里的值是什么呢?里面就是Add函数的返回值,而[ebp-20]所处的空间为res的空间,这条指令就是接受返回值。

printf("%d\n", ret);
002E18FD  mov         eax,dword ptr [ebp-20h]  
002E1900  push        eax  
002E1901  push        2E7B30h  
002E1906  call        002E10D2  
002E190B  add         esp,8  
return 0;
002E190E  xor         eax,eax

最后就是打印和return返回了。这里不再解释了。函数栈帧的创建与销毁这个流程也将结束。


3.3.7 回答理解函数栈帧能解决什么问题中的六个问题

问题1:局部变量是如何创建的?

在创建初始化完函数栈帧后,通过栈底指针ebp-某个数,这个数是由编译器决定的,从而得到一块内存空间,mov指令后,这块空间就是属于局部变量的空间。

问题2:为什么局部变量不初始化内容是随机的?

在创建函数栈帧时,会有4条指令用于初始化函数栈帧的内存空间,每个内存中会存放0cccccccch这个值,而如果在创建局部变量时不进行初始化,那分配的空间中的内容就为0cccccccch。

问题3:函数调用时参数是如何传递的?

对于参数是如何传递的,也就是如何进行函数传参问题,在调用函数之前,会将()内的参数从右到左依次进行push压栈操作,此时参数处于主调函数的栈帧之间,并且存储了实参的值,内存空间与实参不同。

问题4:传参的顺序是怎样的?

函数传参的顺序是从右到左执行的。

问题5:函数的形参和实参分别是怎样实例化的?

函数的实参实例化在传参时压栈操作后,与相对应的局部变量建立了联系,相当于临时拷贝,这是实参进行了实例化。而函数的形参实例化是当要使用该形参时,会通过栈底指针ebp+某个值找到调用函数前push压栈后所对应的空间,使用里边的值,这就是形参的实例化。

问题6:函数的返回值是如何带回的?

当函数执行到return返回语句时,会将返回值赋值给eax寄存器,因为寄存器不会随着函数的结束而销毁,当回到主调函数时,再进行使用eax寄存器里的值。


总结

这就是函数栈帧的创建与销毁相关知识点,希望这篇文章对您有帮助,如果有帮助,可以点个赞,关个注,后续还会出更多的干货,希望大家多多支持!!!❤❤❤❤

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值