透过汇编另眼看世界之多继承下的虚函数函数调用(二)

转载 2007年10月15日 13:58:00
文揭示多继承下的虚函数调用

 

在我的前一篇文章"透过汇编另眼看世界之函数调用"中,我们通过汇编了解了虚函数调用的全部过程。在本文中我将分析多继承的情况下虚函数调用的情况。

首先还是写一些简单的代码作为本文分析的例子代码:

//the abstract base class
class IBase ...{
public:
    
virtual void func1() = 0;
    
virtual void func2() = 0;
}
;

class IDerive1 : public IBase ...{
public:
    
//virtual functions inherited from IBase 
    virtual void func1() = 0;
    
virtual void func2() = 0;

    
//new virtual function
    virtual void foobar() = 0;
}
;

class IDerive2 : public IBase ...{
public:
    
//virtual functions inherited from IBase 
    virtual void func1() = 0;
    
virtual void func2() = 0;

    
//new virtual function
    virtual void callMe() = 0;
}
;

class CMyObject : public IDerive1, public IDerive2 ...{
public:
    
//virtual functions inherited from IBase 
    virtual void func1();
    
virtual void func2();

    
//virtual function inherited from IDerive1
    virtual void foobar();

    
//virtual function inherited from IDerive2
    virtual void callMe();

public:
    CMyObject(): m_iValue(
0...{}
private:
    
int m_iValue;
}
;
/**//////////////////////
//ingore the definitions of all the virtual functions in CMyObject class
int _tmain(int argc, _TCHAR* argv[])
...{
  CMyObject obj;
 
  
//retreive the IDerive1 interface from the object
  IDerive1* pDerive1 = (IDerive1*)&obj;
  pDerive1
->func2();
  pDerive1
->foobar();
 
  
//retreive the IDerive2 interface from the object
  IDerive2* pDerive2 = (IDerive2*)&obj;
  pDerive2
->func2();
  pDerive2
->callMe();
 
  
//retreive the IDerive2 interface from the IDerive1 interface
  pDerive2 = (IDerive2*)pDerive1;
  pDerive2
->func2();
  pDerive2
->callMe();
 
  
return 0;
}

 

这里我采用的是和COM中使用的多继承类似的继承关系。IDerive1和IDerive2都继承自同一个抽象基类IBase,而且IDerive1和IDerive2本身还是抽象基类,CMyObject类多继承自IDerive1和IDerive2。

熟悉COM的朋友很自然的就会想到IBase就是COM中的IUnkown接口,而IDerive1和IDerive2就是COM中其他接口和自定义接口,而CMyObject就是COM中的"组件(Component)"。 之所以这样设计的原因是熟悉COM的朋友对这样的类的层次关系会感到很舒服,而且这样的多继承层次关系也是比较简单的,便于分析。

在分析汇编代码之前,我们还需要了解多继承下类对象的内存分布情况。多继承下的类对象的内存分布情况比较复杂,这也是为什么很多人说"不要随便使用多继承"。本文虽然使用了多继承,但是类对象的内存分布情况还是相对比较简单和容易控制的,两个基类都是抽象类,他们没有数据成员,只有一个虚指针,而类对象本身也只有一个int型的成员变量。对于CMyObject对象的内存分布情况,下面是我用VS2002调试器查看CMyObject对象的内存分布情况的截图:CMyObject对象的内存分布

 下面是我根据上面的截图,并结合我自己对这部分内容的理解,画了一个简图:

Pointer CMyObject vTable for IDerive1
Pointer CMyObject vTable for IDerive2
m_iValue




 

下面就继续我们的汇编分析。在这里我并不想分析所有的汇编代码,原因之一就是有些汇编代码和前一篇文章的代码是一样的,这里就不用罗嗦了。另一个原因就是我只关心和本文主题有关的内容,那些和本文的主题没有太多联系的内容就不会出现在我的讨论中。

 一。派生类指针到基类指针的转化。由CMyObject指针到IDerive1指针和IDerive2指针转化的汇编代码略有不同:

