1

前言

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

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

帧栈结构1

栈帧结构2

总结性文字

代码:

#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调试视角:
VC6调试视角

正文

main过程的局部变量

7:        int i = 1;
00401038   mov         dword ptr [ebp-4],1
8:        double d = 8.0;
0040103F   mov         dword ptr [ebp-0Ch],0
00401046   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],0
00401046   mov         dword ptr [ebp-8],40200000h

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

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

状态1

main函数中调用其它函数

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

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

ebp=0019FF30,so ebp-8=0019ff28(注意进制问题,这里是十六进制的减法),棕黄色框框着。而eax寄存器中的值变成了40200000(顺序反了一下,这个涉及大小端问题,请自行谷歌)。这很符合0040104D mov eax,dword ptr [ebp-8]。因此可以推测另一条指令是造成其它改动的元凶:esp寄存器中的值变为了0019FED0,对应第二个黄色的框的地方,且其中保存的值也变化了(在上一张图中也可以看到,它的值是02000000,和现在图里的不一样),并且变成了eax或者说[ebp-8]的值。总结就是push操作会让esp中的值减(所以说栈是沿着地址大小减小的方向生长)一个字的大小(栈指针指向新的栈顶),在这里是4(0X0019FED4-0X0019FD0=4)个字节。然后将数据存入[esp]的空间。
此时我们已经明白了内存中的行为,那么这个被操作的数据有什么意义呢?也就是说ebp-8对应的内存空间的值是什么?回头去看状态一的图,ebp-8对应的19FF28和ebp-0Ch对应的19FF24,组合起来就正好是main函数帧栈中的局部变量d(double d),而ebp-4则是局部变量i(int i)。这两者都是要被传给被调用的函数的参数。也就是说这一步将被传入的参数的值复制了一份放在在main的栈中——虽然放的位置是因为这个位置才刚刚被main函数过程占据的地方(我们认为一开始main的栈帧空间只有ebp-esp)。

当断点继续执行,直到停在00401059 call @ILT+0(functionA) (00401005)时,得到状态二

10:       functionA(i,d);
0040104D   mov         eax,dword ptr [ebp-8]
00401050   push        eax
00401051   mov         ecx,dword ptr [ebp-0Ch]
00401054   push        ecx
00401055   mov         edx,dword ptr [ebp-4]
00401058   push        edx
00401059   call        @ILT+0(functionA) (00401005) //现在开始研究这一行
0040105E   add         esp,0Ch

因为前六行6次push操作,或者理解为原本占据3个字的main函数中的两个变量被复制一份放到栈中,栈扩大了3个字,栈指针esp指向的位置也变成了19FEC8。
状态2

之后执行call @ILT+0(functionA) (00401005),关于call指令,书本上其实有较为详细的描述,我个人总结如下(先看一下文字描述,再看图示):

寄存器eip用来存储当前指令执行完成后应该执行的指令所对应的地址(因为指令归根结底也是数据,显然也需要空间存放)——这个职责和在什么阶段没关系。调用call指令时,将寄存器eip中的值压入栈中(push操作)后算作保存了被调用的过程调用结束了之后要执行的指令的地址,然后将eip寄存器的值改为functionA函数相关指令流的首指令的地址。

图为00401059 call @ILT+0(functionA) (00401005)执行后的状态。注意,如果没有函数调用,那么下一条该执行的指令的地址为0040105E,即0040105E add esp,0Ch。但注意eip寄存器中的值和[esp-4]中的值。

被调用的函数的自我修养

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

首先来解析int fa_i=i前的汇编指令。其中一些指令之前已经介绍过了,所以只是简单备注它们的用途,没有哪个单独展开来讲。

00401090   push        ebp    //将ebp寄存器中的值压入栈中
00401091   mov         ebp,esp    //将ebp=esp
00401093   sub         esp,4Ch    //让esp=ebp-0x4C
//经过上面两行代码,esp和ebp的位置都发生了巨大的移动。我们可以认为此时的ebp与esp之间是functionA的栈帧空间了
00401096   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存入寄存器ecx
004010A1   mov         eax,0CCCCCCCCh //将0xCCCCCCCC存入寄存器eax(带e的寄存器中的e就是extend的意思,这些寄存器都可以存储32bits的数据)
004010A6   rep stos    dword ptr [edi]

004010A6 rep stos dword ptr [edi]比较新。它的动作是将eax寄存器中的值存入edi寄存器中的值(一个地址)指向的内存空间,然后edi的值加一个eax的长度(即4,4个字节),eax中的值-1。反复此动作直至eax中的值为0。注意之前的指令 00401099 lea edi,[ebp-4Ch] //将&(ebp-4Ch)存入寄存器edi。mov a b是让a=b,而lea a b则是让a=&b.使得 edi中存储的值其实是00401093 sub esp,4Ch //让esp=ebp-0x4C执行后esp即栈指针指向的值。那么每次edi+4,就相当于从栈底向帧指针逼近。由于栈增长时是向地址变小方向生长的,不如把栈底到帧这块算作帧空间。那么这一步的不断重复,将整个帧空间里的值都初始化为 0CCCCCCCC。

执行完之后的状态三

状态3

