函数栈帧的创建和销毁——“C”

Posted 认真学习的小雅兰.

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了函数栈帧的创建和销毁——“C”相关的知识,希望对你有一定的参考价值。

各位CSDN的uu们你们好呀,今天小雅兰来为大家介绍一个知识点——函数栈帧的创建和销毁。其实这个知识点,我们很早之前就要讲,但是因为我的一系列原因,才一直拖到了现在,那么,话不多说,让我们一起进入函数栈帧的世界吧

我们学习了前面这么多内容,不由得会想起几个问题:

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

带着这一肚子的疑惑,就有了今天的函数栈帧的创建和销毁了。


寄存器

什么是函数栈帧

什么是栈

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


首先,我还得给大家拓展一个知识点——寄存器

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

按照功能的不同,可将寄存器分为基本寄存器移位寄存器两大类。

基本寄存器只能并行送入数据,也只能并行输出。

移位寄存器中的数据可以在移位脉冲作用下依次逐位右移或左移,数据既可以并行输入、并行输出,也可以串行输入、串行输出,还可以并行输入、串行输出,或串行输入、并行输出,十分灵活,用途也很广。

这边介绍一下寄存器的基本含义、基本概念、结构、工作原理、类型、存放代码满足条件、寄存器组织、寄存器寻址

 

 

 相关寄存器和汇编指令

 相关寄存器

 

  •  eax:通用寄存器,保留临时数据,常用于返回值
  •  ebx:通用寄存器,保留临时数据
  •  ebp:栈底寄存器
  •  esp:栈顶寄存器
  •  eip:指令寄存器,保存当前指令的下一条指令的地址

 

相关汇编命令

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

 什么是函数栈帧

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

那函数是如何调用的?

函数的返回值又是如何带会的?

函数参数是如何传递的?

这些问题都和函数栈帧有关系。

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

   

    函数参数和函数返回值

   

    临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)

   

    保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。

看到这里,我们就必须还想到一个问题——什么是栈? 


什么是栈

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

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

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

在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。 在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。

在了解了这些准备工作之后,我们就可以进入我们的正题啦——解析函数栈帧的创建和销毁 


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

首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。

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

 2. 这块空间的维护是使用了2个寄存器: esp 和 ebp ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。

 3. 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2010为例。  

函数的调用堆栈

#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;

 我们可以看到,main函数也确实被调用了

在VS2010中,main函数也是被其他函数调用的   __tmainCRTStartup  这个函数又是被调用的  mainCRTStartup

即,mainCRTStartup调用了__tmainCRTStartup,__tmainCRTStartup又调用了main函数

 

 现在转到我们的反汇编,把这个显示符号名的勾勾去掉,这样方便观察

 

 

 压栈(push)操作

 mov操作,表示把esp的值给ebp

 sub操作,表示esp的值减去0E4h

0E4h是一个十六进制数字,转为十进制为228

经过sub操作,esp的值就变了

 

 然后,esp就指向上面开辟的某一块空间了

 

 这一块空间,就是为我们的main函数预开辟的一块空间了,也就是main函数的栈帧

然后再是三个push操作,push了ebx、esi、edi

 

 

 再是lea操作,lea表示Load Effective Address,是为加载有效地址

 把[ebp+FFFFFF1Ch]的值加载到edi中,但是这个值不好观察,那我们还得把我们之前取消的显示符号名给勾上

 

 

这三个操作的意思是,把刚刚main函数的栈帧全部初始化为CCCCCCCC

dword的意思是double word(双字),一个字是两个字节,双字就是四个字节

 

 走了这么半天,竟然还没有执行一行有效的代码!!!

#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 ret = 0;
	ret = Add(a, b);
	printf("%d\\n", ret);
	return 0;

 

 这就是我们的变量为什么要初始化的原因,如果不初始化的话,内存里面放的是一个随机值

 

接下来,就是调用函数

 

 

这几个动作就是在传参

 

 我们会发现,这个Add函数的指令和我们的main函数开始的指令几乎是一样的,这实际上就是在准备栈帧

 

 

其实初始化并不止这么多次,把33h这个十六进制数字换成十进制,是多少次就初始化多少次CCCCCCCC

 

 

通过画图,我们可以清楚地知道,并没有给形参创建空间,这也验证了我们之前的结论:实参传递给形参的时候,形参是实参的一份临时拷贝,改变形参是不会影响实参的

