文章目录
- 一、寄存器
- 二、函数栈帧的创建和销毁
- 1.什么是函数栈帧?
- 2.案例代码-讲解
- 3.总结函数栈帧
一、寄存器
寄存器(Register)是中央处理机、主存储器和其他数字设备中某些特定用途的存储单元。寄存器是集成电路中非常重要的一种存储单元;其可用来暂存指令、数据和地址。在计算机领域,寄存器是CPU内部的元件包括通用寄存器、专用寄存器和控制寄存器。寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快。
1. 相关寄存器:
二、函数栈帧的创建和销毁
1.什么是函数栈帧?
函数栈帧是指函数被调用时,系统为该函数在栈(Stack)区开辟的一段内存空间。栈区是一种后进先出(Lsat In First Out)的数据结构,后面会学习数据结构的相关知识。
2. 部分汇编指令:
2.案例代码-讲解
上面介绍了ebp(栈底指针)和esp(栈顶指针),这两个寄存器中存放的是地址,而这两个地址是用来维护函数栈帧的(维护的是正在调用的那个函数)。
压栈(push):给栈顶放一个元素。
出栈(pop):从栈顶删除一个元素。
下面我们通过一个代码来讲解函数栈帧的知识点:首先所有的函数调用都会在栈区开辟空间(函数栈帧),包括main函数。main函数也是被其他函数调用的:在VS2013的编译器中,main函数是被 __tmainCRTStartup函数调用的,而 _tmainCRTStartup函数又是被mainCRTStartup函数调用的。(栈空间的使用是从高地址向低地址使用的)
假设现在就正在调用main函数
注意:上面是在VS2013编译器下的函数栈帧维护过程,对应维护栈帧的寄存器是esp和ebp。而在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。如果使用越高级的编译器,那对于函数栈帧过程的封装更加的复杂,观察学习越不容易理解。所以我们使用VS2013来学习函数栈帧的知识。
下面通过在VS2013的环境下讲解函数栈帧的维护过程:
● 局部变量是怎么创建的?
● 为什么局部变量的值是随机值?
● 函数是怎样传参的? 传参的顺序是怎样的?
● 形参和实参是什么关系?
● 函数调用是怎么做的?
● 函数调用在结束后是怎么返回的?
通过下面的一段代码来讲解函数栈帧:
#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 c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
按F10调试起来,当调试完毕后,就可以在调用堆栈里面看到上面讲过的main函数被_tmainCRTStartup函数,_tmainCRTStartup又被mainCRTStartup函数调用:
在按F10调试起来后,单机鼠标右键转到反汇编:
C语言对应的反汇编代码如下:
去掉显示符号名,把[ ]中的符号转化为具体的地址,因为我们要看具体的运行布局:
下面我们就来逐一理解上面的每一行汇编代码:
(1) 首先我们知道在调用main函数之前,main函数要被_tmainCRTStartup函数调用,在调用完_tmainCRTStartup函数后,开始调用main函数,则上面前六行汇编代码意思是:
push ebp:向栈顶压入一个元素ebp,之后esp也会随之往上移动.
mov ebp,esp:把esp的值赋给ebp,则此时ebp就指向了esp指向的对象.
sub esp 0E4h:让esp这个地址减去0E4h,即让esp指针往上移动0E4h这么多个地址(0E4h是一个16进制数,h意思是这个数是16进制表示)。此时esp与ebp之间的栈空间就是为main函数预开辟的栈帧,因为esp与ebp这两个指针就是用来维护正在调用的函数的。
push ebx.
push esi.
push edi:这个三个寄存器也随之压入栈顶,先不管这三个寄存器,先知道它们压入栈就行。相应的esp(栈顶指针)也会往上移动.
走完了上面六行汇编代码,此时在栈上的运行示意图如下:
(2) 紧接着我们来到了后面的四行代码:
lea edi,[ebp-0E4h]:lea是加载有效数据的意思,这里意思就是把ebp这个指针减去0E4h以后的地址放到edi里去.
mov ecx,39h:把39h赋给ecx.
mov eax,0CCCCCCCCh:把0CCCCCCCCh这个值赋给eax.
rep stos dword ptr es:[edi]:这句汇编代码的意思是把从edi这个位置开始向下的39h个dword空间的内容全部改成0CCCCCCCCh.(word表示两个字节;dword即double word,表示双字,也就是四个字节的意思。39h是16进制数,换算成十进制就是57)
这四行汇编代码的作用其实就是对main函数预开辟的栈帧空间进行初始化,初始化内容为0CCCCCCCCh:
到这里main函数的栈帧就算开辟完成.
(3) 接下来就是局部变量的创建
mov dword ptr [ebp-8],0Ah:将0Ah这个值放到ebp-8所指向的空间里.
mov dword ptr [ebp-14h],14h:将14h这个值放到ebp-14h所指向的空间里.
mov dword ptr [ebp-20h],0:将0放到ebp-20h所指向的空间里.
注:dword是每次操作四个字节的空间,0Ah、14h及20h分别是10、20与32的16进制表示。现在知道了为什么变量不初始化时,打印出来的值是随机值的原因就是这个。这里就将a、b、c的值放进了main函数的栈帧里:
从内存中可以查看a、b、c存储的情况:
到这里就准备好了a、b、c三个变量
(4) 紧接着就要调用Add函数:(注:由于每次运行代码时分配的空间有所差异,所以上面的汇编指令的地址与下面的略有不同,但调用过程是一样的)
mov eax,dword ptr [ebp-14h]:把ebp-14h所指向空间的值赋给eax.
push eax:向栈顶压入eax这个元素(的值),esp也随机向上移动.
mov ecx,dword ptr [ebp-8]:把ebp-8所指向空间的值赋给ecx.
push ecx:向栈顶压入ecx这个元素(的值),esp随之向上移动.
上面这四步其实就是在进行传参,将a和b的值压入栈,等后面调用Add函数的时候就会用到。所以在调用Add函数之前就已经将形参的值传过去了,所以形参就是实参的一份临时拷贝。
call 009E10E1:调用009E10E1这个地址,其实就是开始调用Add函数了(到call这条汇编指令的时候,要按F11调试才能进入Add函数内部)。在执行了call指令后(按F11),会发现call指令的下一条指令的地址被压入栈顶了(相应的esp也会变化):
按一次F11会跳到下面的界面:
jmp 009E13C0:跳转到009E13C0这个地址处;而Add函数的地址就是009E13C0。再按一次F11就会开始为Add函数分配栈帧:
上面红框部分其实就是在为Add函数预开辟栈帧,和main函数的栈帧开辟是一样的步骤。解释一下7~8行的汇编代码:(勾选显示符号名)
lea edi,[ebp-0CCh]:把ebp这个指针减去0CCh以后的地址放到edi里去.
mov ecx,33h:把33h赋给ecx.
mov eax,0CCCCCCCCh:把0CCCCCCCCh赋给eax.
rep stos dword ptr es:[edi]:把从edi这个位置开始向下的33h个dword空间的内容全部初始化为0CCCCCCCCh.:
紧接着就是在Add的栈帧上为变量z开辟一块空间,并将传过来的形参的值进行相加再存入z中:
mov dword ptr [ebp-8],0:将0赋给ebp-8所指向的空间里.(z的初始化)
mov eax,dword ptr [ebp+8]:将ebp+8所指向空间里的值赋给eax.
add eax,dword ptr [ebp+0Ch]:将ebp+0Ch所指向空间里的值加给eax.
mov dword ptr [ebp-8],eax:把eax的值存到[ebp-8]所指向的空间里.(把求和的结果存到z变量里)
mov eax,dword ptr [ebp-8]:将ebp-8所指向空间里的值赋给eax。因为当Add函数调用完毕后,局部变量会被销毁,所以让eax这个寄存器存放刚刚求和的结果,以便后面将结果带回去;即eax这个寄存器不会随着Add函数栈帧的销毁而销毁:
(5) 当Add函数调用完毕后,就要返回main函数的栈帧:
pop edi:edi弹出栈顶,相应的esp指针会变化
pop esi:esi弹出栈顶,相应的esp指针会变化
pop ebx:ebx弹出栈顶,相应的esp指针会变化
mov esp,ebp:把ebp的值赋给esp;则此时esp就指向了ebp所指向的空间。这里等于就销毁了Add函数的栈帧
pop ebp:⚽把ebp从栈顶弹出来(出栈);注意此时ebp所指向的空间里存放着原来指向main函数栈帧的栈底指针,所以ebp出栈后,ebp就又回到(指向)了main函数栈帧的栈底,所以为什么在调用其他函数之前会把ebp这个栈底指针给压入栈里存起来,就是当函数调用完成后能够回到原来主调(上一级)函数的栈底。当ebp弹出栈顶后,相应的esp(栈顶指针)也会变化⚽
此时我们虽然回到了main函数的栈帧里,但是代码执行到的位置在哪呢?在调用完Add函数后,应该要回到刚才我们执行call指令的下一条指令处继续执行才可以。所以上面的最后一条汇编指令ret就是要回到call指令的下一条指令处继续执行。
🎈🎈由于此时栈顶存放正好就是call指令的下一条指令的地址,所以通过ret指令,就让call指令的下一条指令的地址弹出栈(相应的esp也会向下移动),此时也就跳回到了call指令的下一条指令处继续执行。
add esp,8:让esp这个地址加上8其实就是相当于把形参x和y还给操作系统了,销毁了x和y。
mov dword ptr [ebp-20h],eax:把eax的值存到ebp-20h所指向的空间里。因为eax的值就是刚刚在Add函数里求和的结果,现在赋给ebp-20h所指向的空间,而ebp-20h所指向的空间就是我们的c变量,至此就将a和b求和的结果放到了c变量里。
对于函数栈帧的理解就到这里!!!
3.总结函数栈帧
🍆🍆🍆需要注意:函数栈帧、局部变量的开辟是在栈区上开辟的,而全局变量、静态变量是在堆上创建的,要注意区分。我们就可以逐一回答上面提出的一些问题:当局部变量不初始化时,内存中存储的就是像CCCCCCCC这样的随机值。函数的传参也是通过压栈的方式将实参的值压入栈里,所以我们使用的形参其实就是实参的一份临时拷贝。传参的顺序对于上面的Add函数可以看到是先将b的值压入栈,然后再将a的值压入栈。函数在调用结束后返回上一层的主调函数在上面的讲解中也可以深刻的体会到。还需要深刻的理解寄存器是集成到我们的CPU上面的,是独立于内存的;所以函数栈帧的销毁并不会影响到这些寄存器。ebp和esp这两个寄存器需要记住:这两个寄存器是用来维护正在调用函数的栈帧的🔥🔥🔥