咕咕咕~
目录
(一)前言
(二)将要解决的问题
注意
(三)基本知识
3.1寄存器
esp与ebp
(四)演绎过程
4.1改变认识main函数
4.2观察反汇编
4.3汇编代码
4.3.1栈的创建
4.3.2执行指令(main函数)
4.3.3执行指令(main函数中调用Add函数)
4.3.4Add函数调用结束
4.4开始出栈
看了动态的读者估计此时此刻心里有一万个问号。 ????
在本次更新,本应是讨论 《对指针的深入理解》,但是,我想到了更加重要的内容,一直拖延了很久,迟迟没有动笔写出来。
今天,11月11日,是个好日子,状态不错,于是就有了本篇。毫无疑问这是重头内容,在动笔写本篇之前,我有预感——完成对《函数栈帧空间的创建的销毁》的讨论一定不是一件容易事。
但是,完成这件事,无论对我,还是对我的读者,都是一次很大的提升。
在学习C到了一定程度后,我们或许会有很多疑问?
本篇目的就是通过可视化的手段再现函数栈帧的创建与销毁的过程,试图回答以上的问题,依次解决读者的疑惑。
本次演示以VS2019为例,你也可以在阅读本文后自行演示。
不同 编译器 的演示内容可能会有所不同,但是总体思路是一致的。
为了能够顺利解释函数栈帧的创建,我们先引入概念:
寄存器是计算机内部的一种存储器件(例如:eax,ebx,ecx,edx等),用来暂时存储和处理CPU需要的数据、指令、地址等信息。寄存器通常被安置在CPU内部,是计算机中最快速的存储器,由于其与CPU处于同一芯片上,因此访问速度非常快。CPU在执行指令时,需要从主存中取出数据并在寄存器中进行计算和处理,然后再将结果存回主存。
ebp和esp是x86架构CPU中的两个寄存器,它们都是用来管理栈的。
具体来说:
也许你对寄存器的概念并不感兴趣,但是这段话的内容将会在本篇被进行生动的演绎和验证!
总之,ebp与esp寄存器中存放的是地址,是用来维护函数的栈帧的。
我们知道,函数在调用时,都会在内存的栈区开辟一块空间。
并且,栈区的使用惯例是——先使用高地址,再使用低地址!
在一般情况下,我们认为,C语言中的main函数是程序的入口,程序从main函数进入,然后按照逻辑从上向下一步一步执行指令代码。但是,在汇编层面上,主函数main实际上也是被调用的。
在程序执行时,操作系统会首先加载可执行文件到内存中,然后跳转到main函数的入口地址开始执行代码。
(VS2019被封装的比较严密,目前我没有在调用堆栈中找到调用main函数的函数,但是,它一定是存在的。在这里推荐大家使用版本较低的编译器,比如VS2013,在这样的编译器上,我们可以很容易发现汇编代码中调用main函数的函数):
(VS2013的调用堆栈)
于是,在main函数之下其实已经有一些函数了:
于是,到这里我们知道,main函数与其他函数一样都是被调用的——我们的目的是观察函数栈帧的创建与销毁的过程,但是:
从 (调用main函数的函数)—> (main函数)的过程是比较复杂的,不太容易观察,所以我们在main函数内自己创建一个函数——Add函数,通过观察main函数内部 Add函数的 栈帧创建与销毁 的过程,来了解 函数栈帧创建与销毁 的一般过程。
我们可以看到这段C代码的汇编代码:
可是,它们是什么意思,是怎么操作的?
我们先观察前几句汇编代码:
(图.1)
(1.)push ebp ——就是把ebp压入栈中:
(其实,实际上是起一个记录的作用,在调用完main函数之后,main函数的栈帧被销毁,于是可以根据压入栈中的 ebp 的值,找到 调用main函数的函数 的栈底地址,也就是方便返回到 调用main函数的函数 中去)
(这个ebp的值实际上是 调用main函数的函数的栈底地址)
(由于 esp 是维护函数栈顶的,所以esp会向上移动4个字节)
于是:
我们可以观察esp存储地址的变化:
压栈前:
压栈后:
(2.)mov esp,ebp ——就是把 esp 的值赋给 ebp:
于是ebp与esp指向同一个位置:
于是,esp向上移动了一段距离,而新的esp与ebp之间的内存,就是编译器为main函数预开辟的栈帧空间:
(4.)接下来,连续进行三次压栈操作:
push ebx
push esi
push edi
(每次压栈操作后,栈顶esp都会向上移动4字节)
(5.)lea edi,[ebp-FFFFFFF1ch(也就是0E4h)] —— lea 其实是加载有效地址(load effecitive address)
而 [ebp-FFFFFFF1ch(也就是0E4h)]就是mian函数的栈顶地址,并把它放在 edi 中去
(6.)mov ecx,39h ——将ecx赋值为39h
(7.)mov eax,0CCCCCCCCh ——将eax 赋值为0CCCCCCCCh
(8.)rep stos dword ptr es:[edi] —— 将从edi开始的ecx次(39h次)的dword(一个word为2字节,dword(双字)为4字节)都改成ecx(0CCCCCCCCh)的值;
具体效果,可以在调试窗口内存中查看:
也就是说,有很大一块空间被初始化为:cc cc cc cc
图示:
接下来才真正开始执行我们编写的指令
的
由(图.1)这句代码的作用是在 ebp-8 的地址处 创建变量a
( 下图的地址为:0x008FFB9C 的位置是变量a的位置)
的
由(图.1)这句代码的作用是在 ebp-14h 的地址处 创建变量b
图中
由(图.1)这句代码的作用是在 ebp-20h 的地址处 创建变量c
至此,我们的栈帧实际图为:
接下来,程序将要调用执行下面的Add函数
这时,你就对汇编代码有了一些读写能力了,这两句就是:
把 ebp-14h 的值放在eax里面 (其实 ebx-14h 位置存储的是局部变量b的值)
把eax压入栈中
(13.)
我们再次发现 ebp-8 处的值就是 局部变量 a的值,于是,这两句就是把a的值赋给ecx,并将ecx压入栈中。
其实,仔细想想我们会发现,12,13,两个操作就是函数的传参(实参向形参的传递),这也同时验证了——函数的形参其实就是实参的一份临时copy!(在后半篇会有更加生动的证明)
接下来执行call(调用)指令,我们按F11进入函数内部,在进入函数内部后,我们通过内存
窗口看到在栈中压入了一陌生的值:
经过观察,我们发现这个值其实就是调用Add函数后的下一条指令的地址!!(从右相向左读)
如图:
也就是说,在栈顶压了一条地址,这个地址是call指令的下一条地址。
其实也很容易理解——当我们调用完Add函数后,程序需要再次回到主函数的下一条指令处!!
进入Add函数后:
我们发现前几条指令与主函数的栈帧的创建时相同的;
Add函数的汇编指令:
于是,这里直接给出了前面相同指令执行后的演示图:
接下来执行:
在 ebp -8 的位置 创建 局部变量 z,并且将地址为 ebp+8 和地址为ebp+0ch 的值累加,最后将累加值赋值给 地址为ebp-8 的变量;
在这里,我们发现,
ebp+8 的位置不就是 a 的一份copy吗?(a的值传递给的形参)!!
ebp+0ch 的位置不就是 b 的一份copy吗?(b的值传递给的形参)!!
从这句汇编指令中,我们发现 将 ebp-8 的值赋给eax中,作为暂时保存。
(也就是将z的值赋给eax寄存器中)(这也就解释了为什么函数调用结束后为什么返回值没有被销毁,而是被完好无缺地返回)
接下来的几条指令pop(弹出),就是出栈操作。
——依次从低地址到高地址依次弹出edi ,esi,ebx。
弹出:(一共三次)
将esp 赋给 ebp:(ebp 由低地址指向 高地址 )(也就是返回,为Add函数栈的销毁做准备)
pop ebp:将main函数的ebp弹出,也就意味着ebp回到了原本main函数的栈底。
怎么知道的呢?
因为esp与ebp相互配合来维护一个函数栈帧,它们之间的维护的空间就是函数的栈。
接下来就是跟着主函数继续执行了,后面的过程水墨就不再分享了。
欢迎在评论区分享你的理解和答案
完
未经作者同意禁止转载