阅读本文前最好已经读过 理解程序内存 和 理解C++变量存储模型 相关的内容, C++对象模型比较经典的书是《深度探索C++对象模型》, 但是书本的知识始终局限在理论上,熟话说“纸上得来终觉浅”,只有我们自已用工具经过验证,我们才能真正的理解这些知识。下面我们用WinDbg为工具对C++对象模型进行探索。


类对象实例究竟包含哪些东西

我们的例子代码非常简单:
#include <iostream>

using namespace  std;

class A
{
public:
    void fun1(){ cout << "fun1"; }
    virtual void fun2() { cout << "fun2"; }
    virtual ~A() {}

    char m_cA;
    int m_nA;
    static int s_nCount;
};

int A::s_nCount = 0;

int main() 
{
    A* p = new A;
    p->fun2();

    system("pause");

    return 0;
}
我们在main函数里 system("pause");的地方设置断点,然后让程序运行到这里。

输入WinDbg命令?? sizeof(*p)让他打印A对象的大小,输出如下:
 0:000> ?? sizeof(*p)
unsigned int 0xc
可以看到A的实例对象大小是 0xc = 12 字节

接下来输入WinDbg命令dt p让他打印p所指下对象的内存布局, 输出如下:
0:000> dt p
Local var @ 0x13ff74 Type A*
0x00034600 
   +0x000 __VFN_table : 0x004161d8 
   +0x004 m_cA             : 120 'x'
   +0x008 m_nA             : 0n0
   =0041c3c0 A::s_nCount      : 0n0
可以看到A的对象实例由虚表指针,m_cA, m_nA组成,正好是12字节(内部char作了4字节对齐)。

最后一个静态变量s_nCount的地址是0041c3c0, 我们可以通过命令!address 0041c3c0查看它所在地址的属性, 结果如下:
0:000> !address 0041c3c0
Usage:                  Image
Allocation Base:        00400000
Base Address:           0041b000
End Address:            0041f000
Region Size:            00004000
Type:                   01000000    MEM_IMAGE
State:                  00001000    MEM_COMMIT
Protect:                00000004    PAGE_READWRITE
More info:              lmv m ConsoleTest
More info:              !lmi ConsoleTest
More info:              ln 0x41c3c0
可以看到类静态变量被编译在consoletest.exe可执行文件的 可读写数据节(.data)

结论: C++中类实例对象由虚表指针和成员变量组成(一般最开始的4个字节是虚表指针),而类静态变量分布在PE文件的.data节中,与类实例对象无关。


虚表位置和内容

根据+0x000 __VFN_table : 0x004161d8  继续上面的调试,我们看到虚表地址是在0x004161d8, 输入!address 0x004161d8, 查看虚表地址的属性:
0:000> !address 0x004161d8
Usage:                  Image
Allocation Base:        00400000
Base Address:           00416000
End Address:            0041b000
Region Size:            00005000
Type:                   01000000    MEM_IMAGE
State:                  00001000    MEM_COMMIT
Protect:                00000002    PAGE_READONLY
More info:              lmv m ConsoleTest
More info:              !lmi ConsoleTest
More info:              ln 0x4161d8
可以看到类虚表被编译在consoletest.exe可执行文件的 只读数据节(.rdata)

