C++虚函数调⽤的反汇编解析
C++虚函数调⽤的反汇编解析
作者:阮建辉
虚函数的调⽤如何能实现其“虚”?作为C++多态的表现⼿段,估计很多⼈对其实现机制感兴趣。⼤约⼀般的教科书就说到这个C++强⼤机制的时候,就是教⼤家怎么⽤,何时⽤,⽽不会去探究⼀下这个虚函数的真正实现细节。(当然,因为不同的编译器⼚家,可能对虚函数有⾃⼰的实现,呵呵,这就算是虚函数对于编译器的“多态”了:)。 作为编译型语⾔,C++编译的最后结果就是⼀堆汇编指令了(这⾥不同于.NET的CLR)。今天,我就来揭开它的神秘⾯纱,从汇编的层⾯来看看虚函数到底怎么实现的。让⼤家对虚函数的实现不仅知其然,更知其所以然。(本⽂程序环境为:PC + Windows XP Pro + Visual C++6.0,⽂中所得出来的结果和反映的编译器策略也只针
对VC6.0的编译器)
先看⼀段简单代码:
Code Segment:
Line01:  #include <stdio.h>
Line02:
Line03:  class Base {
Line04:  public:
Line05:      void __stdcall Output() {
Line06:          printf("Class Base/n");
Line07:      }
Line08:  };
Line09:
Line10:  class Derive : public Base {
Line11:  public:
Line12:      void __stdcall Output() {
Line13:          printf("Class Derive/n");
Line14:      }
Line15:  };
Line16:
Line17:  void Test(Base *p) {
Line18:      p->Output();
Line19:  }
Line20:
Line21:  int __cdecl main(int argc, char* argv[]) {
Line22:      Derive obj;
Line23:      Test(&obj);
Line24:      return 0;
Line25:  }
程序的运⾏结果将是:
Class Base
那么将Base类的Output函数声明(Line05)更改为:
virtual void __stdcall Output() {
那么,很明显地,程序的运⾏结果将是:
Class Derive
Test函数这回算是认清楚了这个指针是⼀个指向Derive类对象的指针,并且正确的调⽤了其Output函数。编译器如何做到这⼀切的呢?我们来看看没有“virtual”关键字和有“virtual”关键字,其最终的汇编代码区别在那⾥。
(在讲解下⾯的汇编代码前,让我们对汇编来⼀个简单扫描。当然,如果你对汇编已经很熟练,那么g
oto到括号外⾯吧^_^。先说说上⾯的Output函数被声明
为__stdcall的调⽤⽅式:它表⽰函数调⽤时,参数从右到左进⾏压栈,函数调⽤完后由被调⽤者恢复堆栈指针esp。其它的调⽤⽅式在⽂中描述。所谓的C++的this指针:也就是⼀个对象的初始地址。在函数执⾏时,它的参数以及函数内的变量将拥有如下所⽰的堆栈结构:
(图1)
如上图1所⽰,我们的参数和局部变量在汇编中都将以ebp加或者减多少来表⽰。你可能会有疑问了:有时候我的参数或者局部变量可能是⼀个很⼤的结构体或者只是⼀
个char,为什么这⾥ebp加减的都是4的倍数呢?恩,是这样的,对于32位机器来说,采⽤4个字节,也就是每次传输32位,能够取得最佳的总线效率。如果你的参数或者局部变量⽐4个字节⼤,就会被拆
成每次传4个字节;如果⽐4个字节⼩,那还是每次传4个字节。再简单解释⼀下下⾯⽤到的汇编指令,这些指令都是见名知意的哦:
①mov destination,source
将source的值赋给destination。注意,下⾯经常⽤到了“[xxx]”这样的形式,“xxx”对应某个寄存器加减某个数,“[xxx]”表⽰是取“xxx”的值对应的内存单元的内容。好⽐“xxx”是⼀把钥匙,去打开⼀个抽屉,然后将抽屉⾥的东西取出来给别⼈,或者是把别⼈给的东西放到这个抽屉⾥;
②lea destination,[source]
将source的值赋给destination。注意,这个指令就是把source给destination,⽽不会去把source对应的内存单元的内容赋给destination。好⽐是它就把钥匙给别⼈了;
在调试时如果想查看反汇编的话,你应该点击图2下排最右的按钮。
(图2)
其它指令我估计你从它的名字都能知道它是⼲什么的了,如果想知道其具体意思,这个应该参考汇编⼿册。:)
⼀. 没有virtual关键字时:
(1)  main函数的反汇编内容:
Line22:      Derive obj;
Line23:Test(&obj);
//如果你把断点设置在22⾏,开始调试的时候VC会告诉你这是⼀个⽆效⾏,⽽把断
//点⾃动移到下⼀⾏(Line23),这是因为代码中没有为Derive以及其基类定义构造函
//数,⽽且编译器也没有为它⽣成⼀个默认的构造函数的缘故,此⾏C++代码不会⽣成
//任何可实际调⽤的汇编指令;
004010D8lea eax,[ebp-4]
//将对象obj的地址放⼊eax寄存器中;
004010DB push eax
/
/将参数⼊栈;
004010DC  call        @ILT+5(Test) (0040100a)
//调⽤Test函数;
//这⾥@ILT+5就是跳转到Test函数的的jmp指令的地址,⼀个模块中所有的
//函数调⽤都会是象这样@ILT+5*n,n表⽰这个模块中的第n个函数,⽽ILT的意思
//是Import Lookup Table,程序调⽤函数的时候就是通过这个表来跳转到相应函数⽽执
//⾏代码的。
004010E1  add        esp,4
//调整堆栈指针,刚才调⽤了Test函数,调⽤⽅式__cdecl, 由调⽤者来恢复堆栈指针;
(2)  Test函数的反汇编内容:
Line18:      p->Output();
00401048  mov        eax,dword ptr [ebp+8]
//这⾥的[ebp+8]其实就是Test函数最左边的参数,就是上⾯main函数中压栈的eax;
//将参数的值(也就是上⾯的main函数中的obj对象的地址)放⼊eax寄存器中。
//注意:对于C++类的成员函数,默认的调⽤⽅式为“__thiscall”,这不是⼀个由程
//序员指定的关键字,它所表⽰的的函数调⽤,参数压栈从右向左,⽽且使⽤ecx寄存
//器来保存this指针。这⾥我们的Output函数的调⽤⽅式为“__stdcall”,ecx寄存器
//并不被使⽤来保存this指针,所以得有额外的指令将this指针压栈,如下句:
0040104B push eax
//将eax⼊栈,也就是下⾯调⽤Output函数需要的this指针了;
0040104C  call        @ILT+0(Base::Output) (00401005)
//调⽤类的成员函数,没有任何悬念,⽼⽼实实地调⽤Base类的Output函数;
⼆. 有virtual关键字时:
(1)  main函数的反汇编内容:
Line22:      Derive obj;
//在有virtual关键字的时候,把断点设置在22⾏,调试时就会停在此处了。我们没有
//为Derive类或者它的基类声明构造函数,这说明编译器⾃动为类⽣成了⼀个构造函
//数,下⾯我们就可以看看编译器⾃动⽣成的这个构造函数⼲了什么;
00401088lea ecx,[ebp-4]
//将对象obj的地址放⼊ecx寄存器中,为什么呢?上⾯说了哦~
0040108B  call        @ILT+25(Derive::Derive) (0040101e)
//编译器帮忙⽣成了⼀个构造函数,它在这⾥⼲了什么呢?等会再说吧,作个记号先://@_@1;上⾯要把obj的地址放⼊ecx中就是为这个函数调⽤做准备的;
Line23:      Test(&obj);
//这个调⽤操作跟上⾯的没有virtual关键字时是⼀样的:
00401090lea eax,[ebp-4]
00401093push eax
00401094  call        @ILT+5(Test) (0040100a)
004010C9add esp,4
(2)  Test函数的反汇编内容(跟上⾯的没有virtual关键字时可是⼤不⼀样哦):
Line18:      p->Output();
00401048  mov        eax,dword ptr [ebp+8]
//将Test的第⼀个参数的值放⼊eax寄存器中,其实你应该已经知道了,这就是obj的//地址了;
0040104B  mov        ecx,dword ptr [eax]
//喔噢,将eax寄存器中存的数对应的地址的内容取出来,你知道这是什么吗?等会再//说,做个记号先: @_@2
0040104D  mov        esi,esp
//这个是⽤来做esp指针检测的
0040104F  mov        edx,dword ptr [ebp+8]
//⼜把obj的地址存放到edx寄存器中,你该知道,其实就是this指针,⽽这个就是为  //调⽤类的成员函数做准备的;
00401052  push        edx
//将对象指针(也就是this指针)⼊栈,为调⽤类的成员函数做准备;
00401053  call        dword ptr [ecx]
//这个调⽤的就是类的成员函数,你知道调⽤的哪个函数吗?等会再说,做个记号先:
//@_@3
00401055  cmp        esi,esp
//⽐较esp指针的,要是不相同,下⾯的__chkesp函数将会让程序进⼊debug
00401057  call        __chkesp (00401110)
//检测esp指针,处理可能出现的堆栈错误(如果出错,将陷⼊debug)。
对⼀个C++类,如果它要呈现多态(⼀般的编译器会将这个类以及它的基类中是否存在virtual关键字作为这个类是否要多态),那么类会有⼀个virtual table,⽽每⼀个实例(对象)都会有⼀个virtual pointer(以下简称vptr)指向该类的virtual function table,如图3所⽰:
(下⾯右边表格中的VFuncAddr应该被理解为存放虚函数地址的内存单元的地址才准确。更准确地说,应该是跳转到相应函数的jmp指令的地址。)
(图3)
先来分析我们的main函数中的Derive类的对象obj,看看它的内存布局,由于没有数据成员,它的⼤⼩为4个字节,只有⼀个vptr,所
以obj的地址也就是vptr的地址了。(之所以我这⾥举例的类没有数据成员,因为不同的编译器将vptr放置的位置在对象内存布局中有可能不⼀样,当然,⼀般不是放在对象的头部,⽐如微软编译器;就是放在对象的尾部。不管哪种情况,对于这个例⼦,我这⾥的“obj的地址也就是vptr的地址”都是成⽴的。)
⼀个对象的vptr并不由程序员指定,⽽是由编译器在编译中指定好了的。那么现在让我来分别解释上⽂中标记的@_@1 - @_@ 3。
@_@1:
也就是要解释这⾥为什么编译器会为我们⽣成⼀个默认的构造函数,它是⽤来⼲什么的?还是让我们从反汇编⾥寻答案:
这是由编译器默认⽣成的Derive的构造函数中选取出来的核⼼汇编⽚段:
004010D9  pop        ecx
//编译器默认⽣成的Derive的构造函数的调⽤⽅式为__thiscall,所以ecx寄存器,如前
//所说,保存的就是this指针,也就是obj对象的地址,在这⾥也是vptr的地址了;
//我发现即使你把⼀个构造函数声明为__stdcall,它跟默认的__thiscall的反汇编也是⼀
//样的,这⼀点跟成员函数是不⼀样的;
004010DA  mov        dword ptr [ebp-4],ecx
//对于__thiscall⽅式调⽤的类的成员函数,第⼀个局部变量总是this指针,ebp-4就是
//函数的第⼀个局部变量的地址
004010DD  mov        ecx,dword ptr [ebp-4]
//因为要调⽤基类的构造函数,所以⼜得把this指针赋给ecx寄存器了;
004010E0  call        @ILT+30(Base::Base) (00401023)
//执⾏基类的构造函数;
004010E5  mov        eax,dword ptr [ebp-4]
/
/将this指针放⼊eax寄存器;
004010E8  mov        dword ptr [eax],offset Derive::`vftable' (0042201c)
//将虚函数表的⾸地址放⼊this指针所指向的地址,也就是初始化了vptr了;
⼤家看到了吧,编译器⽣成⼀个默认的构造函数,就是⽤来初始化vptr的;那么你⼤概也能想到其实Base的构造函数做了什么了,不出你所料,它也是⽤来做初始化vptr的:
0040D769  pop        ecx
0040D76A  mov        dword ptr [ebp-4],ecx
0040D76D  mov        eax,dword ptr [ebp-4]
0040D770  mov        dword ptr [eax],offset Base::`vftable' (00422020)
不⽤再解释了,跟Derive的构造函数功能⼀样,初始化vptr了。如果你⾃⼰声明和定义了⼀个构造函数的话,将先执⾏这些初始化vptr的代码后,再会来执⾏你的代码了。(如果你在构造函数中有作为构造函数的初始化列表形式出现的赋值代码,那么将先执⾏你的初始化列表中的赋值代码,然后再执⾏本类的vptr的初始化操作,再执⾏构造函数体内的代码)
@_@2和 @_@ 3:
00401048  mov        eax,dword ptr [ebp+8]
0040104B  mov        ecx,dword ptr [eax]
这⾥前⼀条指令是将obj的地址存放⼊eax中,那么你该知道obj地址对应的内存单元的前四个字节其实就是vptr地址?⽽vptr地址所对应的内存单元的内容其实就是vftable表格的起始地址,⽽vftable表格地址所对应的内存单元的内容就是虚函数地址。⽤下图更清楚地表⽰⼀下吧(如图4,该图表⽰地址和地址单元中的内容对应表。注意,右边的vftable表中的地址,其实并不是真正的函数地址,⽽是跳转到函数的jmp指令的地址,如0x0040EF12,并不是真正的Class::XXX函数的地址,⽽是跳转到Class::XXX函数的jmp指令的地址)。这汇编table指令什么意思
样ecx其实就是存放Derive::Output函数地址的内存单元的地址,然后调⽤:
0040104F  mov        edx,dword ptr [ebp+8]
00401052  push        edx
00401053  call        dword ptr [ecx]
就跳转到相应函数执⾏该函数了。
(如果有多个虚函数,且调⽤的是第N个虚函数,那么上句call指令就会被更改为这样的形式:call dword ptr [ecx+4*(N-1)])
上⾯的汇编是不是象这样:我拿到⼀把钥匙,打开⼀个抽屉,取出⾥⾯的东西,不过这个东西还是⼀把钥匙,还得拿着这个钥匙去打开另⼀个抽屉,取出⾥⾯真正的东西。^_^
(图4)
知道了来龙去脉,别⼈这么调⽤⽤汇编能做到调⽤相应的虚函数,那么我如果要⽤C/C++,该怎么做呢?我想你应该有眉⽬了吧。看看我是怎么⼲的(下⾯⽤⼀个C的函数指针调⽤了⼀个C++类的成员函数,将⼀个C++类的成员函数转换到⼀个C函数,需要做这些:C函数的参数个数⽐相应的C++类的成
员函数多出⼀个,⽽且作为第⼀个参数,⽽且它必须是类对象的地址):
将Base类的Output函数声明为virtual,然后将main函数更改为:
int __cdecl main(int argc, char* argv[]) {
Derive obj;                                                              //对象还是要有⼀个的
typedef void (__stdcall *PFUNC)(void*);              //声明函数指针
void *pThis = &obj;                                                //取对象地址,作为this指针⽤
//对应图4是将0x0012ff24赋给pThis
PFUNC pFunc = (PFUNC)*(unsigned int*)pThis; //取这个地址的内容,对应图4就应
//该是取地址0x0012ff24的内容为
//0x00400112了
pFunc = (PFUNC)*(unsigned int*)pFunc;              //再取这个地址的内容,对应图4就
/
/应该是取地址0x00400112的内容为
//0x0040EF12,也就是函数地址了
pFunc(pThis);                                                          //执⾏函数,将执⾏Derive::Output
return 0;
}
运⾏⼀下,看看结果。我可没有使⽤对象或者指向类的指针去调⽤函数哦。J
这回你该知道虚函数是怎么回事了吧?这⾥介绍的都是基于微软VC++ 6.0编译器对虚函数的实现⼿段。编译器实现C++所使⽤的⽅法和策略,都是可以从其反汇编语句中⼀探究竟的。了解这些底层细节,将会对提⾼你的C/C++代码⼤有裨益!希望本⽂能对你有所帮助。任何问题或者指教,请。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。