目录
前言:
一、寄存器:
二、函数栈帧的创建与销毁:
1.main函数函数栈帧开辟的准备:
2.main函数函数栈帧的开辟:
3.Add函数函数栈帧的开辟:
4.Add函数函数栈帧的销毁:
5.main函数函数栈帧的销毁:
三、总结:
前言:
今天是连续爆更的第五天喽,日更的銮同学更博客不易,辛苦各位路过的小伙伴们点点关注点点赞,给我继续爆更的动力!而今天我将对前面函数部分的学习内容的一个补充知识作以讲解:函数栈帧的创建与销毁。
在前面学习函数时,我们会产生很多疑惑,比如:
· 局部变量是怎么创建的?
· 为什么局部变量的值是随机值?
· 函数是怎么传参的?传参的顺序又是怎样的?
· 形参和实参是什么关系?
· 函数调用是怎么做的?
· 函数调用结束后是怎么返回的?
而以上这些疑惑,都将在今天的知识内容学习后得到解答。并且今天的内容,建议使用的环境是Visual Studio 2013,最好不要使用太高级的编译器,因为我们在学习的过程中需要随时对我们的程序运行过程进行观测,而越是高级的编译器自动优化越优秀,封装也更加复杂,随之而来的就是越不容易学习和观察。同时,在不同的编译器下,函数调用过程中栈帧的创建和销毁大体逻辑上是相同的,但细节上是略有差异的,具体细节取决于编译器的实现。
一、寄存器:
在开始了解函数栈帧的创建与销毁之前,我们首先要了解一下计算机CPU内部的重要组成——寄存器。
寄存器是CPU内部用来存放数据的一些小型存储区域,它们是物理的、固定的,用来暂时存放参与运算的数据和运算结果。其实寄存器就是一种常用的时序逻辑电路,但这种时序逻辑电路只包含存储电路。寄存器的存储电路是由锁存器或触发器构成的,因为一个锁存器或触发器能存储1位二进制数,所以由N个锁存器或触发器可以构成N位寄存器。寄存器是中央处理器内的组成部分。寄存器是有限存储容量的高速存储部件,它们可用来暂存指令、数据和位址。
寄存器最起码具备以下4种功能。
②.接收数码:在接收脉冲作用下,将外输入数码存入寄存器中。
③.存储数码:在没有新的写入脉冲来之前,寄存器能保存原有数码不变。
④.输出数码:在输出脉冲作用下,才通过电路输出数码。
仅具有以上功能的寄存器称为数码寄存器;有的寄存器还具有移位功能,称为移位寄存器。
寄存器有串行和并行两种数码存取方式。将N位二进制数一次存入寄存器或从寄存器中读出的方式称为并行方式。将N位二进制数以每次1位,分成N次存入寄存器并从寄存器读出,这种方式称为串行方式。并行方式只需一个时钟脉冲就可以完成数据操作,工作速度快,但需要N根输入和输出数据线。串行方式要使用几个时钟脉冲完成输入或输出操作,工作速度慢,但只需要一根输入或输出数据线,传输线少,适用于远距离传输。
寄存器分为很多种类型:
①.通用寄存器组。
②.指针和变址寄存器。
③.段寄存器。
④.指令指针寄存器。
⑤.程序状态字寄存器。
在研究函数栈帧时,我们主要研究指针和变址寄存器。而指针和变址寄存器又可以细分为:
· BP( Base Pointer Register):基址指针寄存器。
· SP( Stack Pointer Register):堆栈指针寄存器。
· SI( Source Index Register):源变址寄存器。
· DI( Destination Index Register):目的变址寄存器。
而今天我们的学习要关注的便是扩展基址指针寄存器 EBP 与扩展堆栈指针寄存器 ESP:
★EBP:扩展基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈底。
★ESP:扩展堆栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
二、函数栈帧的创建与销毁:
在前面我们说过,每一个函数在进行创建和使用时,都会在内存中创建一块临时空间,并在临时空间内进行函数的处理。今天我们的任务就时更加详细的去认识和了解这一过程。
每一个函数在创建和使用时,都将会在内存的栈区创建一个临时的空间:
栈区(低地址) |
main函数的函数栈帧 |
栈区(高地址) |
此处提到的高地址与低地址都是相对的,而内存在进行使用时总是习惯像向枪械的弹匣压入子弹一般,将数据依次压入,即优先使用高地址,后使用低地址。
例如我们尝试在VS 2013中观察下面代码的堆栈调用:
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int Add(int x, int y)
{
int z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("%d + %d = %d\n", a, b, c);
return 0;
}
此时就在内存中的栈区为main函数分配出了一块空间用于进行函数处理,而在分配空间时,main函数的函数栈帧的维护就交由寄存器 EBP 与 ESP 来完成。同时我们可以观察到,main函数也是被我们看不到的其它函数(__tmainCRTStartup/mainCRTStartup)所调用的。
并且在main函数中的Add函数也在栈区分配了自己的空间:
栈区(低地址) |
Add函数的函数栈帧 |
栈区 |
main函数的函数栈帧 |
栈区(高地址) |
同样的,函数在开辟空间时,也遵循栈区先使用高地址再使用低地址的原则,先在高地址处为先执行的main函数分配出空间,再为后使用的Add函数在低地址处分配空间。
我们将程序进入调试,并转入反汇编,通过汇编代码来查看其处理过程:
这里为了方便我们查看,我将汇编代码复制过来进行研究。其中压栈(push)指给栈顶放置一个元素,出栈(pop)指从栈顶删除一个元素:
int main()
{
001C18B0 push ebp
001C18B1 mov ebp,esp
001C18B3 sub esp,0E4h
001C18B9 push ebx
001C18BA push esi
001C18BB push edi
001C18BC lea edi,[ebp-24h]
001C18BF mov ecx,9
001C18C4 mov eax,0CCCCCCCCh
001C18C9 rep stos dword ptr es:[edi]
001C18CB mov ecx,1CC008h
001C18D0 call 001C131B
int a = 10;
001C18D5 mov dword ptr [ebp-8],0Ah
int b = 20;
001C18DC mov dword ptr [ebp-14h],14h
int c = Add(a, b);
001C18E3 mov eax,dword ptr [ebp-14h]
001C18E6 push eax
001C18E7 mov ecx,dword ptr [ebp-8]
001C18EA push ecx
001C18EB call 001C10B4
001C18F0 add esp,8
001C18F3 mov dword ptr [ebp-20h],eax
printf("%d + %d = %d\n", a, b, c);
001C18F6 mov eax,dword ptr [ebp-20h]
001C18F9 push eax
001C18FA mov ecx,dword ptr [ebp-14h]
001C18FD push ecx
001C18FE mov edx,dword ptr [ebp-8]
001C1901 push edx
001C1902 push 1C7B30h
001C1907 call 001C10D2
001C190C add esp,10h
return 0;
001C190F xor eax,eax
}
001C1911 pop edi
001C1912 pop esi
001C1913 pop ebx
001C1914 add esp,0E4h
001C191A cmp ebp,esp
001C191C call 001C1244
001C1921 mov esp,ebp
001C1923 pop ebp
001C1924 ret
1.main函数函数栈帧开辟的准备:
我们看到,在main函数一开始,第一行首当其冲的就是压栈操作:
001C18B0 push ebp
它的意义是,将维护完上一个空间的寄存器 EBP进行回收,并用于(push)main函数分配空间的压栈维护。紧接着由于 EBP 的重新指向,寄存器 ESP 也进行回收并指向(mov) EBP 之后:
001C18B1 mov ebp,esp
再接下来,ESP继续向后指向,而向后移动的距离,就是main函数所申请的空间大小:
001C18B3 sub esp,0E4h
//0E4h为十六进制数
接下来再次在 ESP 后压(压栈)上三个元素。在这里我们无需关心这三个元素,这与我们此处的讲解无关,后面它们也会自行弹出:
001C18B9 push ebx
001C18BA push esi
001C18BB push edi
接下来我们看到,在 edi 中存入了一个地址,而我们经过内存的查看发现,这个地址恰恰是main函数真正使用的内存栈空间的结束地址:
001C18BC lea edi,[ebp-24h]
再接下来,我们看到将 9 个 dword 数据(每个 double word 数据占四个字节)全部改为0CCCCCCCCh,即将mian函数实际占用的空间内数据全部改为0CCCCCCCCh:
001C18BF mov ecx,9
001C18C4 mov eax,0CCCCCCCCh
001C18C9 rep stos dword ptr es:[edi]
截至到这里,为main函数的函数栈帧的开辟所作的准备工作就全部结束了。
2.main函数函数栈帧的开辟:
然后正式进入了程序,首先定义变量a并将其值初始化为10,即将十六进制数 0AH (10)放入(mov)地址 [ebp-8] 中。并且我们在上面知道了,EBP所指向的是main函数的起始位置,并从该位置起向后的四个字节(一个整型变量的大小)分配给变量 a 使用:
int a = 10;
001C18D5 mov dword ptr [ebp-8],0Ah
同样的,变量b与c的创建也是相同的原理:
int b = 20;
001C18DC mov dword ptr [ebp-14h],14h
int c = Add(a, b);
001C18E3 mov eax,dword ptr [ebp-14h]
再往后我们看到程序来到了我们的函数调用处,而在这里我们首先进行的便是函数的传参。而在传参时,首先进行的是将两个参数a、b中后压入的变量 b 的值,即20,放入(mov)寄存器 eax 中:
001C18E3 mov eax,dword ptr [ebp-14h]
在将变量b的值存入寄存器eax后,将eax进行压栈操作压至栈顶:
001C18E6 push eax
并对之后压入的变量a执行同样的操作:
001C18E7 mov ecx,dword ptr [ebp-8]
001C18EA push ecx
接着执行了call指令,并且我们通过内存查看,发现call指令将它下一条指令的地址压在了变量之后,并且这个地址不是没有用处的,相反该地址极其重要,我们的程序是按照语句顺序进行执行的,在这里调用过Add函数,并在该函数指执行完成之后,要回到这个位置并继续向后顺序执行,此时这里存储的地址在Add函数执行完成回归主函数时就十分重要了,并且接下来才真正进入到了Add函数之中:
001C18EB call 001C10B4
001C18F0 add esp,8
3.Add函数函数栈帧的开辟:
接着在执行Add函数时,执行的流程与上面main函数的执行流程完全相同:
int Add(int x, int y)
{
001C1770 push ebp
001C1771 mov ebp,esp
001C1773 sub esp,0CCh
001C1779 push ebx
001C177A push esi
001C177B push edi
001C177C lea edi,[ebp-0Ch]
001C177F mov ecx,3
001C1784 mov eax,0CCCCCCCCh
001C1789 rep stos dword ptr es:[edi]
001C178B mov ecx,1CC008h
001C1790 call 001C131B
int z = x + y;
001C1795 mov eax,dword ptr [ebp+8]
001C1798 add eax,dword ptr [ebp+0Ch]
001C179B mov dword ptr [ebp-8],eax
return z;
001C179E mov eax,dword ptr [ebp-8]
}
001C17A1 pop edi
001C17A2 pop esi
001C17A3 pop ebx
001C17A4 add esp,0CCh
001C17AA cmp ebp,esp
001C17AC call 001C1244
001C17B1 mov esp,ebp
001C17B3 pop ebp
001C17B4 ret
并且我们在其中也看到,实际在进行操作时,操作的并不是变量a与变量b的实际地址内的数据,而是在上面进行函数调用传参时,传递过来的寄存器 eax 与 ecx 中所存储的形式参数:
001C1795 mov eax,dword ptr [ebp+8]
001C1798 add eax,dword ptr [ebp+0Ch]
001C179B mov dword ptr [ebp-8],eax
如此我们再来回忆,在我们即将但还没有调用函数之前,我们就已经将参数传递到了寄存器分配的临时空间内,并在之后真正进行函数调用时,操作的是寄存器中所存储的数据。因此我们说函数在进行传值调用是操作的是main函数变量的一份临时拷贝的说法完全正确!
并在Add函数进行了计算之后,将值赋给了变量 z:
int z = x + y;
001C1795 mov eax,dword ptr [ebp+8]
在计算完成后,Add函数将要按照问我们的要求返回数据。它的做法是将计算出的结果z的值,放入寄存器 eax 之中,这么做的原因是,我们也都知道一旦函数执行完成,所有Add函数的空间将被销毁并回收,但寄存器不会被销毁或回收,于是我们通过使用全局的寄存器,才可以实现将Add函数的结果返回给我们的主程序:
return z;
001C179E mov eax,dword ptr [ebp-8]
4.Add函数函数栈帧的销毁:
接下来函数Add函数的函数栈帧便会开始销毁,首先将同样放在栈顶的三个元素由低地址到高地址依次弹出:
001C17A1 pop edi
001C17A2 pop esi
001C17A3 pop ebx
001C17A4 add esp,0CCh
接着将指向Add函数空间栈顶的 ESP 重新指向 EBP,Add函数函数空间被销毁并回收,即Add函数的函数栈帧被销毁:
001C17AA cmp ebp,esp
再接下来,继续弹出此时的栈顶 EBP,此时的 EBP 一经弹出,便找回并指向之前保存在栈顶的main函数的函数栈帧。而此时,前来维护Add函数函数栈帧的 EBP 与 ESP 也得到了释放,又回到了main函数中,继续维护main函数的函数栈帧:
001C17B3 pop ebp
最后,执行最后一条 ret 指令,而此时 ret 指令要回到的,又恰恰是此前 call 指令存储在EBP弹出后变为栈顶的那个地址:
001C17B4 ret
接着我们就回到了主函数中,并且此时,存储在此时的栈顶的,我们在进行函数传参时所使用的临时变量也被全部弹出:
001C18F0 add esp,8
接着再把我们前面存储在全局寄存器中eax中的返回值赋给变量c:
001C18F3 mov dword ptr [ebp-20h],eax
至此,函数Add的生命彻底结束,成功的回到了主函数之中。
5.main函数函数栈帧的销毁:
再接下来main函数函数栈帧的销毁过程,与Add函数完全一致,在此我们也就不再做过多的阐述,各位小伙伴们可以对照这上面Add函数的销毁过程,自己尝试着去分析接下来main函数函数栈帧的销毁过程:
printf("%d + %d = %d\n", a, b, c);
001C18F6 mov eax,dword ptr [ebp-20h]
001C18F9 push eax
001C18FA mov ecx,dword ptr [ebp-14h]
001C18FD push ecx
001C18FE mov edx,dword ptr [ebp-8]
001C1901 push edx
001C1902 push 1C7B30h
001C1907 call 001C10D2
001C190C add esp,10h
return 0;
001C190F xor eax,eax
}
001C1911 pop edi
001C1912 pop esi
001C1913 pop ebx
001C1914 add esp,0E4h
001C191A cmp ebp,esp
001C191C call 001C1244
001C1921 mov esp,ebp
001C1923 pop ebp
001C1924 ret
三、总结:
到这里我们关于函数栈帧的讲解也就全部结束了。函数栈帧的部分略显得有些晦涩难懂,在这里对其进行讲解的意义在于帮助各位小伙伴们从更深层次去了解和理解函数的运作过程。函数作为我们极其常用的代码组成,而函数栈帧又作为函数部分的补充知识,希望各位先伙伴们能够多看几遍,仔细揣摩,认真思考,努力理解,争取掌握。
希望今天的学习能够增加各位小伙伴们对于函数栈帧的理解,和对函数运作过程的认识,帮助小伙伴们尽可能的避免在使用函数时可能遇到的问题。没有拼尽全力,就别推脱运气不好;没有竭尽所能,就别抱怨命运不公;很多时候我们只是看到别人光鲜亮丽的一面,而忽略了别人背后的付出!
新人初来乍到,辛苦各位小伙伴们动动小手,三连走一走 ~ ~ ~ 最后,本文仍有许多不足之处,欢迎各位看官老爷随时私信批评指正!