C++基本概念在编译器中的实现
相信很多程序员都很熟悉C++对象模型。本文试图通过一个简单的例子来演示C++的一些基本概念在编译器中的实现,以达到眼见为实的效果。
1.对象之间的虚函数空
1.1对象之间空
例如,当我们为一个对象分配空空间时:
c child 1 * pChild = new c child 1();
这个空房间里有什么?
当CCChild1没有虚函数时,其基类的非静态成员和自身的非静态成员依次放在CCChild1的object 空中。没有任何非静态成员的对象将有一个一字节的占位符。
如果CChild1有虚函数,VC6编译器会在objects 空前面加一个指针,就是虚函数表指针(VPTR)。让我们看看这段代码:
class cmember 1 {
public:
cmember 1(){ a = 0x 5678;printf(" construct cmember 1 \ n ");}
~ cmember 1(){ printf(" destruct cmember 1 \ n ");}
int a;
};
class c parent 1 {
public:
c parent 1(){ parent _ data = 0x 1234;printf(" construct cparent 1 \ n ");}
virtual ~ c parent 1(){ printf(" destruct c parent 1 \ n ");}
virtual void test(){ printf(" call cpart 1::test()\ n \ n ");}
void real(){ printf(" call cpart 1::test()\ n \ n ");}
int parent _ data;
};
class child 1:public cpart 1 {
public:
ccchil 1(){ printf(" construct ccchil 1 \ n ");}
virtual ~ c child 1(){ printf(" destruct c child 1 \ n ");}
Virtual void test(){ printf(" Call c child 1::test()\ n \ n ");}
void real(){ printf(" Call ccchil 1::test()\ n \ n ");}
CMember1成员;
static int b;
};
child 1对象的大小是多少?以下是演示程序的打印输出:
-->派生类对象
对象地址0x00370FE0
对象大小12
对象内容
00370 fe0:00410104 0001234 0005678
VPTR内容[/br
子1对象大小为12字节,包括:Vptr,parent_data,基类成员变量,派生类成员变量。Vptr指向的虚函数表(VTable)是虚函数地址的数组。
1.2 Vptr和VTable
如果我们用VC自带的dumPBin反汇编输出程序的调试版本:
dumpbin/disasm test _ VC6 . exe > a . txt
可以在a.txt中找到:
?test @ c child 1 @ @ UAEXXZ:
00401640:55推送ebp...
??_ ecchild 1 @ @ UAE paxi @ Z:
004016 a 0:55推送ebp
可以看到,VTable中的两个地址分别指向了ccchild1的析构函数和ccchild1的成员函数test。这两个函数是CChild1的虚函数。如果打印两个CChild1对象的内容,可以发现它们的Vptr是相同的,即每个有虚函数的类都有一个VTable,这个类中所有对象的Vptr都指向这个VTable。
这里的函数名是不是有点奇怪?附录2简单介绍了C++的名字Mangling。
1.3静态成员变量
在C++中,一个类的静态变量相当于具有访问控制的全局变量,不占用对象空之间的空间。它们的地址是在编译链接时确定的。例如,如果我们在项目的链接设置中选择“生成地图文件”,构建后,我们可以在生成的地图文件中看到:
0003:00002e18?b @ c child 1 @ @ 2HA 00414 e18 test1 . obj
从打印输出中我们可以看到,CChild1::b的地址正好是0x00414E18。实际上,类定义中变量B的声明只是一个声明。如果我们不在类定义(全局域)之外定义这个变量,这个变量根本就不存在。
1.4调用虚函数
通过在VC调试环境下设置断点,切换到汇编显示模式,可以看到调用虚函数的汇编代码:
16:pChild-> test();
(1) mov edx,dword ptr[pChild]
(2)mov eax,dword ptr [edx]
(3) mov esi,esp
(4) mov ecx,dword ptr [pChild]
(5)调用dword ptr [eax+4]
语句(1)将对象的地址放入寄存器edx,语句(2)将对象地址处的Vptr加载到寄存器eax,语句(5)跳转到Vptr指向的VTable的第二项地址,即成员函数test。
语句(4)把对象的地址放在寄存器ecx中,这就隐含了这个指针传递到非静态成员函数中。非静态成员函数通过这个指针访问非静态成员变量。
1.5虚函数和非虚函数
在演示程序中,我们打印了成员函数地址:
Printf("CParent1::测试地址0x%08p\n ",& cparent 1::test);
printf("CChild1::测试地址0x%08p\n ",& c child 1::test);
printf(" c parent 1::real address 0x % 08p \ n ",& c parent 1::real);
printf(" c child 1::real address 0x % 08p \ n ",& c child 1::real);
获得以下输出:
CParent1::测试地址0x004018F0
CChild1::测试地址0x004018F0
CParent1::实地址0x00401460
CChild1::实地址0x00401670
两个非虚函数的地址很容易理解,它们可以在dumpbin的输出中找到:
?real @ cparent 1 @ @ QAEXXZ:00401460:55推送ebp...
?real @ c child 1 @ @ QAEXXZ:00401670:55推送ebp
为什么两个虚函数的「地址」是一样的?实际上,thunk代码的地址打印在这里。通过查看dumpbin的输出,我们可以看到:
_9@$B3AE:
(6) mov eax,dword ptr[ecx]
(7)jmp dword ptr[eax+4]
如果我们在跳转到这段代码之前把对象地址放在寄存器ecx中,语句(6)会把对象地址处的Vptr加载到寄存器eax中,语句(7)会跳转到Vptr指向的VTable的第二项地址,也就是成员函数test。基类和派生类VTable的虚函数排列顺序相同,所以可以共享一段thunk代码。
这个thunk代码的目的是通过函数指针调用虚函数。如果我们不取函数地址,编译器就不会生成这段代码。请注意不要将本节中的thunk代码与VTable中的虚函数地址混淆。Thunk代码根据传入的对象指针决定调用哪个函数,VTable中的虚函数地址就是实函数地址。
1.6指向虚函数的指针
我们试试通过指针调用虚函数。非成员函数指针必须通过对象指针调用:
typedef void(Parent::* PMem)();
printf(" \ n-->由函数指针调用\ n ");
PMem pm = & Parent::test;
printf("函数指针0x%08p\n ",pm);
(p parent-> * pm)();
获得以下输出:
->通过函数指针调用
函数指针0x004018F0
调用CChild1::test()
我们从VC调试环境中复制了这段汇编代码:
13:(p parent-> * pm)();
(8) mov esi,esp
(9) mov ecx,dword ptr [pParent]
(10)调用dword ptr [pm]
语句(9)将对象指针放入寄存器ecx,语句(10)调用函数指针指向的thunk代码,也就是1.5节的语句(6)。接下来会发生什么?前面已经说过了。
1.7多态性的实现
经过前面的分析,多态的实现应该是显而易见的。当调用一个基类指针指向派生类对象的虚函数时,调用的当然是派生类的函数,因为派生类对象的Vptr指向派生类的VTable。
通过函数指针调用虚函数也需要通过VTable确定虚函数地址,所以也会发生多态性,即在当前对象VTable中调用虚函数。
位律师回复
0条评论