通过VC6直观地看一看C语言的栈管理

9次阅读

共计 3361 个字符,预计需要花费 9 分钟才能阅读完成。

前言

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],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 制作了下图,记该图的状态为状态一。

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 中的值压入栈。
观察执行后的寄存器以及内存空间值:

结合变化可以发现,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=esp
00401093   sub         esp,4Ch    // 让 esp=esp-0x4C
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] // 将 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=ebp
004010BF   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,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 所使用的空间中的值虽然没有被重置,但可以预见这块空间再度被使用时,旧值要么被 0xCCCCCCCC 覆盖,要么被有意义的新值覆盖,等同于已经被销毁了。

最终状态状态六:

本文也到此结束。

正文完
 0