扫一扫
分享文章到微信
扫一扫
关注官方公众号
至顶头条
在我的前一篇文章"透过汇编另眼看世界之函数调用"中,我们通过汇编了解了虚函数调用的全部过程。在本文中我将分析多继承的情况下虚函数调用的情况。
首先还是写一些简单的代码作为本文分析的例子代码:
这里我采用的是和COM中使用的多继承类似的继承关系。IDerive1和IDerive2都继承自同一个抽象基类IBase,而且IDerive1和IDerive2本身还是抽象基类,CMyObject类多继承自IDerive1和IDerive2。
熟悉COM的朋友很自然的就会想到IBase就是COM中的IUnkown接口,而IDerive1和IDerive2就是COM中其他接口和自定义接口,而CMyObject就是COM中的"组件(Component)"。 之所以这样设计的原因是熟悉COM的朋友对这样的类的层次关系会感到很舒服,而且这样的多继承层次关系也是比较简单的,便于分析。
在分析汇编代码之前,我们还需要了解多继承下类对象的内存分布情况。多继承下的类对象的内存分布情况比较复杂,这也是为什么很多人说"不要随便使用多继承"。本文虽然使用了多继承,但是类对象的内存分布情况还是相对比较简单和容易控制的,两个基类都是抽象类,他们没有数据成员,只有一个虚指针,而类对象本身也只有一个int型的成员变量。对于CMyObject对象的内存分布情况,下面是我用VS2002调试器查看CMyObject对象的内存分布情况的截图:
下面是我根据上面的截图,并结合我自己对这部分内容的理解,画了一个简图:
Pointer CMyObject vTable for IDerive1 |
Pointer CMyObject vTable for IDerive2 |
m_iValue |
下面就继续我们的汇编分析。在这里我并不想分析所有的汇编代码,原因之一就是有些汇编代码和前一篇文章的代码是一样的,这里就不用罗嗦了。另一个原因就是我只关心和本文主题有关的内容,那些和本文的主题没有太多联系的内容就不会出现在我的讨论中。
一。派生类指针到基类指针的转化。由CMyObject指针到IDerive1指针和IDerive2指针转化的汇编代码略有不同:
通过比较我们发现,当CMyObject类指针转化成第二个基类IDerive2指针的时候,除了判断了CMyObject类指针是否为空外,更重要的是,IDerive2指针的值是在CMyObject类指针值的基础上多加了4个字节(一个指针的大小?)。仔细想像,这个不难理解:在多继承的情况下,派生类对象的内存分布是按照基类在派生类中声明的顺序来排列的,在本文中,按照声明顺序,obj的内存分布应该也是基类IDerive1的数据成员,然后是IDerive2的数据成员,最后才是CMyObject的数据成员。由于IDerive1在最前面,而且只有一个虚指针,所以在指针转化的过程中,IDerive1的指针值和CMyObject的指针值是一样的,而IDerive2的指针值就要在CMyObject指针值的基础上加4。
二。基类指针之间转化。下面是由IDerive1指针转化的IDerive2指针的汇编代码:
感到奇怪的是,这里的转化直接将IDerive1的指针赋给了IDerive2的指针。这样的转化合理么?根据上面的分析,我们知道IDerive1的地址和IDerive2的值应该是不相等的,它们之间差4个字节,可是为什么这里编译器却将他们设为相等? 在这种情况下虚函数能正常调用么? 往下看看在说。
三。派生类的虚表。我奇怪的发现,CMyObject有两个虚表:
起初我还以为他们是一样的,但是通过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"的代码:
由上面汇编代码可以看出,"thunk"代码并不是那么神秘,它只是简单的将寄存器的值减4(一个指针的大小?),然后跳转到另外一个函数。为什么是ECX?为什么是减4?ECX在虚函数调用的过程中不是存放this指针的寄存器么?结合着本文中的类的继承层次关系,我开始慢慢的明白了"thunk"的任务。在多继承的情况下,各基类指针的值应该是不一样的,只有第一个基类的指针值和派生类类对象的首地址是一致的,其他的基类指针和派生类对象的首地址存在一个偏移。假如多个基类也都从一个共同的基类继承而来,理论上说我们可以通过任何一个基类指针去调用这个共同基类的虚函数,这个虚函数调用会被解析到派生类的虚函数实现,而且派生类也只能有一个虚函数实现。为了使通过任何一个基类指针调用的虚函数都调用同一个函数,我们只需要将这样的虚函数调用"转化"到通过第一个基类指针来调用就可以了,而在第一个基类的虚表中存放虚函数的实现。这个转化的过程就是由"thunk"来完成的:它首先将基类指针调整到第一个基类的地址,也就是派生类对象的首地址,然后调用相应的虚函数。
有了这样的分析,我们就可以画出虚表的大致情况:
&CMyObject::func1() |
&CMyObject::func2() |
&CMyObject::foobar() |
CMyObject vTable for IDerive2
&thunk for CMyObject::func1()
&thunk for CMyObject::func2()
&CMyObject::callme()
接着再回到基类指针之间转化的那个问题:
此时通过pDerive2能够获得虚表的应该是IDerive1的虚表,所以调用func2()的时候,应该没有thunk发生的。而调用callMe()的时候实际上调用的是foobar(),应该它在IDerive1虚表中偏移量和callMe()在IDerive2虚表中的偏移量是一样的。呜!!!,这个是个错误么?是个Bug么?我也不知道。
11/04/2006 于家中
V1.1
还是基类指针之间转化的问题
根据网友sting的回复,我也明白了这里为什么转化不成功的原因。由于IDerive1和IDerive2之间并没有什么继承关系(虽然他们是另一个派生类的基类),编译器就把他们当作两个"毫无关系"的类,在转化的过程中只能进行简单的赋值,这样的转化形式在C++被定义为reinterpret_cast。
这里有两个方法进行正确的转化:
1。先将一个基类转化到派生类,然后通过派生类再转化到另一个基类。相应的代码可以是这样的:如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。
现场直击|2021世界人工智能大会
直击5G创新地带,就在2021MWC上海
5G已至 转型当时——服务提供商如何把握转型的绝佳时机
寻找自己的Flag
华为开发者大会2020(Cloud)- 科技行者