把[ebp-8]的值放到eax这个寄存器中

 

 

 

 


好啦,小雅兰今天的函数栈帧的创建和销毁的内容就到这里了,总体来说,我觉得这个内容比较地抽象,难度也是很大的,对于我们这种初学者来说,但是,不奢求一遍就把它看懂,但求每多看一遍,收获的知识点就多一点点,这样我就心满意足啦!!!

 

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

**

文章目录


​ —— POWERED BY CAIXYPROMISE


函数栈帧的创建和销毁

通过前面的学习,我们了解到最基础的C语言程序的语法与使用,但你是否有疑问?

比如:

  • 函数的作用域是怎么形成的呢?

  • 局部变量是如何创建的?

  • 为什么未初始化的局部变量的值是随机值或是乱码呢?

  • 函数是如何传参的?传参的顺序又是怎么样的呢?

  • 形参和实参的关系是什么?

  • 函数的调用是怎么实现的呢?

  • 函数调用结束后是怎么返回的呢?

  • 为什么会存在函数递归的最大深度呢?到达最大深度所提出的堆栈溢出错误是什么意思呢?

当你了解了函数的栈帧创建与销毁的时候,这些疑惑将会一一解开!带着这些问题,我们来进入函数栈帧!

由于篇幅较长,本系列文章共分为上、下两篇。本篇为上篇,将主要介绍:

有疑问欢迎在评论区回复,点击立即查看下一篇第一时间学习下篇新知识。

  • 什么是寄存器?
  • 什么是栈?
  • 函数栈帧的形成过程
  • 函数变量的形成过程

了解函数栈帧需要涉及到反汇编操作,笔者会根据相关的汇编指令来做介绍。

圆规正转,进入正题!


什么是寄存器?

首先需要了解的:什么是寄存器?

计算机硬件中,具有存储功能的硬件有什么?它们分别是 硬盘 --> 内存 --> 高速缓存(cache) --> 寄存器,它们4个中从左到右访问速度和存储速度不断递增;同时,它们的大小也是从左到右依次递减的,到最后的寄存器,它的存储空间可能只有4byte位的存储单元大小,与此同时它的访问速度是最快的,因为寄存器一般是集成在CPU上,与内存是不同的独立的存储空间,常言道,网速飞快是坐在服务器上打游戏,而读取速度越快就是坐在CPU上读取,寄存器读取快就是这个道理。

寄存器分类

计算机的寄存器还分多种

  • 一般寄存器:EAX、EBX、ECX、EDX

    ax:累积暂存器,bx:基底暂存器,cx:计数暂存器,ed:资料暂存器

  • 索引暂存器:ESI、EDI

    si:来源索引暂存器,di:目的索引暂存器

  • 堆叠、基底暂存器:ESP、EBP

    sp:堆叠指标暂存器,bp:基底指标暂存器;这两个寄存器,也是函数栈帧中最重要的两个寄存器

其中:

  • EAX、ECX、EDX、EBX:为ax,bx,cx,dx的延伸,各为32位
  • ESI、EDI、ESP、EBP:为si,di,sp,bp的延伸,各32位
  • EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。

寄存器用途

那么,它们在程序中的用途是怎么样的呢?

这些32位的寄存器但每一个都有“专长”,有各自的特别之处。

  • EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。

  • EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。

  • ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。

  • EDX 则总是被用来放整数除法产生的余数。

  • ESI/EDI分别叫做"源/目标索引寄存器,因为在很多字符串操作指令中,其中DS:ESI指向源串,而ES:EDI指向的是目标串。

  • EBP是"基址指针", 它最经常被用作高级语言函数调用的"框架指针"。 在破解软件时,经常可以看见一个标准的函数起始汇编代码:

    push ebp ;保存当前ebp
    mov ebp,esp ;EBP设为当前堆栈指针
    sub esp, xxx ;预留xxx字节给函数临时变量.
    ...
    这样一来,EBP 构成了该函数的一个框架, 在EBP上方分别是原来的EBP, 返回地址和参数. EBP下方则是临时变量. 函数返回时作 mov esp,ebp/pop ebp/ret 即可.
    
  • ESP 专门用作堆栈指针,被形象地称为栈顶指针。堆栈的顶部是地址小的区域,压入堆栈的数据越多,ESP也就越来越小。在32位操作平台上,ESP每次会减少4个字节。