  ; IDerive1* pDerive1 = (IDerive1*)&obj     
  lea    eax, DWORD PTR _obj$[ebp] 
  mov    DWORD PTR _pDerive1$[ebp], eax
  ; IDerive2* pDerive2 = (IDerive2*)&obj
  lea    eax, DWORD PTR _obj$[ebp]
  test    eax, eax
  je    SHORT $L1774
  lea    ecx, DWORD PTR _obj$[ebp
+4
]
  mov    DWORD PTR tv73[ebp], ecx
  jmp    SHORT $L1775
$L1774:
  mov    DWORD PTR tv73[ebp], 
0

$L1775:
  mov    edx, DWORD PTR tv73[ebp]
  mov    DWORD PTR _pDerive2$[ebp], edx

通过比较我们发现,当CMyObject类指针转化成第二个基类IDerive2指针的时候,除了判断了CMyObject类指针是否为空外,更重要的是,IDerive2指针的值是在CMyObject类指针值的基础上多加了4个字节(一个指针的大小?)。仔细想像,这个不难理解:在多继承的情况下,派生类对象的内存分布是按照基类在派生类中声明的顺序来排列的,在本文中,按照声明顺序,obj的内存分布应该也是基类IDerive1的数据成员,然后是IDerive2的数据成员,最后才是CMyObject的数据成员。由于IDerive1在最前面,而且只有一个虚指针,所以在指针转化的过程中,IDerive1的指针值和CMyObject的指针值是一样的,而IDerive2的指针值就要在CMyObject指针值的基础上加4。

二。基类指针之间转化。下面是由IDerive1指针转化的IDerive2指针的汇编代码:

    ;pDerive2 = (IDerive2*)pDerive1;
    mov    eax, DWORD PTR _pDerive1$[ebp]
    mov    DWORD PTR _pDerive2$[ebp], eax

 感到奇怪的是,这里的转化直接将IDerive1的指针赋给了IDerive2的指针。这样的转化合理么?根据上面的分析,我们知道IDerive1的地址和IDerive2的值应该是不相等的,它们之间差4个字节,可是为什么这里编译器却将他们设为相等? 在这种情况下虚函数能正常调用么? 往下看看在说。

三。派生类的虚表。我奇怪的发现,CMyObject有两个虚表:

CONST    SEGMENT
??_7CMyObject@@6BIDerive1@@@ DD FLAT:?func1@CMyObject@@UAEXXZ ; CMyObject::`vftable
'
    DD    FLAT:?
func2@CMyObject@@UAEXXZ
    DD    FLAT:
?
foobar@CMyObject@@UAEXXZ
CONST    ENDS
CONST    SEGMENT
??_7CMyObject@@6BIDerive2@@@ DD FLAT:?func1@CMyObject@@W3AEXXZ ; CMyObject::`vftable'
    DD    FLAT:?func2@CMyObject@@W3AEXXZ
    DD    FLAT:
?callMe@CMyObject@@UAEXXZ
CONST    ENDS

起初我还以为他们是一样的,但是通过undname.exe对虚表的符号名进行"反修饰",却得到了两个不同的符号名:
??_7CMyObject@@6BIDerive1@@@     const CMyObject::`vftable'{for `IDerive1'}
??_7CMyObject@@6BIDerive2@@@     const CMyObject::`vftable'{for `IDerive2'}

更奇怪的是,通过"反修饰"虚表的虚函数的符号名,我也得到两套不同的符号名:
?func1@CMyObject@@UAEXXZ          public: virtual void __thiscall CMyObject::func1(void)
?func2@CMyObject@@UAEXXZ          public: virtual void __thiscall CMyObject::func2(void)
?foobar@CMyObject@@UAEXXZ        public: virtual void __thiscall CMyObject::foobar(void)

?func1@CMyObject@@W3AEXXZ       [thunk]:public: virtual void __thiscall CMyObject::func1`adjustor{4}' (void)
?func2@CMyObject@@W3AEXXZ       [thunk]:public: virtual void __thiscall CMyObject::func2`adjustor{4}' (void)
?callMe@CMyObject@@UAEXXZ        public: virtual void __thiscall CMyObject::callMe(void)

当我看到"[thunk]"的时候突然就意识到:难道这就是江湖上传说的中的"thunk"? 传说中"thunk"是编译器插入的一小段代码,可以用来实现一些特殊的功能,例如在Win32环境下调用Win16 API,那在多继承下的虚函数调用中,"thunk"又起着什么作用呢?我在汇编代码中找到了"thunk"的代码:

?func1@CMyObject@@W3AEXXZ PROC NEAR            ; CMyObject::func1, COMDAT
    sub    ecx, 
4