执行到这一步,functionA函数里还有什么事情要做呢?没错,只有给functionA函数里的本地变量赋值了。可是这一步和main函数中的局部变量的赋值为什么会有区别呢?事实上,也确实没有区别。所以这一过程的解读就不再重复了。同样的,而在functionA里面再调用funciontAA的会发生什么,不用我说你应该也知道了吧?

最终得到状态四

状态4

被调用的函数结束了,被调用者如何收尾

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

前三步是和push指令相反的pop,抓住与push相反去理解,应该不必细讲。栈弹出数据,(那块空间的值具体变成了什么其实无关紧要,重要的是:)栈指针esp指向上一个旧数据的位置,即仍保持栈指针指向栈顶。连续三次执行后,栈之前因为三次push压入的数据都弹出去了,相当于栈被清空了。

然后esp=ebp意味着esp和ebp之间的空间没有了,相当于帧空间也没了。emmmm,那岂不是functionA的帧栈空间已经被销毁了么。

最后一条指令004010BF pop ebp //弹出栈顶的值并存到ebp执行时,esp回到ebp指向位置,请看状态四的图,此时弹出的值(ebp指向位置)将会是旧ebp的值,即main函数的帧栈空间的帧指针的值。将其存到ebp后,则ebp指向了main函数的帧空间的帧底(类比之前将的栈一下),不是么?相当于我们从functionA过程的语境中离开,回到了main的语境里。

再然后

004010C0   ret

ret指令和call指令正好相反。当该执行ret指令时,正常情况下栈指针必然指向“返回地址”的存储空间(在本例中004010BF pop ebp //弹出栈顶的值并存到ebp这条指令的执行,导致esp再次+4,指导了状态四图的19FEC4空间,看,里面保存了啥)。执行ret指令时,弹出栈的值并存入eip中。从而成功将00401059 call @ILT+0(functionA) (00401005)执行后该执行的指令的地址存入eip,从而使下一条指令将会回到被调用函数结束后该执行的位置继续执行。

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

嗯,这时候在看看esp,即栈指针,是不是回到了状态二时的栈指针位置?也就是说栈指针和帧指针都回到了main函数的语境下。

调用者调用完后的收尾工作

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

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

值得注意的是0040105E add esp,0Ch使得esp=esp+0xC=esp+12 而12正好是一个int加一个double的空间所用字节数,因此esp又重新回到状态一的栈指针位置。即之前为传入functionA而做的局部变量值复制工作导致的栈增长被还原。于是,调用functionA的所有痕迹现在都看不到了(虽然functionA之前使用的那块空间的值可能还在),我们完全回到了main函数过程的语境下。

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

最终状态状态六
状态六

总结

调用者调用前的工作

调用者的栈被依次压入被调用者所需的参数(第一个参数最先被压入)。

调用者在压入参数后(若没有参数则直接)使用call汇编指令唤醒被调用者开始它的工作。

call汇编指令的工作

将被调用者调用结束后应当执行的指令所在的空间的地址压入栈。

被调用者的准备工作

将ebp中的值——即调用者栈帧空间的帧底的地址——压入栈中保存。

将ebp中的值改为自己的栈帧空间的的帧空间的起始地址——使ebp=esp。这里请注意:esp因为上一条指令的压入旧ebp值,esp保存的地址值又减小了4(一般来说ebp的长度是四个字节)。

如果需要使用参数,被调用者通过ebp+4+4*i来获取“调用者调用前的工作”所准备的参数。其中i代表参数在参数表中的位置(第一个参数则i=1)。其中之所以会有一个+4是因为帧指针+4所指向的空间存储的是返回地址的变量(上一步做的事)。

使esp=esp-x(esp代表栈空间的基地址)从而扩充了帧空间。其中x是编译器根据一定算法得到的被调用者应有的帧空间的大小。算法具体细节此处不做讨论。之后将分配出来的帧空间用0xCC填满(初始化。)

将ebx,esi,edi等由被调用者保存其值的寄存器的值压入栈中。

被调用者完成一切任务后的收尾工作

将edi,esi,ebx等数据从栈中弹出到它们对应的寄存器中。然后将栈指针收回到帧指针处,意味着被调用者的栈帧空间已被销毁(尽管实际上那些数据并没有被覆盖(销毁))。

继续弹出,此时弹出的是旧的ebp值,即调用者的栈帧空间的帧指针的值。弹出的值被赋给ebp寄存器,意味着调用者帧指针复位。

调用ret汇编指令。

ret汇编指令的工作

继续要求栈弹出,此时弹出的是之前保存的被调用者调用结束后要执行的指令所存储的空间的地址。弹出给edi寄存器,从而确保之后执行的命令是调用者完成调用后该做的指令。

注意,一些木马病毒搞破坏就是修改这个空间的值,让其变成一个不正常指令的地址,从而执行不正常指令。

弹出后栈指针指在被调用者最后一个参数所在空间。

调用者的收尾工作

通过esp=esp+x复位esp至调用者栈帧空间的栈指针。其中x由被调用者的参数的个数和类型所决定。因为此时esp距离复位的位置中间只隔着它之前为参数准备的空间了。


阳光号
129 声望5 粉丝