关于寄存器的概念就说到这。实际运用起来是将内容存到寄存器内而使用其地址。真正与形成函数栈帧有密切关系的是:EBP和ESP这两个寄存器地址。

什么是"栈"?

在开始讲解前,需要再注意一个关键词:什么是“栈”?栈是一类数据结构,本篇不会对其实现方法做太多解释,只需要了解它的一个特性:数据依次放入栈内后,取出元素时顺序是最先进入的元素最后出;例如在一个木桶内放入一堆书籍,在你需要取出底部的书时,你需要先把上部分内容取出才能取出最底部的内容。而本篇说的栈区,主要是运行在系统内存之上的

函数栈帧的概念

在寄存器内,EBP、ESP这2个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。

每一次函数调用时,都需要在栈区内创建一个空间;调用了哪个函数,EBP、ESP两个地址就会去维护这个函数的内存空间,这就是函数的栈帧;例如main函数在运行过程的当中,esp和ebp两个指针地址会同时指向它的栈顶和栈底。

这么说,你可能会不理解。那就画图吧!

函数压栈的过程

为了方便演示和理解,我会使用VS2013版本演示函数压栈的过程,带着你一块一步步的读程序的汇编指令并讲解每一个步骤会做出什么样的操作,最后会对我会对整个指令进行一个总结。因为不同的编译器对于程序汇编封装的方法可能是不同的,而更高阶的编译器对于程序的封装会更加细致,不利于观察。同时,以下的汇编指令的地址会随着每次程序编译而变化(因为内容都是随机分配的),如果你在本地也在进行调试时,请保持在同一个编译情景下。但原理上都是相通的。

需要说明的是,在VS2013之前的版本中,运行程序调试时查看调用堆栈时会发现main函数也是被其他函数调用的

分别是__tmainCRTStartup和mainCRTStartup函数,其中,mainCRTStartup压在最底部。

调用逻辑是mainCRTStartup – > __tmainCRTStartup --> main 函数

__tmainCRTStartup函数压栈

mainCRTStartup函数压栈

由此,我们可以理解此时的内存栈表示为

通过在VS2013编译环境下观察可以发现,函数在运行过程当中,会用esp栈顶指针和ebp栈底指针形成一块内存空间而形成函数的栈帧。那么,程序具体是怎么做的呢?我们可以通过查看程序的反汇编代码来研究它的压栈过程。

以下是主函数的部分反汇编代码,现在我们来看看它的具体原理是如何走的。

示例代码和主函数汇编指令 (部分)

程序代码,本篇将以本段代码进行举例,以此来介绍函数的栈帧、局部变量和函数调用的生成与销毁的过程。

以下的汇编指令是下面将会讲到的部分内容。

本篇中,我讲结合C语言X86代码生成细节的汇编指令文档来讲解本篇的汇编指令,以下是将会常用到的汇编语句。

当程序进入主函数时,我们刚刚提到主函数也是被其他函数调用的,那么这个调用主函数的原函数,是不是已经创建起它的函数栈帧了呢?答案是肯定的。此时原函数__tmainCRTStartup 是被esp和ebp两个栈顶/底指针维护的。

最初开始时的栈区应该如图所示

汇编指令:构建函数栈帧准备 (一)

接下来我们来看main函数进来的第一句汇编指令

进来的第一句话就是push ebp,汇编指令中,它的意思是把ebp的值放到栈顶

那么我们可不可以假设:因为esp维护的是程序的栈顶,此时的esp已经跑到了栈的最顶部,esp的地址会指向ebp的值?如图所示

该如何论证这个假设呢?

当你去开启监视器去监控esp就可以发现它的值会变动。

当前是esp栈顶指针的初始值

push ebp完成后,esp地址是不是由高到低,所以地址应该是减小吧?

监视器进入逐过程时就可以证明这个道理:a8 到 a4 减少了4个字节

那么esp的值会不会是ebp的值呢?打开内存块,搜索新的esp的地址会是ebp的值,答案一目了然!~

刚刚ebp的值时多少?008ffbf4, 现在搜索esp的地址的值就是008ffbf4,假设成立。

esp维护的是程序的栈顶,此时的esp已经跑到了栈的最顶部,新的esp的地址会指向ebp的值