    jmp    
?func1@CMyObject@@UAEXXZ                  ; CMyObject::func1
?func1@CMyObject@@W3AEXXZ ENDP                    ; CMyObject::func1
?func2@CMyObject@@W3AEXXZ PROC NEAR   ; CMyObject::func2, COMDAT
   sub ecx, 4
   jmp ?func2@CMyObject@@UAEXXZ  ; CMyObject::func2
?func2@CMyObject@@W3AEXXZ ENDP    ; CMyObject::func2

 由上面汇编代码可以看出,"thunk"代码并不是那么神秘,它只是简单的将寄存器的值减4(一个指针的大小?),然后跳转到另外一个函数。为什么是ECX?为什么是减4?ECX在虚函数调用的过程中不是存放this指针的寄存器么?结合着本文中的类的继承层次关系,我开始慢慢的明白了"thunk"的任务。在多继承的情况下,各基类指针的值应该是不一样的,只有第一个基类的指针值和派生类类对象的首地址是一致的,其他的基类指针和派生类对象的首地址存在一个偏移。假如多个基类也都从一个共同的基类继承而来,理论上说我们可以通过任何一个基类指针去调用这个共同基类的虚函数,这个虚函数调用会被解析到派生类的虚函数实现,而且派生类也只能有一个虚函数实现。为了使通过任何一个基类指针调用的虚函数都调用同一个函数,我们只需要将这样的虚函数调用"转化"到通过第一个基类指针来调用就可以了,而在第一个基类的虚表中存放虚函数的实现。这个转化的过程就是由"thunk"来完成的:它首先将基类指针调整到第一个基类的地址,也就是派生类对象的首地址,然后调用相应的虚函数。

有了这样的分析,我们就可以画出虚表的大致情况:

CMyObject vTable for IDerive1

&CMyObject::func1()
&CMyObject::func2()
&CMyObject::foobar()

 

 


CMyObject vTable for IDerive2

&thunk for CMyObject::func1()
&thunk for CMyObject::func2()
&CMyObject::callme()



接着再回到基类指针之间转化的那个问题:

