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

**


​ —— POWERED BY CAIXYPROMISE


函数栈帧的创建和销毁

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

比如:

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

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

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

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

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

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

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

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

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

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

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

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

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

圆规正转,进入正题!


什么是寄存器?

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

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

image-20220330211304684

寄存器分类

计算机的寄存器还分多种

  • 一般寄存器: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两个指针地址会同时指向它的栈顶和栈底。

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

image-20220110184503700

函数压栈的过程

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

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

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

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

__tmainCRTStartup函数压栈

image-20220110211617884

image-20220110211729352

mainCRTStartup函数压栈

image-20220110212352354

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

image-20220110220522449

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

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

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

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

image-20220111163038299

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

image-20220110221517882

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

image-20220113123135672

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

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

image-20220110223836717

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

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

image-20220110223528221

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

image-20220110224216553

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

image-20220110224316200

该如何论证这个假设呢?

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

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

image-20220110224605878

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

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

image-20220111000052774

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

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

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

image-20220110225136909

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

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

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

image-20220110225937687

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

image-20220111000207900

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

image-20220111000344361

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

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

image-20220111000446712

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

image-20220111001133085

image-20220111001200430

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

image-20220111001414197

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

image-20220111002520582

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

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

image-20220111002230000

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

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

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

image-20220111003749180

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

观察监视器内esp和ebx的值

image-20220111005406787

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

image-20220111005510565

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

image-20220111005640832

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

image-20220111005902323

image-20220111010033722

压入edi时,esp的变化如下

image-20220111010112098

image-20220111010131509

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

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

image-20220111004521750

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

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

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

image-20220111024656812

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

image-20220111015833316

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

image-20220111001414197

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

image-20220115204906978

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出开始向高地址拷贝

image-20220111113038428

到008ffba4栈底指针处结束

image-20220111113353635

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

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

image-20220111031852347

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

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

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

image-20220111161949940

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

image-20220111135625127

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

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

image-20220111160123356

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

image-20220111160512467

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

image-20220111161648219

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

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

image-20220111165749903

本篇小结

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

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

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

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

本篇结语

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

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

查看下一篇

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

猜你喜欢

转载自blog.csdn.net/weixin_43654363/article/details/123858937
今日推荐