而压入的这个ebp是什么的ebp,我们到下面会进行讲解。

汇编指令:构建函数栈帧准备 (二)

现在我们再来看第二句汇编指令:mov 把esp的值给到ebp。

事实果真如此吗?我们运行调试下一步,监控器反馈如下

此时它的栈区示意图应该是

汇编指令:构建函数栈帧的范围

再来看第三句会汇编指令:sub esp的地址,减去0E4h。(sub是英文中减少的意思,add同理为加上)

通常来说,ebp减去的值都是0E4h,而这里的0E4h实际上是一个八进制的数字。当你想查看0E4h是一个什么数字时,你可以将它放入监控区后可以显示其十六进制的值,再查看十进制数字

走到这里,不就是相当于esp减去了0E4h的值吧?那此时的esp会不会已经发生变化了呢?监视器逐过程查看结果

此时的esp的值已经变成了0x008ffac0,相当于esp的地址值变小上移不再指向原来的地方,而是指向原地址上方某一块区域内。

这个时候你有没有发现,新的esp栈顶和ebp栈顶指针在进入main函数后已经形成的一块新的维度空间,并且esp和ebp不再是维护原来的函数空间了呢?没错,这一块新的区域就是为main函数预开辟的函数栈帧区。而sub就是提出为main函数开辟的多少字节空间。

栈区示意图可以理解成下面这张图

汇编指令:放入三个非易失寄存器

这里的ebx、esi、edi是我们前面所说的寄存器中基底、来源索引、目标索引暂存器,它们三个在这里统称为非易失寄存器。这是一个C语言中的调用约定,这里将三个寄存器压栈的原因就是实现跨平台使用。在X86平台下的调用约定下这3个寄存器用途在于,调用函数时要求压入这3个寄存器以此用来保存调用前的数据,应在调用期间长期存储。

它们此处是在入栈操作,别忘了入栈的同时,esp栈顶指针也在不断的变化。

入栈的过程详情可以如下:

观察监视器内esp和ebx的值

ebx开始压入栈时,esp会如何变化呢?答案是肯定的,esp的值会递减向上挪动

打开内存器时,会发现对应esp的地址是ebx的值0x007e5000

同理继续往里压入esi时,esp的变化如下

压入edi时,esp的变化如下

综上,原来的esp栈顶指针的值已经由最初的008ffac0变成现在的008ffab4,地址在不断的减小,栈顶在不断的上移。

现在的栈区的示意图可以理解为

汇编指令:加载栈帧有效空间

到了这里为了方便直观感受与理解,我们会显示汇编符号名。

到了第七句这里的lea语句,它的全名应该是load effective address(加载有效地址);顾名思义,从此处开始,程序会正式加载当前函数的有效栈帧区域。我们来看看它该如何走吧。

lea edi, [ebp-0E4h],这里的0E4h是不是很眼熟?没错,就是刚刚在预申请main函数的函数栈帧中所预申请的大小。在这里的意思就是将ebp - 0E4h大小的空间存到edi当中去,而这个edi不就是栈底指针吗?由栈区图,我们可以观察到 ebp - 0E4h 实际表达的空间就是当前main函数的栈区空间。此时,它们已经被存到edi寄存器当中。

如何论证呢?翻到刚刚前面的三个非易失寄存器未压入栈时esp的地址

现在,我们打开监视器查看ebp-0E4h和edi的地址,答案显而易见!~ebp-0E4h的地址就是当前esp所指的第3个栈——edi的位置,也是三个非易失寄存器未压入栈时esp的地址。

mov ecx,39h和mov eax,0CCCCCCCCh意思分别是,对应的39h和0CCCCCCCCh分别放在ecx, eax寄存器内。

再来看下一句:rep stos dword ptr es:[edi] ,这里就非常有意思,此处会最终形成函数的栈帧有效空间。来看看指令语句的表述:从edi内所标记处开始重复拷贝ecx次eax的内容,直到栈底指针ebp处。需要注意的是,dword表达的是double word双字节的意思,一个word是2个字节,double word就是双字节等同于4字节。

它们的具体流程是什么样的呢?从edi所标记的ebp-0E4h处开始,向高地址的部分进行字节拷贝,每一次拷贝4个字节。 拷贝的内容就是eax的内容(0CCCCCCCCh),拷贝次数为39h次,到栈底指针ebp处停止。

