函数的栈帧

本文详细解析了函数调用时的压栈过程,介绍了栈的工作原理、栈帧结构,以及esp和ebp寄存器在维护栈帧中的作用。通过观察汇编指令,揭示了参数传递的底层实现,包括形参和实参的关系以及局部变量为何随机的问题。

       我们每次在调用函数的时候,都说会进行传参。每次创建函数,或者进行递归的时候,也会说会进行压栈。

       那么,今天我们就来具体看看函数到底是如何进行压栈,传参的操作。

什么是栈?

       首先我们要知道,我们将内存一般划分为三个区域:

  • 静态区
  • 堆区
  • 栈区

       我们平时创建的临时变量,函数都会在栈区中占据空间:049be485f32735eb27c952c312909990.png

       此时我们也要知道栈区的使用规则:从高地址向低地址使用

栈的使用规则:

       我们知道抢的弹夹,我们要逐个把子弹往里面压,之后如果取出子弹,就需要将上一次压入的子弹取出,之后逐个取出子弹,并只能按照顺序取出。2f6fc71ddbe7a4ad83a5011a05fa5ec9.png

       栈就是这样的使用规则,遵循先进后出,后进先出。ee04dd604b44d37317ce2349c7ac0665.png        此时你会想,不能把任意的数据取出,必须一个一个拿,这种结构真的好用吗?

        起初我也这样认为,但是计算机就喜欢用这种结构。

        在内存中,栈区的使用规则是从高地址向低地址使用的。

函数的栈帧:

       C语言中,我们要想观察函数栈帧就需要用到调试。当我们调试时所在的函数(此时函数未运行完),每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。

       因为我们知道,只要运行函数就会进行压栈操作,所以分析出以下信息:

  1. 栈帧是一块因函数运行而创建的的临时空间。
  2. 每调用一次函数都会创建一个独立的函数栈帧。
  3. 栈帧中存放着函数重要信息,如局部变量,函数返回地址,函数参数等。
  4. 当函数运行完毕后栈帧会销毁。

       既然会创建函数栈帧,那么就会维护其空间,计算机使用寄存器维护空间。

什么是寄存器?

       这里牵扯很多内容,我们只给出笼统解释:寄存器是集成到CPU上的,是独立的,寄存器可以暂存指令,地址和数据。所以寄存器也可以理解为指针。

       我们会使用很多寄存器,要理解清楚函数栈帧,就必须理解ebp和esp,这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。我们详细来讲esp和ebp两个寄存器。其余寄存器混个脸熟即可,一会会用到。ab097277997ae899ec42b06fa45ebc74.png

esp寄存器:

       维护栈顶,始终指向栈顶(此时esp寄存器存储栈顶地址)。

ebp寄存器:

       维护当前函数栈帧的栈低(此时ebp寄存器存储函数栈帧栈低地址)。

几个必要的汇编指令:

       我们观察函数栈帧的创建和销毁,就要知道几个汇编指令,这样可以更好的阅读以下内容。

c20c3a356bb084488464be424a0e6547.png

       记不住没关系,我们一下会一一讲解。 

图解: 

        这里我们使用VS2013来观察,由于VS2022太过高级,有些内部细节就会看不到,所以用VS2013来观察。此时我们执行以下代码:

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;
}

027e3aafe68b511be2041056a8a73232.png

       esp和ebp就是维护当前调用的函数。 

       点击F10开始调试。7847e3ea5047f725aee3bcefdda7bc3e.png       接下来我们看main函数被谁调用了,我们进入main函数,并直接执行完。44ef8f60811eb1455324f8bc3ad41ebd.png

9352fbe164ad55e4fcfdaccec3a667a5.png

087de3999c8c4dfc37f4ea2eb7601289.png

       在VS2013中,main函数也是被其他函数调用的。没想到main函数也是被调用的函数,也理解了为什么每次都要有返回值。 

       mainCRTStartup函数调用 __tmainCRTStartup函数,__tmainCRTStartup函数调用main函数。

b323a680c27e5e3c95627b17772d41a3.png

       之后我们按住F10,之后右击鼠标找到“转到反编汇”,就可以找到C语言所对应的汇编代码,箭头的指向就可以一行一行的执行,我们来逐过程分析:

2a8e9ecd13822aa6acf980327241bfab.png

       

b41433bd18680c13b0a297f2ac48d57e.png

        push ebp,将ebp压入栈区。因为esp维护栈顶,所以esp指向改变。我们可以观察其存放地址的改变。a57f9370ead10badf02bf1440fc2cf98.png

072fc1a57acad7d6d259641545ee3b52.png

       之后执行move,move是把后面的值赋到前面去。

8de3d1de4e3501e2d7cd123cbc42f8bf.png

       此时ebp和esp指向的位置相同。

042bef260e001d8030422c5082072c11.png

1b5b25ec87fad388a2f6a476327e8105.png        之后,就要创建函数的栈帧了,执行sub,就是将其寄存器存放地址减去一个地址,因为栈区是高地址到底地址,所以该地址向上。258afb1e90e2a8fe67021b2bd4fd1f5e.png

d7b8a07ee9a54dac5323bbb080ac9dca.png

       此时esp维护main函数的栈顶,ebp维护main函数的栈低。我们可以看内存:

14e7901203e5119d53793a1d8a513d47.png

       这些内存都是为main函数开辟的空间。

       之后又执行了3次push,push时会有一个动作,就是栈顶指针esp会变,一直指向栈顶。