接下来我们看下虚表中有哪些内容, 输入dps 0x004161d8 查看虚表所在地址的符号,输出如下:
0:000> dps 0x004161d8 
004161d8  00401080 ConsoleTest!A::fun2 [f:\test\consoletest\consoletest\consoletest.cpp @ 13]
004161dc  004010a0 ConsoleTest!A::`scalar deleting destructor'
004161e0  326e7566
004161e4  00000000
我们可以看到虚表里正好包含了我们的2个虚函数fun2()和~A().

另外我们也可以多new几个A的实例试下,我们可以看到他们的虚表地址都是 0x004161d8。

结论: C++中类的虚表内容由虚函数地址组成,虚表分布在PE文件的.rdata节,并且同一类的所有实例共享同一个虚表。


禁止生成虚表会怎样

我们可以通过__declspec(novtable)来告诉编译器不要生成虚表,ATL中大量应用这种技术来减小虚表的内存开销,我们原来的代码改成
class __declspec(novtable) A
{
public:
    void fun1(){ cout << "fun1"; }
    virtual void fun2() { cout << "fun2"; }
    virtual ~A() {}

    char m_cA;
    int m_nA;
    static int s_nCount;
};
继续原来的方法调试,我们会看到一运行到p->fun2(), 程序就会Crash, 究竟是什么原因?
用原来的?? sizeof(*p)命令,可以看到对象大小依然是12 字节, 输入dt p, 输出:
0:000> dt p
Local var @ 0x13ff74 Type A*
0x00033e58 
   +0x000 __VFN_table : 0x00030328 
   +0x004 m_cA             : 40 '('
   +0x008 m_nA             : 0n0
   =0040dce0 A::s_nCount      : 0n0
从上面可以看到虚表似乎依然存在, 但是再输入dps 0x00030328 查看虚表内容, 你就会发现现在虚表内容果然已经不存在了:
0:000> dps 0x00030328 
00030328  00030328
0003032c  00030328
00030330  00030330
但是我们的程序还是通过虚表去调用虚函数fun2, 难怪会Crash了。

结论: 通过__declspec(novtable),我们只能禁止编译器生成虚表,但是不能阻止对象仍包含虚表指针(不能减小对象的大小),也不能阻止程序对虚表的访问(尽管实际虚表不存在),所以禁止生成虚表只适用于永远不会实例化的类(基类)


单继承对象内存模型

下面我们简单的将上面的代码改下下,让B继承A,并且重写原来的虚函数fun2:
#include <iostream>

using namespace  std;

class  A
{
public:
    void fun1(){ cout << "fun1"; }
    virtual void fun2() { cout << "fun2"; }
    virtual ~A() {}

    char m_cA;
    int m_nA;
    static int s_nCount;
};

int A::s_nCount = 0;

class B: public A
{
public:
    virtual void fun2() { cout << "fun2 in B"; }
    virtual void fun3() { cout << "fun3 in B"; }

public:
    int m_nB;
};

int main() 
{
    B* p = new B;
    A* p1 = p;

    p1->fun2();

    system("pause");

    return 0;
}
用原来的方法进行调试,查看B对象的内存布局
0:000> dt p
Local var @ 0x13ff74 Type B*
0x00034640 
   +0x000 __VFN_table : 0x004161d8 
   +0x004 m_cA             : 120 'x'
   +0x008 m_nA             : 0n0
   =0041c3e0 A::s_nCount      : 0n0
   +0x00c m_nB             : 0n0
可以看到B对象的大小是原来A对象的大小加4(m_nB), 总共是16字节,查看B的虚表内容如下:
0:000> dps 0x004161d8 
004161d8  00401080 ConsoleTest!B::fun2 [f:\test\consoletest\consoletest\consoletest.cpp @ 26]
004161dc  004010c0 ConsoleTest!B::`scalar deleting destructor'
004161e0  004010a0 ConsoleTest!B::fun3 [f:\test\consoletest\consoletest\consoletest.cpp @ 27]
004161e4  326e7566
可以看到虚表中保存的都是B的虚函数地址: fun2(), ~B(), fun3()

结论: 单继承时父类和子类共用同一虚表指针,而子类的数据被添加在父类数据之后,父类和子类的对象指针在相互转化时值不变。


多继承对象内存模型

我们把上面的代码改成多继承的方式, class A, class B, 然后C继承A和B:
#include <iostream>
using namespace  std;
class  A
{
public:
virtual void fun()  {cout << "fun in A";}
virtual void funA() {cout << "funA";}
virtual ~A() {}
char m_cA;
int m_nA;
static int s_nCount;
};
int A::s_nCount = 0;
class B
{
public:
virtual void fun() {cout << "fun in B";}
virtual void funB() {cout << "funB";}
int m_nB;
};
class C: public A, public B 
{
public:
virtual void fun() {cout << "fun in C";};
virtual void funC(){cout << "funC";}
int m_nC;
};
int main() 
{
C* p = new C;
B* p1 = p;
p->fun();
system("pause");
return 0;
}
依旧用原来的方式调试,查看C的内存布局
0:000> dt p
Local var @ 0x13ff74 Type C*
0x00034600 
   +0x000 __VFN_table : 0x004161f4 
   +0x004 m_cA             : 120 'x'
   +0x008 m_nA             : 0n0
   =0041c4a0 A::s_nCount      : 0n0
   +0x00c __VFN_table : 0x004161e8 
   +0x010 m_nB             : 0n0
   +0x014 m_nC             : 0n0
可以看到C对象由0x18 = 24字节组成,可以看到数据依次是虚表指针,A的数据,虚表指针, B的数据, C的数据。