结合当前按理,程序会从008ffac0处开始往高地址进行字节拷贝,直到ebp栈底指针处;当你打开内存图查看此时的内存情况时,就可以论证这一个观点~

从008ffac0出开始向高地址拷贝

到008ffba4栈底指针处结束

可能你会有疑问,这个cccccccc是什么意思呢?他们在各个编译器可能都有些许不同,而当我们平时在编写程序时,变量未定义初始值时,打印输出来的是“烫烫烫”乱码字符,不是计算机对自己自身温度的自我表达:),实际上这就是内存中放的0CCCCCCCCh字符。

综上,栈区的示意图可以如下

程序运行到现在,程序历经五步,为main函数开辟的函数栈帧正式完成,这一块由esp和ebp形成的区域就是一个函数的作用域,而由0CCCCCCCCh形成的一块空间则是用来存放局部变量的空间。接下来,程序会正式执行有效代码。

汇编指令:生成函数局部变量

由上面的诸多操作下来,一个函数的有效栈帧区已经形成,此时程序才会真正执行它的有效代码。根据之前写的代码要求,程序一进来会创建局部变量,在栈帧区内,局部变量又是如何被创建的呢?

首先我们来看汇编指令:这个语法是不是很熟悉呢?语句的的意思是:依次的将0Ah、14h、0 放入到ebp - 8、ebp - 14h、ebp - 20h 位置处。

ebp - 8、ebp - 14h、ebp - 20h是以栈底指针为基准向低地址减小的一串地址,在这里就是开辟一块空间分配给0Ah、14h、0 ,而这个0Ah、14h、0 就是计算机的十六进制的10、20、0的表达形式。所

现在我们来论证刚刚所说的。首先来继续观察ebp栈底指针的值,看它是不是往低地址存放变量;

逐过程进入语句,答案很明显,从栈底指针008ffba4处往低地址 - 8处,存放的值就是0ah。此时局部变量a = 10已创建

我们继续看下一步,创建局部变量 b = 20,后面的c同理。

根据观察在栈帧中局部变量的创建过程,我们可以发现局部变量是在形成一片有效的栈帧空间后,由高地址向低地址存放。如果变量未设置初始值时,程序会划定好一块区域规定为该变量的地址。

此时的栈区示意图可以如下

本篇小结

本篇我们基于main主函数简单介绍了一个函数的栈帧建立的基本过程;我们了解到:

  • 一个函数的栈帧实际上是由esp和ebp两个栈顶和栈底指针共同维护的一片内存空间;
  • 当一个函数在开始生成栈帧以后,会首先压入上一个函数的栈底指针ebp地址。
  • 在生成栈帧过程中,不断扩大的栈帧、压入新的内容或寄存器都会使得esp栈顶指针向上偏移;
  • 在进行确定方位相关操作时,都是以栈底指针ebp的位置作为偏移量向低地址开始偏移的。
  • 在压入3个非易失寄存器后,程序会基于ebp栈顶指针向低地址的填充一块区域,而这块区域就是一个函数的作用域。在这块作用域中,程序会根据ebp指针向上(低地址)作为方位,生成对应的变量。

下一篇,我们将会介绍函数的调用与返回过程,以及对我们开篇提出的问题做出一个总结。

有疑问欢迎在评论区提问。

本篇结语

函数栈帧销毁与过程(1)介绍完了,还有相关主题的第二篇,内容较多,干货满满。如果你觉得这篇文章对你有用的话,别忘了点赞在看+关注噢!

创作不易,你的关注与赞赏是对笔者最大的激励!笔者会继续分享有关于C/C++学习、Python实战运用相关知识。你的支持会使得后续笔者更努力推出更多高质量的文章,与你一起学习升级进阶打怪!

查看下一篇

下一篇预计在本周内发表,本系列文章已在微信公众号:01编程小屋 率先发布,欢迎大家关注第一时间学习新知识;关注小屋 学习编程不迷路

以上是关于函数栈帧的创建和销毁——“C”的主要内容,如果未能解决你的问题,请参考以下文章

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

C语言学习 -- 函数栈帧的创建和销毁

函数栈帧的创建与销毁

C语言函数栈帧的创建和销毁,以简单函数的调用来进行详细刨析

学好C语言,还需要掌握这个内功——函数栈帧的创建与销毁