    pDerive2 = (IDerive2*)pDerive1;
    pDerive2
->
func2();
    pDerive2
->callMe();

此时通过pDerive2能够获得虚表的应该是IDerive1的虚表,所以调用func2()的时候,应该没有thunk发生的。而调用callMe()的时候实际上调用的是foobar(),应该它在IDerive1虚表中偏移量和callMe()在IDerive2虚表中的偏移量是一样的。呜!!!,这个是个错误么?是个Bug么?我也不知道。

11/04/2006 于家中

V1.1

还是基类指针之间转化的问题


根据网友sting的回复,我也明白了这里为什么转化不成功的原因。由于IDerive1和IDerive2之间并没有什么继承关系(虽然他们是另一个派生类的基类),编译器就把他们当作两个"毫无关系"的类,在转化的过程中只能进行简单的赋值,这样的转化形式在C++被定义为reinterpret_cast。

这里有两个方法进行正确的转化:

1。先将一个基类转化到派生类,然后通过派生类再转化到另一个基类。相应的代码可以是这样的:
pDerive1 = static_cast<IDerive1*>( static_cast<CMyObject*>(pDerive2) );
pDerive2 = static_cast<IDerive2*>( static_cast<CMyObject*>(pDerive1) );

2。使用dynamic_cast来转化。要想使dynamic_cast能够正常的工作,我们需要开启"运行时类型标识(RTTI)"。运行时类型标识为处于同一个继承链上的所有类建立了一张"关系网",这样任何两个类之间就有了"千丝万缕"的关系,这样就为他们之间的直接转化提供了可能。相应的代码可以时这样的:
pDerive1 = dynamic_cast<IDerive1*>(pDerive2);
pDerive2 = dynamic_cast<IDerive2*>(pDerive1);
 

派生类虚函数调用基类版本

C++ primer 这本书上有这么两句话“派生类虚函数调用基类版本时,必须显式使用作用域操作符。如果派生类函数忽略了这样做,则函数调用会在运行时确定并且将是一个自身调用,从而导致无穷递归。” ...
  • glx2012
  • glx2012
  • 2012-12-04 10:27:02
  • 2090

透过汇编另眼看世界之函数调用

在我的另外一篇文章中 ,我提到了要通过汇编语言来分析虚函数调用的真相。我们现在就开始踏上这次艰辛却非常有意思的旅程。其他闲话少说,直接进入主题。本文中使用的C++代码:#include "stdafx...
  • houdy
  • houdy
  • 2006-10-15 09:45:00
  • 3831

透过汇编另眼看世界之多继承下的虚函数函数调用

在我的前一篇文章"透过汇编另眼看世界之函数调用"中,我们通过汇编了解了虚函数调用的全部过程。在本文中我将分析多继承的情况下虚函数调用的情况。首先还是写一些简单的代码作为本文分析的例子代码: //the...
  • houdy
  • houdy
  • 2006-11-04 14:19:00
  • 5127

透过汇编另眼看世界之多继承下的虚函数函数调用

在我的前一篇文章"透过汇编另眼看世界之函数调用"中,我们通过汇编了解了虚函数调用的全部过程。在本文中我将分析多继承的情况下虚函数调用的情况。首先还是写一些简单的代码作为本文分析的例子代码: //the...
  • foreverfresh
  • foreverfresh
  • 2007-01-24 13:27:00
  • 518

透过汇编另眼看世界之类成员函数指针

前言:在CSDN论坛经常会看到一些关于类成员函数指针的问题,起初我并不在意,以为成员函数指针和普通的函数指针是一样的,没有什么太多需要讨论的。当我找来相关书籍查阅了一番以后,突然意识到我以前对成员函数...
  • discory
  • discory
  • 2007-02-12 11:37:00
  • 516

透过汇编另眼看世界之类成员函数指针

前言:在CSDN论坛经常会看到一些关于类成员函数指针的问题,起初我并不在意,以为成员函数指针和普通的函数指针是一样的,没有什么太多需要讨论的。当我找来相关书籍查阅了一番以后,突然意识到我以前对成员函数...
  • houdy
  • houdy
  • 2006-11-25 12:35:00
  • 3907

透过汇编另眼看世界之DLL导出函数调用

前言:我一直对DLL技术充满好奇,一方面是因为我对DLL的导入/导出机制还不是特别的了解,另一面是因为我发现:DLL技术在Windows平台下占有重要的地位,几乎所有的Win32 API都是以导出函数...
  • houdy
  • houdy
  • 2006-11-19 12:45:00
  • 3798

透过汇编另眼看世界之DLL导出函数调用

DLL的导出/导入机制到底是怎么实现的呢?本文通过引用大量的文章和对实际汇编代码的深入分析,理论联系实际,深入剖析了大量隐藏在背后的秘密。前言:我一直对DLL技术充满好奇,一方面是因为我对DLL的导入...
  • benny5609
  • benny5609
  • 2007-11-01 17:35:00
  • 634

C++构造函数中调用虚函数

谈谈关于构造函数中调用虚函数的情况,仅讨论单继承,不考虑虚拟继承和多重继承。 测试平台:VS2013 + Win7X64 一个例子: #include #include class Base...
  • alex_my
  • alex_my
  • 2015-03-02 13:58:13
  • 1400

读《透过结构看世界》

李忠秋 推荐 2 星 个人并不喜欢这本书。也不认为是一本值得推荐给朋友的书。 书的内容有些空,虽然讲的也是方法论 用方法去理解事物,去解决问题,是我关注的主题,但是所讲,我并不认同。 ...
  • wide288
  • wide288
  • 2017-02-02 17:59:02
  • 626
收藏助手
不良信息举报
您举报文章:透过汇编另眼看世界之多继承下的虚函数函数调用(二)
举报原因:
原因补充:

(最多只允许输入30个字)