前言:
本篇讲解函数栈帧的创建与销毁,让你深入了解C语言代码执行的时候,栈上是怎么样操作的。
在你学习C语言的时候,你可能会想到这样的问题:
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
等我们分析完函数栈帧的创建与销毁,就可以解决这些问题,也可以为往后的学习有一个更深刻的了解了。
一、预备知识
今天我们使用的编译器是vs2013 ,其他编译器如vs2019 也都存在这样的原理,但为了解释更清楚我们使用vs2013 进行研究,来越高版本,越高级的编译器,越不容易去学习和观察底层的东西。
同时,在不同的编译器下,函数调用过程中堆栈的创建也略有差别,具体细节取决于编译器的实现。(大体一致,略有差异)
然后是几个概念:
1.寄存器 如eax,ebx,ecx,edx ,还有我们今天观察中十分重要的ebp,esp 。要理解函数栈帧,就要了解这两个寄存器。esp,ebp 这两个寄存器中存放的是地址,这两个地址是原来维护函数栈帧的。
2.每一个函数调用,都要在栈区上创建一个空间。
在图中,我们把ebp 称为栈顶指针,维护的是栈顶顶部,把esp 称为栈底指针,维护的是栈底底部,而再使用新空间的时候就是在顶部放东西。栈区的使用习惯是先使用高地址再使用低地址。
今天我们研究的是一个简单的代码:
把两个数加起来的函数:
二、调用堆栈
“调用堆栈” 窗口可以查看当前堆栈上的函数或过程调用。 “调用堆栈” 窗口显示每个函数的名称以及编写它所用的编程语言。
那我们打开vs2013 ,进入编译器,将代码敲好。
在这一串代码中,先进入的是main函数,那么main函数也被调用了,被谁调用了呢?我们可以点击F10 调试起来,再点击调试窗口调用堆栈查看。
然后我们可以看到main函数在调用堆栈中出现,说明main函数是被调用了:
那main函数是被谁调用了呢,我们继续F10 调试起来:
我们可以看到出现了这两个函数,mainCRTStartup 和__tmainCRTStartup ,然后我们可以在左边的crtexe.c 中往上观察可以看见,main函数 其实是被__tmainCRTStartup 调用的,而__tmainCRTStartup 又是被mainCRTStartup 调用的。
这里我们就清楚了其实main函数也是被别人调用的,而且过程中的函数可以用调用堆栈来观察。
所以大概的分配空间就是这样子的:
PS:当调用Add函数的时候,esp 和ebp 应该维护Add函数的函数栈帧:
那么接下来就进入到我们研究是如何调用函数以及函数栈帧的创建与销毁。
三、进入main函数
同样是相加这份代码:
F10进入函数,右击进入反汇编(C语言所对应的汇编代码):
进入反汇编之后,我们可以看见函数调用的详细过程,当然这里要记得把显示符号名给取消掉,这样子我们观察到的就是如ebp-8 这种地址名,更容易我们的观察与理解。
然后我们开始理解,首先在进入main函数之前,是__tmainCRTStartup 调用了main函数,所以进入main函数之前,esp和ebp 所维护的函数栈帧是__tmainCRTStartup 的。
然后我们进入main函数中依次执行汇编代码:
1.首先是push压栈操作,将在栈顶上放置了应该ebp,同时esp寄存器往上移动,指向ebp上部。
push 压栈:给栈顶放上一个元素。 pop 出栈:从栈顶删除一个元素。
如果想观察esp是否真的移动到了上面,我们也可以通过观察esp的地址是否发生了变化,点击上方调试——窗口——监视——监视1,然后输入esp,ebp,然后F10调试观察变化。
刚好是向低地址移动了4个字节。所以esp 的确是往上移动了4个字节。同时你也可以在内存中查看,看看这个地址上的值是不是ebp 的值,是否把ebp 压进去了。
2.然后进行的是mov操作,就是把, 后的值赋给前面的。同时ebp值变成与esp一样,所以esp和ebp指向同一个地方且值相同。
3.然后是sub操作,就是减去,前面的减去后面的,这时候esp 就要往上走了,因为上面是低地址。
这时候我们惊奇的发现,esp 和ebp 有了新的维护空间,没错,就是我们进入的main函数,所以紫色的就是我们main函数的函数栈帧!而现在esp 和ebp 指向位置的值之差就是main函数所申请的内存。
4.然后接下来继续push操作,压栈压进去了ebx ,esi ,edi ,同时esp往低地址移动。
5.然后这几步其实是起到一个整体作用,就是将从ebp-4E0h处连续39h次将double word 改成0CCCCCCCCh, 其实这就是初始化了main函数里面的内存数值!
进行完以上的操作之后,为main函数栈帧的开辟已经准备完了。再往下执行的时候才是c语言代码。
6.然后是给a赋值为10,就是把10放到了ebp-8 的位置,这就是为a开辟的空间。如果不赋值,那么打印的就是初始的值,也就是我们有时候会打印出来的烫烫烫烫烫cccc,不同的编译器可能是其他的随机值。
这里假设一格就是4个字节,假设!
7.接下来两步同理是在创建main函数栈帧的基础上,在main函数栈帧的空间中找到一处空间,给bc的赋值,开辟空间。
8.然后就是调用Add函数,给Add函数传参,将ebp-14 的值就是把b的值20赋给eax ,然后下面就是push 压栈,把eax 放到最上面。同时esp往上移动。同理操作ecx ,将ebx-8 的值就是把a的值10赋给ecx ,然后压栈。
这两步,就是函数传参!
9.执行到下面这一步的时候,就到call 指令,call指令是调用的意思点F11进入,但进入之前先注意一下call 指令的下一个地址00261A30,esp 上面的一个地址是全0,然后点击进入后变成了00261a30(这里内存要从右往左看)。发现把call指令的下一条指令的地址,放到了栈顶上!为什么要放呢?往下看:
点击F11之前:
点击F11之后:
在这里放下call指令的下一条指令的地址,是因为call指令进去调用函数,当调用函数执行完之后,仍然要回来执行main函数中剩下的,那怎么回来呢,就记录下call指令的下一条指令,当调用完之后就会找到这个地址,然后继续执行。
再次按F11,真正进入Add函数。
四、进入Add函数
进入Add函数后看见的这些操作是不是似曾相识,我们来挨个梳理下去。
1.push 压栈,这里把main函数的ebp给放到了上面,注意是main函数的ebp,然后esp往上挪。
2.然后把esp的值赋给ebp,所以ebp就又移动上来了。
3.然后又是esp减0CCh,将esp往上(低地址)移动0CCh个单位。这就是为Add函数开辟的函数栈帧。
4.然后就是push三次,还是把ebx ,esi ,edi 压栈。
5.然后的4步合起来就是初始化函数栈帧,把ebp-0CCh连续33h次将double word改为0CCCCCCCCh,就是初始化Add函数内存中的值。
6.接着是为z变量申请空间,ebp-8 的空间上存的就是z的值了。
7.然后接下来就是真正的加法了,那我们传参放到哪里了呢,这就可以看到之前我们把他放进的eax 和ecx 了,当进行加法的时候就找到他们两个进行操作。
找到传进来的值的空间就是ebp+8处,还有ebp+12处进行操作。
所以我们发现:
1. 在我们调用函数的时候,进行函数传参的时候其实是将传进去的值用一个寄存器存储起来,进行压栈操作,然后使用的时候会找到这个值。 2. 而且在进行传参的时候,先压栈的是b,然后才是a,说明传参的方向是从又往左传进去的。 3. 形参并不是在调用的函数内部创建的,而是找回刚刚压栈处的空间处的值。所以说形参是实参的一份临时拷贝!所以这也是为什么我们在改变这个形参的值时并没有影响到实参。
8.然后就是return z 的操作,函数在进行调用后,那返回的时候,函数已经销毁了,那怎么得到返回值呢,答案是又把值寄存到了eax 里面,放到这里就安全了
9.然后就是我们在上面就说过的pop 出栈,就是将其元素在栈上弹出。这里就是把edi ,esi 和ebx 都弹出了。
这不就是开始返回了吗?所以进入到我们的返回过程。
五、返回过程
1.mov操作,将ebp的值赋给esp,所以esp也接着移动到ebp处。
2.然后紧接着把进行pop 出栈 操作,把ebp 给弹出去了,我们的之前存的ebp-main给弹出去了,这时候,ebp 就要回到我们的最开始的位置。随着函数的销毁,栈顶是很容易找到的,但栈底不容易找,所以我们在调用函数之前先把栈底存上到这里,然后销毁的时候弹出,就会回到一开始的位置找到我们的栈底。
这时候就已经退出Add函数了,然后我们可以观察到esp和ebp又开始维护的是main函数部分的空间了。
3.然后是ret指令 ,这其实就是弹出了之前我们存的call指令 下一条指令的地址,然后我们跳回去的时候,刚好可以从调用函数的下一条指令进行执行。弹出后esp 也往下了一格。
又回来了。
4.然后进行add操作,esp加8 ,esp 回到edi 处。
5.然后终于将eax的值赋给ebp-20就是C的空间处,这样子就算是把值传回来了。
6.然后接下来也是与Add函数类似的操作,将函数销毁等操作,就不过多重复操作了。
六、解决问题
在我们了解了函数栈帧的创建与销毁的过程之后,我们就可以解答之前我们的问题啦:
-
局部变量是怎么创建的?
先创建好函数的空间,然后在函数的空间中给局部变量分配一定的空间,并将值赋给这个空间。
-
为什么局部变量的值是随机值?
局部变量中的值是我们自己放进去的,如果没有放进值的话,就是函数初始化的随机值,就好像我们上面的0CCCCCCCCh一样,如果放进去值,就可以把随机值给覆盖了。
-
函数是怎么传参的?传参的顺序是怎样的?
当还为调用函数的时候,我们已经将参数从右向左push 压栈压进去了 ,然后当调用函数的时候,通过指针变量,就可以找回我们的传参。
-
形参和实参是什么关系?
形参确实是我们在压栈时开辟的空间,和实参的值是相同的,空间是独立的,所以形参是实参的一份临时拷贝。改变形参不会影响实参。
-
函数调用是怎么做的?
call指令进入函数,在函数内完成一系列操作后返回值存在寄存器上,然后自身销毁。
-
函数调用结束后是怎么返回的?
在调用之前就把调用指令的下一条指令的地址给记住了,通过压栈压进去了,当我们要返回的时候,就弹出我们ebp,跳转回到我们原来函数中下一条指令的地址处。返回值就是通过我们的寄存器返回得到的。
好啦,本篇的内容就到这里,小白制作不易,有错的地方还请xdm指正,互相关注,共同进步。
还有一件事:
|