函数栈帧万字详解,学不会你来捶我!!!
😁😁😁😁😁😁😁😁😁😁😁😁😁
在学习函数的时候我们或许会有一些疑惑,比如:
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函数既然是一个函数就一定也会在栈上建立栈帧,会建立栈帧,那一定就有函数调用它,那到底是谁在调它呢?
我们按下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只是并为维护那块空间了,并没有像我们建立栈帧那样格式化,这位我们恢复数据提供了可能;