查看第一个虚表内容:
0:000> dps 0x004161f4 
004161f4  004010f0 ConsoleTest!C::fun [f:\test\consoletest\consoletest\consoletest.cpp @ 33]
004161f8  004010b0 ConsoleTest!A::funA [f:\test\consoletest\consoletest\consoletest.cpp @ 16]
004161fc  00401130 ConsoleTest!C::`scalar deleting destructor'
00416200  00401110 ConsoleTest!C::funC [f:\test\consoletest\consoletest\consoletest.cpp @ 34]
可以看到前面虚表的前面3个虚函数符合A的虚表要求(第一个A::fun让C::fun取代了,第三个A的析构函数~A让~C取代了),最后加上了C的新增虚函数funC, 所以该虚表同时符合A和C的要求,也就是说A和C共用同一个虚表指针。

再看第二个虚表内容:
0:000> dps 0x004161e8 
004161e8  00402850 ConsoleTest![thunk]:C::fun`adjustor{12}'
004161ec  004010d0 ConsoleTest!B::funB [f:\test\consoletest\consoletest\consoletest.cpp @ 27]
004161f0  004187a0 ConsoleTest!C::`RTTI Complete Object Locator'
可以看到第二个虚表符合B的虚表要求,并且把B的虚函数fun用C的改写了,所以它是给B用的。 

我们再看基类对象B的布局情况:
0:000> dt p1
Local var @ 0x13ff70 Type B*
0x0003460c 
   +0x000 __VFN_table : 0x004161e8 
   +0x004 m_nB             : 0n0
我们可以看到p1指针本身在堆栈上的地址是0x13ff70,而p1所指向对象的地址是0x003e460c ,所以将C指针转成B指针后,B的地址和C的地址之差是0x003e460c0x00034600  = 0xc = 12字节, 也就是说B的指针p1指向我们上面的第二个虚表指针。

另外我们上面要特别留意第二个虚表的第一个函数:004161e8  00402850 ConsoleTest![thunk]:C::fun`adjustor{12}'
我们发现这个函数不是我们真正的class C的fun函数:004161f4  004010f0 ConsoleTest!C::fun [f:\test\consoletest\consoletest\consoletest.cpp @ 33]
该函数地址是00402850, 我们可以反汇编看下:
0:000> u 00402850
ConsoleTest![thunk]:C::fun`adjustor{12}':
00402850 83e90c          sub     ecx,0Ch
00402853 e998e8ffff      jmp     ConsoleTest!C::fun (004010f0)
00402858 cc              int     3
00402859 cc              int     3
0040285a cc              int     3
0040285b cc              int     3
0040285c cc              int     3
0040285d cc              int     3
可以看到这个函数是编译器生成的一个代理函数,它内部实现只是把我们B的this指针(ecx)加上12个字节的偏移后,然后再去调用我们真正的C的fun函数。
为什么会这样呢? 因为class C的fun 内部在实现时假设的this指针都是它本身实例的起始地址,但是B指针并不符合这个要求,所以B的指针需要调整后才能去调用真正C的方法。

结论: 多重继承时派生类和第一个基类公用一个虚表指针,他们的对象指针相互转化时值不变;而其他基类(非第一个)和派生类的对象指针在相互转化时有一定的偏移,他们内部虚表保存的函数指针并不一定是最终的实现的虚函数(可能是类似上面的一个代理函数)。



如何用虚表实现多态?

有了上面这些分析,这个咱们就不证明了,直接下结论吧。

结论: C++通过虚表来实现多态,派生类的虚表和基类的虚表根据索引依次保存相同的函数类型指针,但是这些函数指针最终指向他们各自最终的实现函数,调用虚函数时,我们只是根据函数所在虚表的索引来调用,所以他们可以在派生类中有各自不同的实现。 



虚拟继承

恩,有了前面的基础,这个就当思考题吧...


总之,拿着一把刀,庖丁解牛般的剖析语言背后的实现细节,看起来不是那么实用,但是它能让你对语言的理解更深刻。实际上ATL中大量应用上面的技术,如果没有对C++ 对象模型有比较深刻的理解,是很难深入下去的。
posted on 2012-09-21 23:02 Richard Wei 阅读(4126) 评论(2)  编辑 收藏 引用 所属分类: C++

FeedBack:
# re: 探索C++对象模型
2015-08-18 13:32 | lvshiling@qq.com
可以看到这个函数是编译器生成的一个代理函数,它内部实现只是把我们B的this指针(ecx)加上12个字节的偏移后,然后再去调用我们真正的C的fun函数。

应修改为

可以看到这个函数是编译器生成的一个代理函数,它内部实现只是把我们B的this指针(ecx)减去12个字节的偏移后,然后再去调用我们真正的C的fun函数。  回复  更多评论
  

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理