前言
和俺一样是c语言小白的同学们在学习过程中是否有过这些疑惑 1.为什么局部变量不初始化时是随机值? 2.为什么说函数形参只是实参的一份临时拷贝? 3.函数形参是怎样创建的? 4.函数调用时是怎样分配内存空间的? 5.函数调用后局部变量会销毁,那么又是如何返回值到主函数的? …
这期内容其实只是加深一个对c语言函数知识的加深理解,并不是主线的内容。但是在俺看来,这些深层次的知识会在使我们对c了解更为透彻,就像上面的那些问题一样我们有必要去了解更多的知识解除这些疑惑,而这期内容就会带着大家拨云见日,相信在认真看完之后大家会更容易接受和理解c语言的知识。
上正题!!!!!!
首先说明一下,函数栈帧的创建和销毁这个过程在不同编译器环境底下的具体操作细节可能会有细微的差距,而编译器的版本越旧它所进行的操作过程就会越直白些,更有利于我们看清这一过程展开分析。俺的编译器是vs2019的,不适合解析过程,所以这篇文章就采用vs2013的操作环境。
要了解栈帧就得先明白什么是栈?
栈(stack)又名堆栈,它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈(本文就统称压栈了),它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈(这里就统称出栈了),它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。 注意:栈是从高地址位向低地址位存储的,并且在删除操作时遵循先进后出,后进先出的原则,如果要出栈中间的一个元素,则只能从栈顶开始逐层向下出栈。
下面就用这个简单一点的代码进行剖析
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);
printf("%d", c);
return 0;
}
对这个代码我们打开反汇编窗口来进行一步步分析,这是我在网上找的vs2013编译器的反汇编页面
先看右侧栏
有ebp、esp等等这些陌生的英文,这些其实代表的是寄存器(简单理解就是集成在cpu上,比内存读取更快存量更小的东东) 而我们这里只要先了解ebp、esp这两种即可。这两个寄存器中存储的是地址,这两个地址是用来维护函数栈帧的。(ebp存的是指向栈底的指针,esp存的是指向栈顶的指针) 再看到左侧一栏的 push、mov等字眼,这些其实是汇编指令。分析反汇编表我们肯定得对这些指令首先要了解清楚,这里俺就写几个常用的多的就不在这赘述了。
mov | mov A ,B 将A的值移动到B |
---|
push | 压栈 | pop | 出栈 | sub | 减法 | add | 加法 | call | 函数调用 | lea | lea A,B 加载有效地址(计算源操作数A的有效地址,并将它存储到目标操作数B中) | – | – |
下面一个一个指令进行观察,当然前提是先看完上面的表噢
我们看这个指令是将ebp这个元素push进去,那么到底执行完有没有真正压栈呢我们打开监视窗口和内存窗口来看一下结果。 push前
push后
我们看到esp指向的地址减了4 因为ebp和esp的地址是随着栈底和 栈顶的地址变化而变化的,所以如果栈顶上压进了一个元素,esp自然会向低地址走一个单位(栈是从高地址位向低地址位拓展的)这也就意味着确实在栈顶push进去了一个ebp的值。
我们也可以观察一下esp指向的内存中的存储情况的变化
push后
这是push后esp所指向的内存中存储的值 我们发现两者是一样的,这是不是也意味着ebp的值的的确确被压到了栈顶呢。 上述操作简单用图解可以表示为
接下来看第二条指令
这里的意思是把esp的值移动到ebp中去。要知道esp指向栈顶,ebp指向栈底,这一步操作就相当于ebp此时也指向了栈顶
我们同样在监视窗口中观察ebp的值的变化
可以看到mov后ebp和esp的值变得一样了。
图解为下
接着看第三条指令
意思是把esp的地址减去0E4h个单位,即把esp指向的位置向上移。
sub前
sub后
可以看到esp的地址确实是变小了
过程图解 后面连着的三个也是push指令这里就不再多说了。
接着看这一块指令
大家看到这一连串的指令千万不要惊慌,先看第一行的指令[ebp-0E4h](这里h的标识是十六进制的意思,不用去纠结)的地址放到edi(细心的小伙伴会发现这里和前面sub指令后得到的地址是一样的所以其实就是esp指向的位置)最重要的其实是最后一句指令 rep stos ,而后面的执行内容dword ptr es:[edi] 的意思是从edi指向的位置向下 操作四个字节(word是两个字节,这里dword是四个字节),操作39h次把到ebp所指位置中间所有元素初始化为0ccccccccch,所以中间的edi、ecx和eax其实只是起到一个中间变量的作用。
这里还得是俺这个天才画手再来给你们画图解释一下
至此我们才为main()函数开辟完了空间,接下来当我们就开始创建局部变量了,我们会看到下面的这些信息。
这里其实就是把赋给a的值放到距离ebp8个单位的内存位置,将赋给b的值放到距离ebp14h个单位的位置,将赋给c的值放到距离ebp20h个单位的位置。8,14h,20h这些给变量预留存放值的位置是根据编译器而定的(不同编译器可能有差距)。
然后看接下来的指令
前四步的操作其实就是将刚刚b和a的值先后压到栈顶。(那么这里为什么要先将b和a的值压栈呢?相信有小伙伴已经猜到了其实这一步就是在创建形参,进行了传参的操作,慢慢看下去。)
第五步终于走到call指令开始调用了Add函数,但是大家对这一步运行进行调试会发现call指令执行的第一步是将它的下一条指令add的地址压到栈顶然后才开始执行调用函数的内容( ?)。走进Add函数时会出现一条push ebp的指令,就是将main函数的栈底地址压到栈顶,(这一步操作其实是为了后面调用完函数释放空间后能回到main函数)后面也要进行类似于main函数中开辟栈帧的操作这里也不再过多赘述。
我们快进到z=x+y这条程序
我们发现这里其实是找回了刚才call指令前对b和a里的值压栈的两个空间,然后将这两个元素的值相加存到z变量中再压到Add函数的栈帧中。 就是说我们并没有实际意义上地再Add函数内创建形参,而是在调用函数前的传参过程中就将实参b和a的值先后压栈进行使用的(也可以理解为我们所认为的形参创建过程也是在main函数中完成的),所以这也就解释了为什么说形参是实参的一份临时拷贝,改变了形参对实参不会产生任何的影响。
接着我们继续走下去观察一下操作返回值的指令
我们发现这里并没有将返回值存入Add函数的局部变量 z当中,而是将返回值存到了寄存器eax中去(寄存器中的内容可以保存的,在后面回到main函数时我们再从这个寄存器中取出来就好)。这也就恰好解释了我们上面的疑问,为什么返回值能传回到main函数中去。
已经返回值了那么这个函数就调用完了,既然调用完了我们就要回收这个这个调用函数的栈帧空间了。我们看下面这个指令
先把esp所指向的地址改成了ebp所指向的地址,原本esp是指着Add函数栈顶的,ebp是指着Add函数栈底的,这样一来Add函数的栈顶和栈底在同一个位置,也就相当于它的栈帧空间被回收了。是不是很巧妙呢
再再再看这个(xdm再坚持一下,马上就看完了,其实我也不想再码了)
这里将Add函数的ebp所指向的元素(这个是刚刚在调用Add函数前将main函数的ebp压栈到这的吗,为了方便在回收栈帧后返回main函数时能找回main函数ebp的位置,使得esp和ebp重新维护main函数的栈帧空间)出栈弹出到ebp寄存器中去,于是此时ebp中指向的位置就变成了main函数的ebp,即回到了main函数中去。当然此时esp所指向的位置既然被弹出了,esp就继续向高地址走一个单位正好重新指向了main函数的栈顶。
既然Add函数已经调用完并回收了,我们就该回到main函数中继续执行下一条语句,看这个指令
这条ret 指令的作用就是将栈顶的元素弹出并返回到这个地址的位置。此时聪明的我想起来了刚刚在main函数栈顶是不是压入了一个call指令下一条指令的地址,这ret 指令蹦这么一下,是不是就能找回来刚刚main函数下一条指令的位置,从那个位置继续执行。有没有发现这个神操作和刚刚找回main函数栈底的操作思想惊人的相似。
至此,整个函数栈帧的创建与销毁的过程就完美的进行完了。 今天就先到这里了,没了我的家人们。 俺c语言小白一枚,如内容有误欢迎评论区大佬留言指正哈。
|