C语言第十九课:补充知识——函数栈帧的创建与销毁

目录

前言:

一、寄存器:

二、函数栈帧的创建与销毁:

        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

三、总结:

        到这里我们关于函数栈帧的讲解也就全部结束了。函数栈帧的部分略显得有些晦涩难懂,在这里对其进行讲解的意义在于帮助各位小伙伴们从更深层次去了解和理解函数的运作过程。函数作为我们极其常用的代码组成,而函数栈帧又作为函数部分的补充知识,希望各位先伙伴们能够多看几遍,仔细揣摩,认真思考,努力理解,争取掌握。

        希望今天的学习能够增加各位小伙伴们对于函数栈帧的理解,和对函数运作过程的认识,帮助小伙伴们尽可能的避免在使用函数时可能遇到的问题。没有拼尽全力,就别推脱运气不好;没有竭尽所能,就别抱怨命运不公;很多时候我们只是看到别人光鲜亮丽的一面,而忽略了别人背后的付出!

        新人初来乍到,辛苦各位小伙伴们动动小手,三连走一走 ~ ~ ~  最后,本文仍有许多不足之处,欢迎各位看官老爷随时私信批评指正!

  • 38
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 42
    评论
评论 42
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

銮崽的干货分享基地

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值