前排提示:本文偏难!C语言作为一门偏底层的语言,了解到函数栈帧的创建和销毁还是非常有必要的。这对于我们理解C语言底层知识是有着非常大的帮助的,能够修炼我们的内功,不管你了不了解,本文都是强推!
本文受到
Code_Caohttps://blog.csdn.net/qq2466200050
原来45https://blog.csdn.net/weixin_62700590
两位大佬文章的启发
本文开篇我们提出5个问题:
1.局部变量是怎么创建的?
2.为什么局部变量的值是随机值?
3.函数是怎么传参的?传参的顺序是怎样的?形参和实参是什么关系?
4.函数调用是怎么做的?
5.函数调用是结束后怎么返回的?
认真看完这一篇文章你都能一一解答。
先看代码,这是我们今天研究的代码。
#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", c);
}
目录
3.函数是怎么传参的?传参的顺序是怎样的?形参和实参是什么关系?
1.基础理解
1.1 寄存器
电脑中主要的本地存储方式有寄存器、内存、硬盘。硬盘最大,读取速度最慢。而寄存器的空间很小,但是速度是最快的,并且寄存器是集成在cpu上面的。它们可用来暂存指令、数据和地址。
通俗一点解释就是:
寄存器就是你的口袋。身上只有那么几个,只装最常用或者马上要用的东西。
内存就是你的背包。有时候拿点什么放到口袋里,有时候从口袋里拿出点东西放在背包里。
硬盘就是你家里的抽屉。可以放很多东西,但存取不方便。
本文我们研究的寄存器主要是:ebp esp
1.2 函数
C语言是由函数为单元模块构成的,每一次我们用C语言编写程序的时候,都需要写一个主函数main()。我们了解函数栈帧的创建和销毁,其实就在研究一个代码是如何实现的。
我们需要用到的是 汇编代码 。
1.3 栈帧
C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。栈帧也叫过程活动记录,是编译器用来实现过程/函数调用的一种数据结构。首先应该明白,栈帧是存放在内存中的栈区的。栈帧是栈区分配给进程的内存区。
2.main函数栈帧的创建
当我们创建了一个main函数,ebp和esp寄存器里面存的就是这个函数的首位地址,用来维护这一块空间。
我们在进入这个程序的时候,首先需要调用main函数,你没听错,就是调用它。那么是谁调用的main函数呢?它是被一个简称叫做CRT的函数所调用的。
以VS2019为例,我们转到反汇编
可以看到这个seh函数调用了SRT函数(这里说法可能有误有错误请及时指出)
然后SRT函数再调用了main函数(这里说法可能有误有错误请及时指出)
用图表示出来就是esp和ebp在维护函数CRT的空间。我们接下来再看了解主函数是如何创建函数栈帧的。
这里的push代表的是压栈,就是将ebp放到栈顶。并且esp的指针也要向低地址移动。此时变成了:
接着我们看下一条指令:
mov是move的缩写,意思是将esp赋值给ebp,实质是将esp里面的地址放到ebp里面。(也就是两个指针指向了同一位置)
这里提一句:此时ebp和esp没有在维护CRT函数了,但他的函数栈帧没有销毁,因为栈空间的使用只能先销毁低地址,再销毁高地址。即图中的从上往下销毁。
我们接下来看第三条指令:
sub是减法的缩写,意思是将esp这个地址减去E4这个16进制数字。E4转化为十进制就是228,那么就是说将esp从高地址向低地址移动228以后存放起来。变成了如下图:
接下来看第4,5,6条指令:
这就是把ebx、esi、edi放到栈顶。esp也会对应着变化。至此一个main函数的栈帧就创建成功了
3.main函数栈帧的初始化
我们往后面看四个指令,lea指的是load efficitve address,即加载有效地址,把后面的地址给edi。
mov就是move的意思,看到第三条和第四条指令,就是把ebp到edi之间的空间全部初始化成为
cc cc cc cc,这些 cc cc cc cc是随机值,被初始化的空间大小为24(16进制)。
dword即double word,就是4个字节。
可以很明显的看到,ebp到edi这一部分被初始化成为了cc cc cc cc(随机值)。
main函数栈帧开辟完了,接下来就是执行代码了。
4.函数的调用
接着往下看,这是一个move指令,意思是把这个地址存放到ecx中去,就是将返回的地址存放起来,等会再回访这个地址执行下一条指令。
接下来就是a和b的初始化。把0A(16进制数)存放到ebp-8的地址中间去,把14(16进制数)存放到epb-14的地址中去。这样子a和b的值就存放到内存中去了。
这里a和b存放的位置取决于编译器,相隔多远也取决于编译器。
综上:局部变量a,b,c的创建就是先创建一个函数栈帧,然后在里面找到
一块空间,再把a,b,c放进去。
紧接着就要进行传参数的操作了。eax、ecx在此之前是没有出现过的寄存器,存放的是
ebp-14h的值,也就是b的值20,接着push一下。ecx也是同理,就变成了这样子(也可以看到函数传参的时候是先传递后面的参数)
至此,我们把调试的f10按钮改成按f11按钮,进入ADD函数的反汇编代码中,我们仍然一步一步的进行解析。我们看到了一个call:
这个call指令是干什么的呢?call指令call了一个地址,这个地址是什么呢?
其实这个地址是执行完ADD函数以后返回来的一个地址,到时候ADD函数销毁以后直接找到这个地址就能继续完成代码。
再次按下f11,反汇编代码就进入了ADD函数的内部,我们一步一步来看:
这个ebp就是main函数的ebp,此时变成了这样子:
经过之前的一系列操作以后,又出现了跟main函数相同的内容:把esp的值赋给ebp,esp向上移动0CCh,再push ebx,esi,edi
这样子ADD函数就开辟好了空间,之后的操作也是跟main函数里面是一样的
接下来进入加法的部分
这里很关键,ebp+8和ebp+0Ch,这两个地方并不是新开创的空间,而是之前a和b传递的值的拷贝,也就是
也就是说传递的参数根本没有进入ADD函数里面去,而只是保存在寄存器里。
把两eax相加以后,再把eax的值放到ebp-8里面(也就是z)。这样子就完成了一次加法。
也可以证明,函数在调用的时候是自右向左传参的(ADD(a,b)先传b)。
同时再次证明了,形参是实参的一份临时拷贝!
5.函数的销毁
我们继续看反编译的代码:
这就是把30这个值放到eax这个全局的寄存器里面去,,然后接着的是三个pop(pop就是在栈顶弹出这个元素,可以理解为销毁):
但是还有很大一块ADD开辟的空间还存在,怎么办呢?
很简单,只要把ebp赋给esp就行了。这时候ebp和esp指向同一个地址,上面的ADD开辟的空间就被销毁了。计算出来的值已经存到eax中了。
紧接着:
pop了ebp,这个ebp是main函数的ebp。main函数的栈顶是很容易找到的,栈底并不好找。于是可以把ebp存在原来main函数里面,先push再pop后ebp回到得地方还是main函数栈底的位置。这样子ebp一下子就回到了栈底的位置。
之后出现了ret指令:
为什么要在栈顶存上一个call指令的下一条指令地址?其实ret指令返回的时候就是返回的call指令的下一条指令地址,那么就直接返回到了这个地址里面。
因为执行完call后,会直接进入到ADD函数中去,等到ADD函数销毁了,程序进行到这一步时,就会自然而然访问地址里面。
再往后就是eax的值存到了c中去
这样子,c的值就被打印出来了。
最后还要把形参销毁,这里销毁的方式比较简单,直接让esp加上8个字节,那么两个形参就不在esp和ebp的范围之内了。
6.问题解答
1.局部变量是怎么创建的?
首先为函数分配好栈帧空间,然后初始化一部分空间为cc cc cc cc,然后给局部变量在栈帧里面分配一点空间,把局部变量的值直接写入到内存中去,这就是局部变量的创建。
2.为什么局部变量的值是随机值?
因为不初始化的时候里面的随机值是我们放进去的。初始化成cc cc cc cc就是随机值。
3.函数是怎么传参的?传参的顺序是怎样的?形参和实参是什么关系?
当我们要调用函数的时候,其实在我们还没有调用函数的时候,我们就已经开始push从右向左开始压栈,压到了内从中,当我们真正进入函数内部的时候,通过指针的偏移量,找回了我们的形参,这就是函数的形参以及他的使用。
形参确实是我们在压栈的时候开辟的空间,他和我们的实参只是值是相同的,空间是独立的,所以形参是实参的一份临时拷贝,改变形参,不会影响实参。
4.函数调用是怎么做的?
开辟出新的内存空间。
5.函数调用是结束后怎么返回的?
在调用函数之前我们就把call函数的下一条指令地址记住了(压进去了),把ebp调用这个函数的上一个函数的栈帧edp存进去了,当我们函数调用完要返回的时候弹出edp就能找到原始上一个函数的edp,然后指针往下走的时候就能找到esp的地址,回到我们的栈帧空间,然后我们记住了call指令下一条指令的地址,当我们往回返的时候就可以跳转到call指令的下一条指令地址,让我们函数调用可以返回。返回值是通过寄存器的方式带回来的。
至此,函数的函数栈帧的创建和销毁就讲解到这里了,真是肝了整整的一天!觉得作者写的还不错的话留下赞和评论再走吧。要是本文对你有所帮助的话也可以动动手指收藏一下呀。博主会持续更新的!