前言:作为同样正在学习C语言的我决定同样在vs2013的环境下,来巩固函数栈帧的创建和销毁的底层原理(超详细),希望为大家带来帮助
记笔记前引入几个问题:
- 局部变量是怎么创建的
- 为什么局部变量不初始化的时候时随机值呢
- 函数是怎么传参的?
- 形参的实参是什么关系?
- 函数调用是怎么做的?
- 函数调用时结果时怎么返回的?
目录
1. 寄存器
eax
ebx
ecx
edx
函数栈帧
ebp,esp 这2个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的
ebp 每一个函数调用都要再栈区创建空间
esp 再调用哪个函数的时候ebp和esp维护的就是哪个函数的函数栈帧
先写一个足够简单的代码
如下图:esp和ebp中间的这个空间就是main函数被调用时分配的空间,而这块空间(main函数的函数栈帧)就是由ebp和esp所维护的。
ps:我们通常把ebp称为栈底指针,把esp称为栈顶指针
先给予一个模糊的概念:
栈区的使用习惯是:先使用高地址再使用低地址 —— 一会儿我们便能观察到占用的空间是从栈底往栈顶去使用的。
2.调用堆栈理解
我们用vs2013按下f10开始调试代码,调试中点击窗口里的调用堆栈
在调用堆栈窗口中便能看到main函数被调用,但是我们困惑的一个点是main函数被谁调用
这个时候我们让代码往下走,逐过程执行完程序便可以看到下面窗口:
这时候我们看到__tmainCRTStartup(),这个函数便用来调用main函数 return 0 返回值实际上就是返回到mainret里面去了
而__tmainCRTStartup()函数是被mainCRTStartup()函数调用的
这个过程虽然很复杂,但是需要被我们所了解——这便是main函数的调用逻辑:
在vs2013中,main函数是被其他函数调用的:__tmainCRTStartup()
而__tmainCRTStartup()是被mainCRTStartup()调用的
以上是这个函数调用的基本框架,接下来我们来研究这个函数具体是怎么调用的
3.通过汇编代码研究
3.1 调用main函数时栈帧的预开辟
右击鼠标点击转到反汇编,如下图:
这些代码我们该如何理解呢?
首先我们要点击查看选项把显示符号名(a,b,c)勾选去掉,方便我们观察它们的地址
首先我们已经了解到main函数是由__tmainCRTStartup()函数调用的
可知:esp和ebp先维护__tmainCRTStartup()函数,并分别维护它的栈顶和栈底
接下来我们观察到:汇编代码main()函数的第一行,它并没有直接执行int main,而是进行了一系列操作后才开始执行main函数
首先研究第一行代码:push ebp
ebp储存的是栈底的地址,而push ebp就是把ebp的值,压到栈中去,称为压栈,这个时候相当于下图:
当push完这个值的时候esp所储存的地址就不在这了,而是如下图:
在以下步骤我们都可以观察到......
首先我们观察一下此时还未执行push时的值:
假设当我们执行push以后esp要往低地址上走一步,那么它的值是不是应该减少呢?
运行之后我们发现确实是这样的,esp的值减少了4,如下图:
(以上结果更加深刻地证明了栈区的使用习惯是由高地址往低地址走的,并让我们理解了压栈的过程,以及esp维护函数栈顶的规则)
.........................
我们还可以通过内存窗口进一步证实压栈的存在:
在内存窗口中输入esp得到esp的地址,发现第一个元素就是ebp的值00a2fca0(内存中以每两个十六进制数字倒序存储)
接着我们继续往下顺,整体思路便可以出来了...........
move这个指令是把esp的值给ebp
相当于ebp就指向esp的位置上去了:
继续监视ebp的值我们发现确实实现了以上操作
接下来的代码是sub-->(减) 0E4h是一个八进制数字 , 也就是把esp的地址减0E4h
我们可以通过监视来观察到0E4h所对应十进制的值:
当我们执行这个代码的时候esp的值就变了:
此时便意味着esp指向的地址便走向上面的地址去了,此时紫色的这块空间就是为main函数预先开辟好的空间:
接下来执行的三行push代码,不需要关注push了什么,只要注意细节里面有它们就行了,
此时esp指向ebi:
接下来我们可以看到lea这样一个代码:
lea --> load effective address(加载有效地址),相当于把[ebp-0E4h]地址加载到edi里面去
此时反汇编代码点击显示符号名便可以看到ebp-0E4h:
观察前面的sub代码:便可以发现 esp-0E4h和ebp-0E4h其实指向相同的位置
通过监视窗口再次查看edi的值是与之前sub代码操作时esp的地址相同的——也就是为main函数预先开辟栈帧时的栈顶地址
这个地址有什么用呢?
mov ecx,39h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
事实上真正起作用的代码是rep stos dword ptr es:[edi] ( 1个word是两字节,dword是双字的意思也就是四个字节)
作用是把加载了[ebp-0E4h]地址的edi下面的39h的空间内(也就是之前为main函数预先开辟的栈帧范围)的字符全部改成eax的内容(CCCCCCCC)
通过内存可以观察到确实达到了预期的效果,
并且发现再ebp结束
也就是进行了以下操作:
3.2 main函数的执行
而下一行代码的意思是就是把0A(0A是十六进制数字也就是十进制的10)放入ebp-8的地址中:
剩下两行代码也是一个道理:
3.3 调用Add函数
创建好三个变量之后才是函数调用:
第一行代码把[ebp-14h]的值放到eax里去(相当于拷贝),第二行再将eax压栈
后面两行同理
上面这两个步骤的目的是传值,现在我们可能还感受不到,先不要着急,把它们先放在这儿,一会儿我们会用到的
接下来看到的call代码就是用来调用函数的,此时我们记住红框里的值,按f11进入call指令
如下图:
实际上我们发现call指令又把下一条指令上的地址压栈,目的是:调用完Add函数后还要回到call函数的下一条指令继续执行
再次按下f11,才真正进入了Add函数
我们可以发现前面这一堆汇编代码和上面调用main函数时的一样:也就是为Add函数准备栈帧
在执行第一行指令之前ebp都还在维护main函数的栈底,执行第一行代码后
将来自main函数的ebp元素压在如下图所示:
下图便可见刚压入栈中的值与ebp的值相同 --> 来自main函数的ebp元素
接下来是第二行代码:让ebp指向esp
接下来是第三行代码:为Add函数开辟栈帧
第四五六行代码:
第七八九十行代码:把ebp到edi之间的空间全部初始化完了
接下来就要开始执行Add函数了:
先给z开辟一个空间存放值
此时我们会疑惑:z创建完了,那么x和y在哪呢?
再来看接下来的汇编代码:
将ebp+8的值存放到eax寄存器中,也就是下图:
ecx和eax是调用main函数时传值过来的,我们将它们标为a' 和 b',如下图:
因此刚刚将ebp+8的值存在eax中的操作,实际上就是将main函数传递的a值寄存在eax中,此时eax的值是10
同理,执行下一行代码的ebp+0Ch,实际上就是main函数传递的b值:
这行代码运行的操作就是把b值再加到寄存器eax中,达成x+y的作用,此时eax中的值是30
下面的代码是把eax的值 放到ebp-8中
此时ebp-8的值便由0变成了30 而z的地址是ebp-8因此z的值便变成了30
再了解了这么些过程之后我们也明白了Add函数到底是怎么做的:
Add函数再调用计算的时候并没有主动创建形参,而形参是由下面调用main函数的时候,通过以下两个指令,就把值通过push压栈的形式传过去了:
而我们是先传的b再传的a,因此我们的参数是从右向左传
并且调用Add函数之后,我们并没有创建新的形参,而是回来找到并调用ecx和eax这两个空间
因此ecx的值就被认为是x,eax的值就被认为是y :
这个现象也映证了函数传参方式的逻辑性:
- 形参就相当于是实参的拷贝,因此改变形参是无法对实参产生影响的
3.4 返回值
接下来我们来研究Add函数是如何返回值的:
返回值的代码作用是把ebp-8的值放到eax中:相当于用全局的寄存器将这个值保存起来,等回到主函数中是再将eax里的值拿出来用便可以了
紧接着我们要进行三次pop(弹出)代码:
由于我们所需的值已经存放再eax中了,所以edi,esi,ebx便可以被回收了;
每次执行pop的时候,将元素弹出 --> 相当于esp++了一次
执行前:
执行后:
然而剩下的为Add函数开辟的空间要怎么回收呢:只需一个代码:mov esp,ebp
此时esp便指向了ebp的地址
再执行下面的代码:
调用完Add函数后 main函数的栈顶是很容易找到的,而栈底却不容易找到,
当初为什么要把ebp的地址存放到main函数栈顶呢?
目的就是为了:能够使ebp返回栈底,当我们pop ebp后,ebp就回到原来main函数的栈底,并且随着ebp的弹出,esp也向下走一个地址跳到原函数的栈顶
接下来的ret 其实就是让栈顶返回到00B31450这个地址(也就是调用Add函数的下一个语句)执行完ret后才算成功返回到main函数中,继续执行main函数未执行完的语句。
这便是为什么要在调用main函数时将调用Add函数的下一个语句压栈的原因
执行完ret后esp 和 ebp 又开始正常维护main函数的栈顶和栈底。
执行完ret之后便跳出返回到调用Add函数的下一个语句,如图:
此时便证明了调用main函数调用Add函数时创建和销毁严谨的过程
返回main函数后,此时esp便指向这里了
而此时esp下面两个创建的形参已经没有用了便执行下面一条代码,将ecx和eax(为传a , b值创建的寄存器)删除,如下图:
当esp指向这儿的时候相当于把ecx和eax还给操作系统了,也就是把形参给释放了
最后一步也就是将存放再eax里的值给c,如下图:
这样我们的返回值就带回来了....................
将到这儿,整个函数创建和销毁栈帧的逻辑就盘通了,逻辑设计的精妙之处是否感受到了?
4.总结答疑
接下来我们回到最开始的疑惑:
- 局部变量是怎么创建的
- 为什么局部变量不初始化的时候时随机值呢
- 函数是怎么传参的?
- 形参的实参是什么关系?
- 函数调用是怎么做的?
- 函数调用时结果时怎么返回的?
- 局部变量的创建:首先为函数分配好栈帧空间,栈帧初始化好一部分空间后,然后再这一部分空间内分配好局部变量的空间。
- 局部变量要再栈帧中分配空间,如果不初始化的话,栈帧初始化的值是随机放进去的,因此是随机值,初始化的话就把局部变量的随机值给覆盖了,才不会有随机值。
- 当我们还没调用函数之前,原函数就已经把值从右向左push压栈到寄存器里了;调用函数的时候,通过指针的偏移量来找到存放再寄存器里的值。
- 形参确实是压栈时开辟的空间,但是形参和实参的空间是独立的,所以形参是实参的临时拷贝,通过改变形参不会改变实参。
- 根据图解
- 再调用之前就把ebp的地址以及call指令下一条指令的地址记住了,当执行完调用函数的指令后,会先pop弹出ebp到原函数的栈底,此时esp指向下一条指令的地址,再ret回到原函数调用Add函数指令的下一条指令继续执行main函数。