• 函数栈帧详解



    前言

    函数栈帧万字详解,学不会你来捶我!!!
    😁😁😁😁😁😁😁😁😁😁😁😁😁
    在学习函数的时候我们或许会有一些疑惑,比如:
    1、局部变量是怎末创建的?
    2、为什么局部变量的值是随机值?
    3、函数是怎末传参的,传参的顺序是怎么样的?
    4、形参和实参是什么关系?
    5、函数调用是怎末实现的?
    6、函数调用结束后是怎末返回返回值的?
    今天,博主就和大家一起探讨一起探讨一下函数栈帧的创建和销毁,学会了些,我们的疑惑也就自然解决了;

    一、准备工作

    测试环境:VS2019
    首先我们得知道:eax、ebx、ecx、edx是一些常见的寄存器;
    当然我们今天的主角esp和ebp也是一种寄存器;
    上面的寄存器我们目前只需要知道他是一个寄存器就行了;
    其中函数栈帧是在栈上开辟我们得知道,栈是先进后出;
    其中esp和ebp之间维护的空间就叫函数栈帧;
    在这里插入图片描述
    测试代码:

    int Add(int x, int y)
    {
    	int z = x + y;
    	return z;
    }
    int main(int argc, char* argv[]) {
    	int a = 20;
    	int b = 30;
    	int c = Add(a, b);
    	printf("%d\n", c);
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    二、谁调用了main函数?

    我们知道main函数是整个程序的入口;
    main函数既然是一个函数就一定也会在栈上建立栈帧,会建立栈帧,那一定就有函数调用它,那到底是谁在调它呢?
    我们按下f10,先调试起来,在窗口中找到调用堆栈;
    在这里插入图片描述
    我们可以看到,黄色箭头所指向的位置,就是main函数,在其下main还有其他函数,我们点进去看看:
    在这里插入图片描述
    我们可以发现,是一个叫做invoke_main()的函数调用的main函数;
    我们发现invoke_main()也是一个函数,那一定也有函数调用它:
    在这里插入图片描述
    在这里插入图片描述

    我们可以发现是一个叫__scrt_common_main_seh()函数调用了invoke_main()函数,那谁又调用了__scrt_common_main_seh()?
    在这里插入图片描述
    是一个__scrt_common_main()函数调用了它;
    谁有调用了__scrt_common_main()?
    在这里插入图片描述
    是mainCRTStartup()调用了__scrt_common_main();
    至此调用结束;
    简单梳理下:
    在这里插入图片描述
    就是这样层层调用;
    也就是说在main函数所开辟的空间下,还有其它函数开辟的空间,main函数并不是从栈底开始开辟空间的;

    在这里插入图片描述

    接下我们从汇编的角度来理解一下,函数栈帧的创建和销毁;

    三、浅读一下汇编

    既然要读汇编,我们就要首先找到汇编,按下f10,右击鼠标,找到反汇编;
    在这里插入图片描述
    我们就可以看到我们所写代码,对应的汇编指令了;
    在这里插入图片描述
    为了方便观察把这个勾掉;
    接下来进入正题:
    通过上文我们知道,在没有调用main函数的时候,我们的esp和ebp维护的是invoke_main()函数的栈帧:
    在这里插入图片描述
    同时我们记录一下此时esp和ebp所保存的地址;

    接下来我们看一下第一条指令:
    在这里插入图片描述
    这条指令的意思呢就是将ebp里面的值压入栈中(注意是ebp里面的值!!!不是将ebp这个寄存器压进去,ebp的值也就是ebp当前所在位置的地址),在栈中先保存一下(剧透一下,也就是保留一下回来的路,避免出去了回不来的尴尬);
    在这里插入图片描述
    既然往栈中压入了元素,我们的esp就往上维护一下我们esp里面就放着压入栈中的的元素的地址(压入的这个元素就是ebp所存的值),这个我们来对比一下esp和ebp维护的地址就行了
    指令执行之前:
    在这里插入图片描述
    指令执行之后:
    在这里插入图片描述
    是不是相当于之前的地址减小了4字节;
    我们再来看看esp所存的指针所指向的空间是不是存的ebp的值;
    在这里插入图片描述
    答案是和我们上诉讲的理论是相符的(倒着看);
    我们接着这往下看下一条指令:
    在这里插入图片描述
    这条指令的意思呢就是,将esp寄存器里面的值赋给ebp寄存器
    等价于ebp=esp;
    那我们就执行呗:
    在这里插入图片描述
    我们观察一下ebp里面的值是不是等于esp:
    在这里插入图片描述
    符合我们上述理论:
    这条指令就执行完了;
    接着看下一条指令:
    在这里插入图片描述
    这条指令的意思就是将esp里面的值减去0E4h(十六进制)在重新赋给esp等价于esp=esp-0E4h;
    esp也就不在维护这里了,esp也就更新了维护地点:
    我们看看监视:
    在这里插入图片描述
    在这里插入图片描述
    我们可以发现,esp和ebp是不是已经不在维护invoke_main()函数的栈帧了,而是在维护一块新空间;
    联想我们在干什么,这一块新空间,不就是为main()函数预开辟的吗!!!

    在这里插入图片描述
    同时记录一下此时esp和ebp维护的指针:
    在这里插入图片描述

    接下来看下一条指令:
    在这里插入图片描述
    这条指令的意思就是将ebx寄存器里面的值压入栈中!!!不是将edi这个寄存器压进去(ebx的值压进来具体作用我们不讨论)
    同时esp向上维护一下也就是esp=esp-4;
    在这里插入图片描述
    接着再看下一条指令:
    在这里插入图片描述
    与上一条指令一样的效果:
    在这里插入图片描述
    接着再看下一条指令:
    在这里插入图片描述
    与上一条指令一样的效果:
    在这里插入图片描述
    最终达到的效果就是:
    在这里插入图片描述

    接着来看下一条指令:
    在这里插入图片描述

    这条指令的意思就是将ebp-24h的地址加载到edi中;
    那么edi中就应该存放的是ebp-24h的值:
    在这里插入图片描述
    经过我们运算,edi里面就应该放这个值,我们来看看到底是不是呢?
    在这里插入图片描述
    很明显我们的计算是正确的;
    仔细观察的话我们会发现edi其实是比esp大的:
    在这里插入图片描述
    我们也就能大概知道edi是指向那的指针了:
    在这里插入图片描述
    接着来看下一条指令:

    在这里插入图片描述
    意思:将9放入ecx这个寄存器:
    在这里插入图片描述
    接下来我们看下一条指令:
    在这里插入图片描述
    将0cccccccch放入eax寄存器中去:
    在这里插入图片描述
    接下来我们看下一条指令:
    在这里插入图片描述
    这条指令的意思就是从edi所指向的位置开始的4个字节(word代表1字,1字也就是2个字节,double word双字,也就是4字节)初始化为eax,其中edi=edi+4;
    并且重复此过程ecx次,最终我们会发现edi==ebp;
    在这里插入图片描述
    在这里插入图片描述
    我们可以看到的确是初始化成了cccccccc,这也是为什么我们不初始变量的时候,打出来的是随机值或者烫烫烫,当然不同的编译器,对于开辟的空间用什么值初始化是不一样的;
    接着看下一条指令:
    在这里插入图片描述
    将79c003h放入ecx中:
    在这里插入图片描述
    至此main函数的栈帧完全开好了,并且完成了初始化!!!!;
    在这里插入图片描述

    接着下一条指令:
    在这里插入图片描述
    也就是int a=20;这条语句对应的汇编;
    将14h(20)放入地址为ebp-8的这个位置:
    在这里插入图片描述
    在这里插入图片描述

    接着下一条指令:
    在这里插入图片描述
    与上一样为b开辟空间并初始化;
    在这里插入图片描述
    在这里插入图片描述

    接着下一条指令:

    在这里插入图片描述

    这两条的意思就是将ebp-14h地址处的值放入eax中,然后再将eax中的值压入栈中存起来,请注意!!ebp-14h地址处的值是什么是变量b唉!!!!,先在将b的值压入了栈中唉,既然出现了压栈esp就应该向上维护一下:
    在这里插入图片描述
    在这里插入图片描述

    这一步是不是相当于在传参了,先传的b;
    接着下一条:
    在这里插入图片描述
    遇上面一样的,ebp-8是a的地址;
    这次传参传的是a了;
    在这里插入图片描述
    在这里插入图片描述
    从上面的指令我们知道了,两个有用信息,函数传参的顺序的确是从右往左;
    形参的确是实参的一份临时拷贝;

    接着下一条指令:
    在这里插入图片描述
    从这里开始调用Add函数;
    在此之前编译器会把下一次指令的序号(007955D0)压入栈中,以防下次回来的时候,能够保证能顺利往下执行下一条指令,然后才开始正式调用Add;
    在这里插入图片描述
    观察esp所指向的空间是不是放的下一条指令的序号:
    在这里插入图片描述
    接下来我们便开始为Add()函数建立栈帧:
    在这里插入图片描述
    过程同main函数建立栈帧一样(读者可以自己尝试走一遍)
    在这里插入图片描述
    下一条指令:
    在这里插入图片描述

    ebp+8不就是形参x的地址吗,第一句话的意思就是将x的值放入eax的寄存器里面;第二句话:
    将地址为ebp+0ch(也就是形参y的地址)将y与eax寄存器里面的值做加法,在写入eax寄存器;
    第三句话:将eax的值写入地址为ebp-8的位置处(也就是z);
    z空间里面所放的;
    在这里插入图片描述
    其实从这里我们可以发现:初始化和赋值的关系:
    初始化其实并不等同于赋值;初始化的话是直接在栈上开辟空间,然后将值放入所开辟的空间里面就行了;
    而赋值的话就不是,赋值的话由于空间已经开辟好了,我们将值向读入寄存器里面保存一下;
    再通过寄存器之手写回内存:
    在这里插入图片描述

    下一条指令:
    在这里插入图片描述
    将ebp-8地址处的值放入寄存器里,ebp-8也即是z的地址,也就是相当于把z的值放入eax寄存器里面
    在这里插入图片描述

    在这里插入图片描述
    从这里我们可以看出,return返回值并不是直接就返回了,而是先保存在寄存器里面,等回到main函数的栈帧时才开始返回;
    接下来我函数作用发挥了,可以销毁函数了:
    在这里插入图片描述
    这三条指令就是出栈:
    也就是每出一次栈esp+4;
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    esp和ebp维护的空间在在缩小,也就是Add函数栈帧在一步步销毁;
    下一条:
    在这里插入图片描述
    我们如果注意的话其实esp+0cch就回到了ebp的位置:
    在这里插入图片描述
    这是刚为Add建立栈帧时esp所向上维护的步长也是0CCh;
    在这里插入图片描述
    esp和ebp相等:
    在这里插入图片描述
    下一条:
    在这里插入图片描述
    出栈操作:将ebp所指向的元素出栈,并且将此元素的值放入ebp;既然是出栈操作,esp当然得esp=esp+4;
    在这里插入图片描述
    在这里插入图片描述
    此时我们可以发现
    在这里插入图片描述
    esp的确加了4,ebp回到了维护main函数的地方;由此我们可以得出一个结论,ebp不仅维护被调用函数的栈上,也维护着调用函数的基地址;
    下一条:
    在这里插入图片描述
    将esp所指向的元素出栈,并且回到改元素所标的位置;esp+=4;
    在这里插入图片描述
    在这里插入图片描述
    至此Add函数栈帧已被销毁完毕!!!;
    下一条:
    在这里插入图片描述
    esp=esp+8;
    也就是将形式参数销毁!!!;
    至此Add函数完全被销毁;
    在这里插入图片描述
    至此我的esp和ebp又开始重新维护main函数这块栈帧了;
    至此函数栈帧的销毁已经结束了;
    (后面的main函数栈帧的销毁读者可以自己走一遍)
    在这里插入图片描述
    当然还要把寄存器里面的返回值读出来,放在ebp-20h的地址处也就是c;
    在这里插入图片描述
    最终截图:
    在这里插入图片描述

    总结

    回答一下前面的问题:
    1、局部变量是靠函数栈帧来创建的,先有函数栈帧才有局部变量,函数栈帧消失,局部变量消失,先定义的局部变量地址较高;这个主要使用ebp-某值来确定的,至于具体是多少,和怎么使用空间,是由编译器决定的;
    2、对于为初始化的变量,由于没有值去覆盖这个空间原有的值,而原有的值是函数栈帧建立时编译器自动为我们覆盖在上面的,不同的编译器对于所格式化的值也就不一样,故不初始化的变量,里面存的是栈帧建立初期,编译器格式化空间的值;
    3、函数传参是从右往左的(VS2019是这样);
    4、形参其实是实参的一份临时拷贝;
    5、主要是靠esp和ebp的移动来维护的;如果函数有参数的话,先为形式参数开辟空间,把行参存起来,在保存一下下一条指令,以免函数调完后,不能顺利的往下执行,最后就是保存一下,调用函数的基地址,以保证下次我们能够原路返回;再为被调用函数建立栈帧并初始化栈帧;被调用函数的局部变量,在这栈帧里面开辟;若国函数调完了,并且拥有返回值的话,先将返回值保存在寄存器里面(不着急返回)然后一步步逆向当时建立栈帧时的操作;待回到调用函数内部是,将返回值写回内存;至此是函数栈帧创建销毁的大概过程;
    6、拥有返回值的话,先将返回值保存在寄存器里面(不着急返回)然后一步步逆向当时建立栈帧时的操作;待回到调用函数内部是,将返回值写回内存
    … …
    … …
    … …
    其实通过函数栈帧销毁的过程我们可以发现esp和ebp只是并为维护那块空间了,并没有像我们建立栈帧那样格式化,这位我们恢复数据提供了可能;

  • 相关阅读:
    新渲染引擎、自定义设计和高质量用户体验的样例应用 Wonderous 现已开源
    SQLAlchemy学习-6.Column 设置字段一些参数配置
    Springboot学生作业管理系统毕业设计-附源码251208
    神经网络(五)卷积神经网络
    神经网络入门书籍推荐,神经网络的书籍推荐
    判断点是否在贝塞尔曲线(Bézier curve)上的方法
    JAVA毕业设计古惠农产品线上销售系统计算机源码+lw文档+系统+调试部署+数据库
    Javascript知识【案例:重写省市联动&案例:列表左右选择】
    K线形态识别_黑三兵
    Windows11 WSL2 Ubuntu编译安装perf工具
  • 原文地址:https://blog.csdn.net/qq_62106937/article/details/125998163