目录
前言
一、什么是函数栈帧?
二、理解函数栈帧能解决什么问题呢
三、函数栈帧的创建和销毁解析
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)所开辟的空间,这些空间是用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
简单理解就是创建函数时,会在栈区创建一块空间,而这块空间正是函数栈帧。
二、理解函数栈帧能解决什么问题呢
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数时如何传递的?
- 传参的顺序是怎样的?
- 函数的形参和实参分别是怎样实例化的?
- 函数的返回值是如何带回的?
函数栈帧的知识是偏向底层的,当理解透彻后,对理解变量的存储、静态变量的创建、动态内存的申请与销毁等等知识点有很大的帮助!
三、函数栈帧的创建和销毁解析
讲解函数栈帧之前,需要了解一些预备知识点。
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寄存器里的值。
总结
这就是函数栈帧的创建与销毁相关知识点,希望这篇文章对您有帮助,如果有帮助,可以点个赞,关个注,后续还会出更多的干货,希望大家多多支持!!!❤❤❤❤