文章目录
前言:
我们在初学C语言的时候可能会有不少的疑问,例如:
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用是怎么做?
- 函数调用结束后怎么返回的?
下面,博主会带领大家一起通过了解函数栈帧,来一一回答上面的问题。
注:博主此次用的编译环境:VS2013-X86-Debug
在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。不要使用太高级的编译器,越高级的编译器,越不容易学习和观察。
1.寄存器
要了解函数栈帧的创建,那必然离不开寄存器
寄存器是中央处理器内的组成部分。 寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。 在中央处理器的控制部件中,包含的寄存器有指令寄存器 (IR)和程序计数器 (PC)。
本文不必过多深入了解寄存器,只要知道寄存器集成在CPU之中以及通用寄存器即可
寄存器名称 | 寄存器的功能 |
---|---|
eax | (针对操作数和结果数据的)累加器,返回函数结果 |
ebx | (常存放存储器地址)基址寄存器 |
ecx | (字符串和循环操作数)计数器 |
edx | (存放数据)数据寄存器 |
ebp | (表示堆栈区域中的基地址)基址指针寄存器 |
esp | (指示堆栈区域的栈顶地址)堆栈指针寄存器 |
esi | (常保存存储单元地址)源变址寄存器 |
edi | (常保存存储单元地址)目的变址寄存器 |
本篇重点在于要掌握ebp和esp 两个寄存器,这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。
2.函数栈帧
2.1什么是栈帧?
栈帧也叫过程活动记录,是编译器用来实现函数调用过程的一种数据结构。
C语言中,每个栈帧对应着一个未运行完的函数。
每一个函数调用,都要在栈区创建一个空间,我们称为帧,所有这个函数里面的内部变量都保存在这个帧里面。
所有的帧都存放在Stack,由于帧是一层层叠加的,所以Stack叫做栈。生成新的帧,叫做“入栈”,英文是push;栈的回收叫做“出栈”,英文是 pop。
栈的特点是,最晚入栈的帧最早出栈。
寄存器ebp指向当前的栈帧的底部(高地址),寄存器esp指向当前的栈帧的顶部(低地址)。
2.2函数栈帧的创建和销毁
下面的代码是博主对于本次了解函数栈帧的实验对象
#include<stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
return 0;
}
2.2.1 观察main函数的函数栈帧的准备
对于main函数的函数栈帧并不是直接就创立的,而是建立在编译器的隐藏条件上的,我们进入调试打开调用堆栈
从这个调用堆栈里看出main函数也是被调用了的,那么它是被谁调用了呢?
我们往上面翻,最后发现,他是被__tmainCRTStartup()函数调用了,而__tmainCRTStartup()又被mainCRTStartup 函数调用。
在VS2013中,main函数也是被其他函数调用的。
也就是说在main()函数的栈帧被创建出来之前,编译器就已经创建好了__tmainCRTStartup()的函数栈帧,
然后在这个基础上,在来创建我们的main()函数的函数栈帧
让我们进入代码界面感受一下吧,首先进入调试界面,然后在调试开始处的箭头位置不要动,单击鼠标右键,点击转到反汇编
然后就可以看到这个反汇编语言这个界面:
然后在这个界面再次单击右键,如果显示符号名前面被勾选,那就取消它
因为如果用符号名,不利于我们理解观察
下面是取消符号名和未取消符号名的对比:
我们可以发现,取消符号名之后,我们对于编译器的执行观察得更加清楚明白,看到它是如何将值存在栈里面的,存在栈里哪个位置的,都是一目了然。
2.2.2 main函数的函数栈帧的创建
刚开始的时候,ebp和esp是 __tmainCRTStartup()函数的函数栈帧里,对 __tmainCRTStartup()的函数栈帧进行维护,但是经过图1的三步走之后,ebp和esp 跑到 __tmainCRTStartup()函数的函数栈帧上面去开辟了main函数的函数栈帧,并对main函数进行维护。
最直观的表现就是他们的地址:
打开监视和内存,让我们直观感受三步走之后的变化。
这是未变化之前的地址:
ebp:
esp:
走完第一步压栈后:
我们可以对比前后发现,此时的esp的地址大小减少了4个字节,然后压栈之后,此时 esp存放的值是ebp 的地址
注意:栈的使用习惯是先用高地址,后用低地址,所以这里的存放ebp的地址的时候,是倒着存储的。
走完第二步mov后
mov ebp,esp
//就是将esp的值存放在ebp里面
这里大家可能多少看的有点懵,我们还是在内存和监视里面一起来直观感受下。
此时的ebp:
mov之后:
ebp:
ebp的地址发生了变化,它的地址比之前小,可见它在栈中在向前移,因为是将esp的值mov给ebp,所以ebp这个栈底指针来到了esp这个栈顶指针的位置,那么,既然ebp顶替了esp,那么esp呢?
别急,马上就知道了。
最后一步:sub开辟main空间
sub esp,0E4h
//sub 就是减法的意思,用esp - 0E4h
这里的esp的地址减去0E4h,这就解释了上面esp的去处,那么esp与ebp之间的空间就是 main函数的函数栈帧
此时的esp:
示意图:
然后接下来又是三次push压栈:
又给栈顶压入三个元素,这三个元素我们暂不作讨论,就知道它压入进去就行了
2.2.3局部变量随机值
压入,三个元素之后,esp又往上走了3步,然后我们再进入到下一步:
lea -> load effective address//加载有效地址
就是将 [ebp+FFFFFF1Ch]存到edi里面
我们就这样是看不出来它到底加上了多少,但是我们在将它变成在显示符号的情况下,我们就能看到了
它是一个八进制数:
这样我们就清楚edi里面存放的多少
mov ecx,39h
// 39h->0x00000039
//ecx是计数器寄存器,这里存放0x00000039次
mov eax,0CCCCCCCCh
//将0CCCCCCCCh 存放到累加寄存器eax里
rep stos dword ptr es:[edi]
//意思是从ebp-0E4h及其以下,全部转换成CCCCCCCC
这也就解释了为什么局部变量在没有初始化的前提下是随机值的问题。
2.2.4局部变量的创建
[ebp-8] //a
[ebp-14h] //b
[ebp-20h] //c
这里就很明显的诠释了栈区的使用规则,是先用高地址,再用低地址
局部变量的创建,就是建立在函数栈帧的前提下,再寻找合适的空间将其放进去。
2.2.5函数传参和函数调用
这里是先存放b的值将它的值放入eax(y)里面,然后eax压栈压入进去,再将a的值放入ecx里面,然后压栈,将ecx(x)压入进去。
这里这个过程就叫做传参。
传参传完之后,来到了
call 003B10E1
这条指令,这里的003B10E1 就是call指令的下一个指令的地址,
call指令这里可以正式进入到Add函数里面去,再进去之前,会先将003B10E1压栈,这里有什么用一会我们再来讨论。
Add函数的函数栈帧的创建
这里的Add 的函数创建和main函数一样
先将ebp的地址压入栈中,然后将esp的地址传给ebp,
再用esp的地址减去0CCh 从而得到Add函数的函数栈帧,
然后再次压入ebx,esi,edi。
然后再将[ebp-0CCh]传给edi
将33h存入ecx
eax中存放,0CCCCCCCCh
rep stos dword ptr es:[edi]
给Add函数自[ebp-0CCh]起及其以下,全部填入CCCCCCCC
mov dword ptr [ebp-8],0//创建局部变量z
终于来到了Add 函数的核心z=x+y;
这里它的函数里面似乎并没有直接接受想,传参,那么它是如何调用,函数的呢?
mov eax,dword ptr [ebp+8]
add eax,dword ptr [ebp+0Ch]
//这里将x和y的值都存放到eax里面,在eax里面完成加法运算。
从这里也可以看出,我们在创建这个函数的时候,根本没有创建形参
形参是在我们刚开始调用这个函数的时候就已经创建好了
我们提前将 a 和 b 拷贝了一份放在 ecx 和 eax 里的,后面即便我们对 ecx 和 eax 做出修改,也不会影响到原来的 a 和 b 。
从这里也可以看出,实参传给形参时,形参只是实参的一份临时拷贝。
mov dword ptr [ebp-8],eax //把算术结果传给z
mov eax,dword ptr [ebp-8]//将[ebp-8]存放到寄存器eax里。
存放到寄存器里面,即便空间被销毁,数据也不会丢失
2.2.6函数栈帧的销毁
Add函数的函数栈帧的销毁
这里的三次pop,就是三次释放空间,
第一次:
第二次pop:
第三次pop:
执行到了这里的时候,怎么释放Add函数的函数栈帧呢?
这时候,编译器将 ebp 的值赋给了 esp,让esp 直接来到
Add函数的栈底,直接释放了Add函数的空间
最后pop掉 原先压栈压进来的ebp , 让 ebp 直接返回到main函数的栈底。
结束掉了Add函数的生命
那么结束掉了 Add函数的生命之后,main 函数还要继续运行啊,怎么办呢,这时候,我们之前压入的 call 指令的下一个指令的地址,就起到作用了
在pop完了之后,指令
利用上面的地址,直接让函数回到了之前运行的地方。
这里这条语句释放掉了形参
最后这里的 eax 寄存器里面的值传给了c
总结
局部变量是怎么创建的?
在函数栈帧被分配一定空间后,在函数栈帧里面分配一定的空间存放局部变量
为什么局部变量的值是随机值?
在函数的函数栈帧分配完之后,函数栈帧的所有空间会被赋值有意义的地址,在划分完地址之后,前面的文章里说过,会被赋值CC CC CC CC这样的值
函数是怎么传参的?传参的顺序是怎样的?
函数传参是在原来的主函数上面压栈,压入空间,来存放形参,传参顺序满足栈的先进后出规律,如前文提到的实参a
和实参b,因为实参a先被存入函数栈帧中,所以后传参。
形参和实参是什么关系?
形参是实参的一份临时拷贝
函数调用是怎么做?
在调用函数之前,编译器汇通过call 指令向栈中压入一个地址,这个地址是函数调用指令的下一条指令,在创建好地址之后,开辟需要调用的函数的函数栈帧,再在函数栈帧里面通过寄存器来完成一系列计算存储操作,从而完成函数调用。
函数调用结束后怎么返回的?
详情请看 2.2.6 函数栈帧的销毁