a7bb7f2bd25c76e310e4d119699804f5.png

       我们通过内存窗口来观察:f3ebc35faf37c32855e285bcdd77b464.png        因为我们说过,寄存器既可以存地址,也可以存数据,此时ebx存的是数据(就是内存里面的内容),而esp存放的是ebx的地址。压入ebx以后,继续将esi、edi压入。d43326b5523625434b8da7fb6e28e659.png

       之后执行lea : load effective address 加载有效地址。ad891038449594d989178c5497d9c725.png        此时会发现,正好是加载的空间正好是最开始esp减去的空间,就是main函数栈帧的低地址。

52ef22eb8abc3397207ae412afb56948.png

       之后执行的命令,我们就需要先讲解一下了。

       我们应该听过字节的概念,1字节等于8比特位,那么字和字节又什么关系?一个字等于两个字节。

       比特记为bit,字节记为Byte,字记为word,所以有如下关系:

  • 1Byte=8bits
  • 1word=2Bytes=16bits 
  • dword:一个word是两个字节,d代表double,就是双字,就是4个字节。

       此时我们要看其以下的三个步骤:25777b8234c17069f764eb55f09bc57e.png

        此时edi里面存放main函数栈帧的低地址。5f489843aeb4fb76f279b7fcae9117a7.png

c59b7b38ec06ab66f1db85292ac5ea5e.png0e604bdd7acd5e98aa535b917283538e.png

        这样就可以理解为什么每次打印未初始化的空间,打印出来的字符都是一个汉字“烫烫烫烫”了。ae8caa0c8fe36208f2122c1e67e50abd.png

       此时才会开始执行有效的代码。在此之前都是为main函数开辟的空间。24e8bdbf25c3b89789246585350b6e46.png529db0274896aa84a4786f7611646f6c.png

5aa341e29d3d99490547cf5cc3562f00.png

436aecd84f611f0c8d57061c020adf09.png578a68dc74e4b272b210b61f77b6a5a2.png

       此时就要调用Add函数了,一样的,我们要改变ebp和esp的指向,因为进入Add函数就需要维护Add函数栈帧了,但是还是要做以下准备,就是传参,我们来看形式参数的创建。11328f96ae41097833474378f73ed6ae.png26dc42dfdc355ddc17979176c7ec63a6.png5196262072342d87ed825a6d895ecd26.png

       这两个动作相当于传参,之后执行call,就是调用函数,要记住call的地址,此时点击F11才能进入Add函数。4638e9f9166bb9150dcf257f582aa052.pngf05ab96f2b73865eedb0eaeb1ce51ead.png

       我们可以发现就在ecx的下一个地址里面存储了call指令下一个执行的地址。为什么要记录地址?我们先埋个伏笔,此时我们会先进入Add函数,流程如下:0bbfd569300272f1fc8fbbfba31148ae.png

       注意此时main函数的函数栈帧已经增长到call指令的下一个地址了。7201d4dc68283089e8d74dfcc0a4a363.png        此时我们来观察Add函数的细节:将esp减去一个地址改变指向:787fb341b225879c9f9ba17c4112a979.png

       之后还是main函数栈帧的那一套操作,压入3个寄存器并初始化空间,并将z初始化为0: 1b0c88e82aadd7954e5bcb026af0c128.png

       此时先将 ebp + 8 的值赋给 eax ,此时 eax = 10;之后又执行add,将 ebp + 12 的值等于30,最后将eax的值赋给 ebp - 8 ,此时 ebp - 8 地址的值是30.a6668563702f772b44a6f23634fdea8b.png        我们可以发现,我们使用Add函数并没有创建形参,在我们传参时其实已经压栈过了,而且参数是从右向左传参的。

       返回的话z会被销毁,我们来观察其如何返回。

41560d72de9697a70f0c6c70a4ecd79d.png

       我们将结果放入eax寄存器当中,此时就不用担心函数销毁。

       此时将上面的3个寄存器弹出栈顶。8a85df0df8d9599cce25b23e2d68751a.png

       之后mov esp的位置,esp的指向改变:6824b33092768a8e7bb0bd486c33d632.png        此时弹出ebp,ebp弹出以后会指向main函数的栈低,因为之前记录着mian函数的栈低。ffd6c114c58b6026ffcf52e84397e008.png

3d51b5ca9f7bd01ad690d3f66ef15ff9.png

        当前栈顶元素为call指令的下一个指令的地址,ret这条指令就是找到之前call指令记录的地址,并pop一次栈顶元素。a6911b7b81994eea5d9f54c3c7c2f4e4.png

        此时执行add   esp,8 因为没有dword 所以是改变指向。2be2c2c79a19a5afd0604395f4844f63.png

       此时将形参x,y的空间还给操作系统。此时又执行mov,将eax存放的值赋给 ebp - 20h 就是给c赋值。 9911fc2d21f6c6adcfdee396de25515b.png

       此时main函数执行完,也是以上步骤,我们不再赘述。

总结: 

       我们通过观察函数栈帧的创建和销毁,最后返回值是由寄存器带回来的;也可以理解为什么局部变量的值是随机的,形参和实参的关系,确实是一份临时拷贝。希望大家下去多加练习,逐渐就会顿悟其中的原理。

       爆肝一整天,点点赞吧,呜呜~

a60775e804154e7eb067f1b9b6c11b3e.png

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值