目录
前期学习的困惑
在我们前期学习的时候,可能会有一些困惑,比如:
1、局部变量究竟是怎么创建的?
2、为什么局部变量的值是随机值?
3、函数是怎么传参的,传的顺序又是怎样的?
4、形参和实参究竟是什么关系?
5、函数是怎样调用的,调用后怎么返回?
我们可能只是知道要这么做,却对其原理一知半解,学了今天的内容,你将会有更深层次的认识。
寄存器
要了解函数栈帧,首先得了解寄存器。
寄存器是CPU内部用来存储数据的小型区域,其存储空间小但是运行速度非常快,一般频繁大量使用的数据都被存放在寄存器中。
寄存器有eax,ebx,ecx,edx ,还有ebp,esp。今天这几个寄存器都会见到,重点是ebp和esp。
ebp,esp这两个寄存器中存的是地址,这两个地址是用来维护函数栈帧的。
每一个函数调用,都需要在栈区创建一个空间。
具体场景
以Add函数为例。
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\n", c);
return 0;
}
写一个简单的Add函数,分得足够细,方便我们观察。
在调用main函数时,我们需要在栈区上为它开辟一块空间。称为main函数的栈帧空间。
PS:是当前的main函数
那么这个空间怎么维护呢?是依靠上面说的ebp和esp寄存器来维护的。
注意:调用哪个函数,ebp和esp就跑去维护哪个函数的栈帧。ebp和esp之间的空间就是为当前调用函数所分配的空间。
通常称ebp为栈底指针,esp为栈顶指针。 其实,栈区上是先使用高地址再使用低地址,由高地址向低地址消耗空间,向栈区里放数据时,也是从顶部开始放数据。ebp维护的是栈底,esp维护的是栈顶。
我们来看一下main函数调用堆栈。
可以看到,其实main函数也是被调用的,被__tmainCRTStartup调用,而它又是被mainCRTStartup调用的。
函数栈帧的创建
下面我们来看main函数的细节。
F10调试起来,右击转到反汇编,我们来看详细细节。
这就是我们的汇编代码,或许你看不太明白也没接触过,但没关系,跟着我的思路走,也能看懂。
首先,第一行,push操作,意思是“压栈”,将ebp压到栈顶上。
PS:push(压栈):从栈顶放入一个元素;
pop(出栈):从栈顶删除一个元素。
大概就是这么一个过程。原来ebp指向栈底,现在将它压到栈顶去,同时,esp也就向上移动,再次指向栈顶。
然后第二行:move操作,意思是:将esp里的值放到ebp里面去。
第三行:sub操作,意思是:esp减去0E4H大小的地址。
接下来,进行了三次push操作,注意:push是把东西压到栈顶,push完一次后,esp也要随之指向新的栈顶。
接下来是个关键点!
首先,执行lea操作,“加载有效地址”,将ebp-0E4h的地址放到edi中去; 然后,下面三行的意思是:将从edi开始的39h这么大的空间全部初始化为0ccccccch。
可以看到效果,为main函数开辟的空间里所有内容都被初始化成了0cccccccch。
其实到这里还没有执行一条C语言有关代码,全都是在为main函数开辟空间。
下面到int a才开始执行C语言相关内容。
接下来就是初始化a,b,c了。我们来看看是怎么往栈里面存入值的。
第一句:将ebp-8的位置放入0Ah,0Ah在十进制中也就是10;同理,第二句、第三句也一样,往ebp-14h的位置放14h,往ebp-20h的位置放0 。
所以试想一下,如果我们写代码时没有初始化,那么里面存的东西就是ccccccc了。
接着往下。
这一步其实就是传参,但是因为不熟悉汇编语言,大家可能没感觉。这里是将ebp-14h(也就是b)放入eax, 将ebp-8(也就是a)放入ecx 。再将他们两个压到栈顶。
传参完后,要执行Add函数了,执行完后需要返回这里吧,于是给了条call指令,使得执行完Add函数后能回到这里继续向下执行。
这里在栈顶记录00c21450的地址,返回时就执行call指令的下一条指令,也就是这里的地址位置。
接下来jmp跳到Add函数里面,执行Add函数的内容。
和main函数中一样,先准备Add函数的栈帧空间,过程相似,这里就不赘述了。
直接来看结果图:
到这里大体和main函数栈帧创建一样。
然后Add函数里创建变量z, 将z=0放入ebp-8中。
接着是执行z=x+y,但是我们没有x和y啊,别急,其实我们之前传参的时候已经存了。
ebp+8,ebp+0ch,其实就是从我们ebp的位置往下8个、12个字节处。因为地址是高到地的。
这两个位置就是之前传参的a和b。
将ebp+8(a)传给eax,再加ebp+0ch (b) , 最后将eax传给ebp-8 。
所以实际上,这里的ebp+8就是x,ebp +0ch就是y。
从中也可以看出,传参的时候是从右往左传的,先传的是b,再传a,因为先要将b压栈,再压a。
而且,形参不是Add函数内部创建的,而是回去找了传参的空间。
因此,我们说改变形参的值对实参没有影响。
那么现在z的值是a+b了,要return z,怎么返回呢?怎样才能将z的值带回主函数,我们还是将其放入全局的寄存器eax中。
因为寄存器是不会随着函数执行结束销毁的,所以先将ebp-8(z)放入eax中,再在main函数中调用就行了。
函数栈帧的销毁
返回z以后,我们又要回到main函数,那么esp和ebp是怎么回头去维护main函数的,接着来看:
上面说了,pop是删除(弹出)栈顶的元素,所以这里将栈顶的edi,esi,ebx全部弹出。
然后将ebp的值赋给esp,那么此时esp就不指向栈顶位置了,而是指向此时的ebp。
再将ebp pop弹出,那么ebp就回到了main函数的栈底位置,此时esp和ebp归位,重新维护main函数去了。
到这里,大家再思考一个问题:之前在栈顶存的call指令的下一条指令00c21450是不是Add函数调用完返回后便于esp找到它继续执行main函数用的,这样就整个连起来了,所以汇编语言设计还是很巧妙的。
执行到call指令下面,esp+8是为了销毁形参x,y 释放形参占用的空间
这样,形参什么时候销毁,空间什么时候释放,怎么样释放,大家应该清楚了吧。
后面main函数的销毁也是一样的。