解析C++汇编代码
局部变量空间分配及程序栈操作
刘强
2005年8月18日
[前言]对于一个资深程序员来说,了解我们的程序最底层的运行机制是很重要的。特别是对于C/C++程序员来说,这点显得尤为突出。在很多情况下,知道其底层运行机制对我们理解更深层次的东西是非常有帮助的。比如说吧,如果你对这些的底层运行机制比较熟悉,那么可能在COM编程中,你会更容易理解它的虚拟表(vtbl)技术,或者WINDOWS编程中经常涉及的THUNK机制。
其实在不同的C/C++编译器中,由同样的C++代码编译成的(机器)汇编代码是不同的。在本文中,主要讨论Microsoft Visual C++ .Net编译器生成的机器代码。笔者作过比较,Visual C++不同版本生成的机器代码没有什么大的区别。这些在讨论后面的实现中会有详细论述。
1. 代码与汇编码
C++
下面我要给出一个全局函数代码。为了简单起见,代码比较简单。
void InitFun(Function * pFun)
{
pFun->SetAge(22);
pFun->SetName("Tcliuqiang");
}
Function是我们定义的一个类,这个函数的功能是接受一个Function类型对象的指针以对该对象进行初始化。SetAge和SetName是Function中定义的两个函数,分别设置Function中定义的age和name属性。下面给出Visual C++编译之后的汇编代码:
;//{{ 前期工作:设置基址指针,为局部变量分配内存
@01: push ebp
@02: mov ebp,esp
@03: sub esp,0C0h
;//{{ 保存三个常用辅助寄存器原始信息以用于本函数
@04: push ebx
@05: push esi
@06: push edi
;//{{ (以0CCCCCCCCH值)初始化已分配的局部变量内存空间
@07: lea edi,[ebp-0C0h]
@08: mov ecx,30h
@09: mov eax,0CCCCCCCCh
@0A: rep stos dword ptr [edi]
;//{{ :函数主体:执行函数逻辑
@0B: push 16h
@0C: mov ecx,dword ptr [pFun] ;//{{ [ebp+8] 从栈中取得所参数 }}
@0D: call Function::SetAge (419F9Bh)
@0E: push offset string "Tcliuqiang" (44E0C8h)
@0F: mov ecx,dword ptr [pFun] ;//{{ [ebp+8] 从栈中取得所参数 }}
@10: call Function::SetName (419C8Ah)
;//{{ 恢复三个常用辅助寄存器原始信息
@11: pop edi
@12: pop esi
@13: pop ebx
;//{{ 检测程序是否发生过异常,只在Visual C++调试版本中才会有这样的指令
@14: add esp,0C0h
@15: cmp ebp,esp
@16: call @ILT+3240(__RTC_CheckEsp) (419CADh)
;//{{ 释放局部变量空间,恢复上级程序执行现场
@17: mov esp,ebp
@18: pop ebp
@19: ret
应该说我们给出的代码市比较简单的。但是从上面可以看到编译后的机器代码却有一大段。
2.解析C++汇编代码
首先,我们进入函数体,就要执行三条初试化指令:
@01: push ebp
@02: mov ebp,esp
@03: sub esp,0C0h
我们知道,ebp寄存器在Visual C++中是被默认用来做基址指针的。因此,在刚进入函数执行阶段,都要对ebp进行相应的操作。第一步,如@1语句ebp中的值,然后将它用在本函数中。第二步,获取当前堆栈指针。获得的堆栈指针将作为函数局部变量的基址指针使用。最要引起我们注意的是第三条语句@3,因为大家知道,在C/C++中,程序局部变量是在堆栈中分配的。可是,我们并没有在每个函数中发现诸如AllocMem等申请内存的函数或指令。实际上,函数中的局部变量空间的分配就是由这条指令完成的。在本例中,程序分配了0C0H(192)字节的空间供该子函数使用。至于为什么要分配192字节,我们后面再讨论。所示,先保存当前
其次,是辅助寄存器ebx,edi,esi的状态保存。作为通用寄存器,它们经常被用在一些常见的操作中。特别是在字符串、数组等的操作中,edi、esi通常作为存储目的、源数据的地址指针来使用。因此这里先保存这三个寄存器的值。虽然在本例中,并没有用到ebx和esi,但两者还是被保存了。
第三,变量的初始化。我们可以看到,程序使用了四条指令来初始化局部变量空间:
@07: lea edi,[ebp-0C0h]
@08: mov ecx,30h
@09: mov eax,0CCCCCCCCh
@0A: rep stos dword ptr [edi]
如@07所示,程序获得局部变量空间的起始地址(低地址)并将它送入edi寄存器,设置ecx寄存器为变量空间长度30H(DWORD型48双字长度,也即192字节),eax为0CCCCCCCCh值,然后通过循环指令rep stos将eax中的值存入以edi为起始地址的192字节的地址空间内。这里我们可以发现一个问题就是在Visual C++中,局部变量一律以0CCCCCCCCh来填充。这就不怪我们在程序调试时经常出现的数据就是“0CCCCCCCC”了。
第四,就是函数调用了。在前面给出的C++源代码中,我们有两个调用类成员函数的语句:
pFun->SetAge(22);
pFun->SetName("Tcliuqiang");
在谈到类对象方法的调用上,我们有必要讨论它的具体存储方式。在C++中,类对象的成员数据和类方法是分立存储的。这样,对同一种类的多个对象,可以共享相同的代码。如下图所示,三个Function对象共享相同的类方法代码。
SetAge(int)
SetName(char *)
GetAge()
GetName()
……
|
Age=20
Name=“Tcliuqiang,刘强”
|
Age=21
Name=”Cambest”
|
Age=22
Name=“cambest@sohu.com”
|
这就为我们引入了一个新的问题:当我们调用某个方法时,类方法代码如何知道应该对那个对象数据进行操作?在C++代码级别,这个问题是不存在的。因为在调用对象方法时是要加上对象限定符的,如上面的pFun->SetAge或objFun.SetAge等。但是在汇编(机器)码级别是怎样处理的呢?在Visual C++中,对象数据指针地址是用ecx寄存器来传递的。也就是说,在类方法中,对对象数据的访问,是使用ecx寄存器中的数值来作为对象基址指针对对象数据进行存取等操作的。实际上,这个ecx就是你在实现类成员方法时在其中使用的this指针。所以,你看到的汇编实现是这样的:
@0B: push 16h
@0C: mov ecx,dword ptr [pFun]
@0D: call Function::SetAge (419F9Bh)
首先将常数16h(22)推入堆栈,将pFun所指对象的地址值传入ecx寄存器,然后调用类成员方法Function::SetAge。
@0E: push offset string "Tcliuqiang" (44E0C8h)
@0F: mov ecx,dword ptr [pFun]
@10: call Function::SetName (419C8Ah)
后面的实现也是如此。
第五,退出函数体时的恢复工作。首先时恢复前面提到的三个常用的辅助寄存器:ebx,edi,esi。三条指令完成这步操作:
@11: pop edi
@12: pop esi
@13: pop ebx
最后是释放局部变量空间,恢复现场。就是让程序在跳出子函数后,不会觉得有什么被改变了。因为本例是调试版本,所以还有检测是否正确执行的代码(@14,@15,@16):
;//{{ 检测ebp,esp是否相同,如不同则说明在运行时出现异常
@14: add esp,0C0h
@15: cmp ebp,esp
@16: call @ILT+3240(__RTC_CheckEsp) (419CADh)
;//}} 非调试版本不会有这样的指令
@17: mov esp,ebp
@18: pop ebp
@19: ret
在进入函数体时,程序就将当前的堆栈指针传入ebp寄存器;在程序执行的过程当中,不改变ebp中的值。在退出函数体时,再将ebp中的值恢复到esp寄存器,通过这样的方式来实现恢复程序现场。
3.局部变量空间分配及栈操作
在前面我们有谈到局部变量空间分配的问题。在本例中,InitFun函数内没有定义任何局部变量,但是也分配了0C0H字节的空间。其实在所有的Visual C++函数中,编译器都要分配0C0H的保留空间。如果定义了局部变量,则在0C0H的基础上再加。如:
int Add(int s1,int s2){
int s3=s1+s2;
return s3;
}
该函数内定义了一个INT型的变量,编译器为它分配了额外的0CH字节内存空间。 Visual C++为每个变量分配多于它本身需要的内存空间。
堆栈操作在C++语言中是占到了很大的比重的,C++语言从某种程度上来说也是基于堆栈的语言,因为它其中的好多操作都是基于堆栈的。特别是从面向对象的角度来看,一般在我们的整个程序中,全局变量所占的比例是很小的,其它绝大多数的变量(包括如INT等基本数据类型、自定义类型)都是局部变量。这些局部变量的分配和释放都是通过堆栈操作来完成的。
因为在C++语言当中,程序栈是向下生长的,即在堆栈空间内,变量是从高地址向低地址方向依次分配的。所以,我们在前面的看到的局部变量内存分配是通过sub指令完成的,而不是add指令。因此,像下面的指令,
@03: sub esp,0C0h
为函数分配0C0H字节的局部变量空间。在退出函数体时,也可以通过这样的指令来释放局部变量空间:
@14: add esp,0C0h
但是,在函数体内,可能会由于某些原因,push/pop等堆栈操作指令可能不成对,或者其他指令改变了esp值,会使得这条指令不能恢复进入函数体时esp的值。这种情况多发生在不同DLL版本的访问方式上。如,这里的int Add(int,int)是由Borland C++编译器所编译的DLL提供的,你在Visual C++程序调用该函数,则很可能出现这种问题,因为两者在寄存器使用约定上,栈操作方式上都不尽相同。或者不同的调用约定如__cdecl,__stdcall,__pascal,__fastcall之间转换不明确很有可能引发这种问题。如,假设某函数属性定义为__cdecl,而调用时按照__stdcall或__pascal方式就很可能产生问题。所以,程序当中不使用@14语句那样释放内存恢复现场,而是恢复保存在基址指针ebp寄存器中的值来实现,如下所示:
@17: mov esp,ebp
但这也向我们提出了要求,不可随便改变这些寄存器的值,而我们在向C++代码中嵌入汇编代码时很有可能在不经意间写出这样的代码。尝试向你的代码添加这样一条指令:
_asm add ebp,4
它自行修改了基址指针的值,在该函数执行结束时肯定引发访问异常(Access Violation)。
最后,我们给出例子程序的堆栈操作步骤,如下。
![[栈接口图]](https://p-blog.csdn.net/images/p_blog_csdn_net/cambest/image002.png)
该图给出了在进入函数体时,程序对程序栈所作的操作。
4.结束语
从这里我们可以看出一个C++程序,它的局部变量内存分配在底层的实现。很多情况下(可以说在绝大多数一般通用软件的编写情况下),我们并不需要了解这么底层的技术细节。但是,要想做一个优秀的C/C++程序员,深入的了解这些细节又是必要的。只有这样,我们才能在一望无际的代码中立于不败之地,当你的代码出现好多莫名其妙的问题但在逻辑上又无法发现其缺陷或者逻辑上根本没有缺陷时,我们能够胸有成竹地面对它们。不论出现什么样的问题,我们都能够知道为什么会出这样的问题,都能够想出办法去解决这样的问题。这才是我们要达到的目的。
谨以此文献给同样热爱C/C++的同行们。