文章目录

  • 前言
  • 一、准备工作
  • 二、谁调用了main函数?
  • 三、浅读一下汇编
  • 总结

前言

函数栈帧万字详解,学不会你来捶我!!!

在学习函数的时候我们或许会有一些疑惑,比如:
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;}

二、谁调用了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只是并为维护那块空间了,并没有像我们建立栈帧那样格式化,这位我们恢复数据提供了可能;