前言

VC6 虽然已经是几乎被淘汰的技术,但是它在调试时允许我们直观地查看寄存器和内存空间中的值和地址,转换为汇编语言后每条指令在内存空间的地址的特性,可以让我们更直观地看到一些操作。

本文希望通过 VC6 更直观一些地看到C语言在为局部变量和函数调用分配内存空间的具体细节,从而验证从书上学到的知识。相关知识主要参考《深入理解计算机系统》(中文第二版)第三章,其中3.1节和3.7节尤为重要。

代码:

#include<stdio.h>void functionA(int, double);int main(){        int i = 1;    double d = 8.0;        functionA(i,d);    short int s = 10;    return 0;}void functionA(int i, double d){    int fa_i = i;    double fa_d=d;}

VC6调试视角:

正文

main过程的局部变量

7:        int i = 1;00401038   mov         dword ptr [ebp-4],18:        double d = 8.0;0040103F   mov         dword ptr [ebp-0Ch],000401046   mov         dword ptr [ebp-8],40200000h

解读:
首先要明确一下这些汇编指令中的取值规则。
ebp:取出ebp中存储的值(一个地址)作为取来用的值。
[ebp]:取出ebp中存储的值(一个地址)后,取出改值(地址)指向的内存空间中存储的值作为用的值。
因此mov dword ptr [ebp-4],1,要求将1存入ebp-4这个地址对应的内存空间。
留意一下此时的寄存器和内存窗口:

执行完00401038 mov dword ptr [ebp-4],1

那么对于

8:        double d = 8.0;0040103F   mov         dword ptr [ebp-0Ch],000401046   mov         dword ptr [ebp-8],40200000h

会发生什么,也就不必多说了。

为了更加直观地查看内存空间的变化,用excel制作了下图,记该图的状态为状态一。

main函数中调用其它函数

10:       functionA(i,d);0040104D   mov         eax,dword ptr [ebp-8]00401050   push        eax00401051   mov         ecx,dword ptr [ebp-0Ch]00401054   push        ecx00401055   mov         edx,dword ptr [ebp-4]00401058   push        edx00401059   call        @ILT+0(functionA) (00401005)0040105E   add         esp,0Ch

解读了前两行,就能解读前六行。
那么前两行做了什么呢?把ebp-8对应的内存空间的值取出,并存入寄存器eax中,然后将eax中的值压入栈。
观察执行后的寄存器以及内存空间值:

结合变化可以发现,push操作将数据压入(向低地址方向)栈中,栈指针指向新的栈顶。
此时我们已经明白了内存中的行为,那么这个被操作的数据有什么意义呢?也就是说ebp-8对应的内存空间的值是什么?其实看完整个六行就知道了,ebp-8ebp-0Ch组合起来就正好是main函数帧栈中的局部变量d(double d),而ebp-4则是局部变量i(int i)。这两者都是要被调用函数的传入的参数。也就是说这一步将被传入的参数的值复制了一份增长在main的栈帧空间的栈中(也就是图3-21的参数u构造区域)。
当断点继续执行,知道停在00401059 call @ILT+0(functionA) (00401005)时,得到状态二:

之后执行call @ILT+0(functionA) (00401005),关于call指令,书本上其实有较为详细的描述,我个人总结如下——寄存器edi用来存储当前指令执行完成后应该执行的指令所对应的地址(因为指令归根结底也是数据,显然也需要空间存放)。调用call指令时,将寄存器eip中的值压入栈中(push操作)后算作保存了函数调用结束了之后要执行的指令的地址,然后将eip寄存器的值改为functionA函数相关指令流的首指令的地址。
结合执行后寄存器和内存空间中的值变化,可能更为直观。

被调用的函数的自我修养

继续执行下去,断点前进,将会进入函数的内部。函数内部在执行int fa_i = i前尚有自己的一些初始化操作:

首先来解析int fa_i=i前的汇编指令

00401090   push        ebp    //将ebp寄存器中的值压入栈中00401091   mov         ebp,esp    //将ebp=esp00401093   sub         esp,4Ch    //让esp=esp-0x4C00401096   push        ebx    //将ebx的值压入栈00401097   push        esi    //将esi的值压入栈00401098   push        edi    //将edi的值压入栈00401099   lea         edi,[ebp-4Ch]    //将&(ebp-4Ch)存入寄存器edi。mov a b是让a=b,而lea a b则是让a=&b.0040109C   mov         ecx,13h    //将常数0x13存入寄存器ecx004010A1   mov         eax,0CCCCCCCCh //将0xCCCCCCCC存入寄存器eax(带e的寄存器中的e就是extend的意思,这些寄存器都可以存储32bits的数据)004010A6   rep stos    dword ptr [edi] //将eax寄存器中的值存入edi寄存器中的值(一个地址)指向的内存空间,然后edi的值加一个eax的长度(即4,4个字节),eax中的值-1。

执行完这么一套东西后,我们得到状态三:

到此,其实对于之后的代码,即functionA中局部变量的赋值,内存空间会如何分配,我们不应该可以推理出来了么?0x0019FEBC会用来存放fa_i,0x0019FEB8和0x0019FEB4会被用来联合存放fa_d,即本地局部变量等以帧指针(ebp)为基准低地址生长,而栈指针被调用者(如本例中的main函数)为被调用过程准备参数,备份返回地址,备份调用结束后回到自己的帧栈空间的帧指针。

状态四:

被调用的函数结束了,调用者如何管理(回收)空间

20:   }004010BA   pop         edi    //从栈中弹出栈顶的值,存到edi寄存器中004010BB   pop         esi    //弹出004010BC   pop         ebx    //弹出004010BD   mov         esp,ebp    //将esp=ebp004010BF   pop         ebp    //弹出栈顶的值并存到ebp

这些指令都不必多说。栈弹出数据后栈指针指向上一个旧数据的位置,即仍保持栈指针指向栈顶。

将esp=ebp意味着esp和ebp之间的空间没有了,即functionA的帧栈空间已经被销毁。
esp回到ebp的位置,请看状态四的图,此时弹出的值将会是旧ebp的值,即main函数的帧栈空间的帧指针的值。将其存到ebp后,帧栈指针指针已经回到main函数的帧栈空间内。

004010C0   ret

ret指令和call指令正好相反。当该执行ret指令时,正常情况下栈指针必然指向“返回地址”的存储空间。执行ret指令时,弹出栈的值并存入eip中。即将返回地址存入eip,从而使下一条指令将会回到被调用函数结束后该执行的位置继续执行。

这一段结束后得到状态五:

这样函数的调用就彻底结束了,回到main函数的指令流了。此时后面待执行的有:

00401059   call        @ILT+0(functionA) (00401005)0040105E   add         esp,0Ch11:12:       short int s = 10;00401061   mov         word ptr [ebp-10h],offset main+45h (00401065)13:14:       return 0;00401067   xor         eax,eax15:   }

值得注意的是0040105E add esp,0Ch使得esp=esp+0xC=esp+12 而12正好是一个int加一个double的空间所用字节数,因此esp又重新回到最开始的栈指针位置。

到此,我们就认为这个函数调用的过程全部结束。帧栈指针全部复位到未调用时的状态。而为functionA所使用的空间中的值虽然没有被重置,但可以预见这块空间再度被使用时,旧值要么被0xCCCCCCCC覆盖,要么被有意义的新值覆盖,等同于已经被销毁了。

最终状态状态六:

本文也到此结束。