探索C++对象模型
阅读本文前最好已经读过
理解程序内存
和
理解C++变量存储模型
相关的内容, C++对象模型比较经典的书是《深度探索C++对象模型》, 但是书本的知识始终局限在理论上,熟话说“纸上得来终觉浅”,只有我们自已用工具经过验证,我们才能真正的理解这些知识。下面我们用WinDbg为工具对C++对象模型进行探索。
类对象实例究竟包含哪些东西
我们的例子代码非常简单:
#include
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
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
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的地址之差是0x003e460c
-
0x00034600 = 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++ 对象模型有比较深刻的理解,是很难深入下去的。