c++对象模型之虚表,虚表指针,thunk,多态,多重继承this指针偏移,多重继承virtual析构函数,多重虚继承下的访问虚基类成员变量时虚表的工作原理

前言

上两篇文章将c++的核心部件(Value categories)讲清楚了,这篇文章将会带大家分析c++对象模型的底层原理。笔者这里用的编译器是clang version 10.0.0-4ubuntu1,不同编译器对数据布局的处理可能会不同(《深度探索c++对象模型》中已阐述原因,感兴趣的读者可以自行阅读)。
友情提示:本文章涉及ATT式intel式汇编代码的相关知识。

虚表和虚表指针(vtbl & vptr)

  • 每个class产生出一堆指向virtual function的指针,放在表格之中,这个表格被称为virtual table(vtbl)。
  • 每一个class object被安插一个指针,指向相关的virtual table。通常这个指针被称为vptr。vptr的设定(setting)和重置(resetting)都由每一个class的constructor、destructor和copy assignment运算符自动完成。每一个class所关联的type_info object(用以支持runtime type identification,RTTI)也经由virtual table被指出来,通常放在表格的第一个slot(clang++不是放在第一个slot,文章后面会讲到)。

接下来笔者写两个测试用例来帮大家分析clang++编译器下的vtbl & vptr。

首先咱们来看下vptr放在对象的什么位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class A 
{
public:
int i;
virtual void testfunc(){}
};

int main()
{
//虚函数表指针位置分析
//类:有虚函数,这个类会产生一个虚函数表。
//类对象,有一个指针,指针(vptr)会指向这个虚函数表的开始地址。
A obj;
int objLen = sizeof(obj);
std::cout<< objLen << std::endl; //x86-64 16字节

/*p1获取对象首地址,p2获取对象数据成员i的地址,两者都以低层次char *去解释,以便用旁观者角度去比较*/
char *p1 = reinterpret_cast<char *>(&obj);//reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。
char *p2 = reinterpret_cast<char *>(&(obj.i));
if(p1 == p2)//说明obj.i和obj的位置相同,说明i在对象obj内存布局的上边。虚函数表指针vptr在下边
{
std::cout<< "虚函数表指针位于对象内存的 末尾 " <<std::endl;
}
else
{
std::cout<< "虚函数表指针位于对象内存的 开头 " <<std::endl;
}
return 0;
}

运行结果如下:

可以看到 obj对象的大小是16字节,vptr位于对象的开头。

接下里咱们不走virtual机制,直接从虚表中获取虚函数并且运行对应虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Base
{
public:
virtual void f() { std::cout << "Base::f()" << std::endl; }
virtual void g() { std::cout << "Base::g()" << std::endl; }
virtual void h() { std::cout << "Base::h()" << std::endl; }
};
class Derive : public Base
{
virtual void g() { std::cout << "Derive::g()" << std::endl; }
};
int main()
{
Derive* pd = new Derive();//派生类指针。
long* pd_cast = reinterpret_cast<long*>(pd);//获取较低层次上的重新解释。
long* vptr_pd = reinterpret_cast<long*>(*pd_cast);
/*解引用派生类指针得到虚表指针,而不是得到派生类,因为做了位模式的低层次转换。
得到虚表指针后,将虚表指针的类型从long重新解释为long*。
使用long过渡,是因为long在32位编译器和64位编译器所占用的空间大小和指针是一样的*/

for(int i = 0; i < 3; i++)
{
printf("vptr_pd[%d] 的内容为: %p\n",i,vptr_pd[i]);
}

typedef void(*Func)(void);//定义一个函数指针类型
Func f_pd = (Func)vptr_pd[0];
Func g_pd = (Func)vptr_pd[1];
Func h_pd = (Func)vptr_pd[2];

f_pd();
g_pd();
h_pd();

Base* pb = new Base();//派生类指针。
long* pb_cast = reinterpret_cast<long*>(pb);
long* vptr_pb = reinterpret_cast<long*>(*pb_cast);

for(int i = 0; i < 3; i++)
{
printf("vptr_pb[%d] 的内容为: %p\n",i,vptr_pb[i]);
}

typedef void(*Func)(void);//定义一个函数指针类型
Func f_pb = (Func)vptr_pb[0];
Func g_pb = (Func)vptr_pb[1];
Func h_pb = (Func)vptr_pb[2];

f_pb();
g_pb();
h_pb();

delete pd;
delete pb;
}

运行结果为:

或者用GDB命令打印出vtbl的样子:
gef➤ info vtbl pd

和上面代码运行的结果一致!值得关注的一点是,vtbl是存在于只读数据段,而vptr在堆中(vptr也可以在栈中)。
下面用一张图来描述上述代码的行为:

vptr & vtbl的创建时机以及vptr初始化问题

引用《深度探索C++对象模型》p45中的一段话:
下面两个扩张行动会在编译期间发生:

  • 一个virtual function table会被编译器产生出来,内放class的virtual functions地址
  • 在每一个class object中,一个额外的pointer member会被编译器合成出来,内含相关的class vtbl的地址

在c++中,virtual functions可以在编译时期获知。此外,这一组地址是固定不变的,执行期不可能新增或替换它。由于程序执行时,表格的大小和内容都不会改变,所以其构建和存取皆可以由编译器完全掌握,不需要执行期的任何介入。
值得注意的是vptr的初始化问题:
这里笔者就不带大家画堆栈图,过程如下!
相关ATT式反汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Derive* pd = new Derive();//派生类指针。
401222: bf 08 00 00 00 mov $0x8,%edi
401227: e8 64 fe ff ff callq 401090 <operator new(unsigned long)@plt>
40122c: 31 f6 xor %esi,%esi
40122e: 48 89 c1 mov %rax,%rcx
401231: 48 89 cf mov %rcx,%rdi
401234: ba 08 00 00 00 mov $0x8,%edx
401239: 48 89 45 80 mov %rax,-0x80(%rbp)
40123d: e8 0e fe ff ff callq 401050 <memset@plt>
401242: 48 8b 7d 80 mov -0x80(%rbp),%rdi
401246: e8 95 01 00 00 callq 4013e0 <Derive::Derive()>
40124b: 48 8b 45 80 mov -0x80(%rbp),%rax
40124f: 48 89 45 f0 mov %rax,-0x10(%rbp)

00000000004013e0 <Derive::Derive()>:
class Derive : public Base
4013e0: 55 push %rbp
4013e1: 48 89 e5 mov %rsp,%rbp
4013e4: 48 83 ec 10 sub $0x10,%rsp
4013e8: 48 89 7d f8 mov %rdi,-0x8(%rbp)
4013ec: 48 8b 45 f8 mov -0x8(%rbp),%rax
4013f0: 48 89 c1 mov %rax,%rcx
4013f3: 48 89 cf mov %rcx,%rdi
4013f6: 48 89 45 f0 mov %rax,-0x10(%rbp)
4013fa: e8 21 00 00 00 callq 401420 <Base::Base()>
4013ff: 48 b8 70 20 40 00 00 movabs $0x402070,%rax
401406: 00 00 00
401409: 48 05 10 00 00 00 add $0x10,%rax
40140f: 48 8b 4d f0 mov -0x10(%rbp),%rcx
401413: 48 89 01 mov %rax,(%rcx)
401416: 48 83 c4 10 add $0x10,%rsp
40141a: 5d pop %rbp
40141b: c3 retq
40141c: 0f 1f 40 00 nopl 0x0(%rax)

0000000000401420 <Base::Base()>:
class Base
401420: 55 push %rbp
401421: 48 89 e5 mov %rsp,%rbp
401424: 48 b8 d0 20 40 00 00 movabs $0x4020d0,%rax
40142b: 00 00 00
40142e: 48 05 10 00 00 00 add $0x10,%rax
401434: 48 89 7d f8 mov %rdi,-0x8(%rbp)
401438: 48 8b 4d f8 mov -0x8(%rbp),%rcx
40143c: 48 89 01 mov %rax,(%rcx)
40143f: 5d pop %rbp
401440: c3 retq
401441: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
401448: 00 00 00
40144b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)

该图对应mov %rax,-0x80(%rbp)执行完。已经发生的事情有:new表达式通过operator new(unsigned long)这个动态函数申请了首地址为0x0000000000416eb0的堆内存,并将其赋给了栈空间为rbp-0x80(0x00007fffffffe060)的地址中,下一条反汇编代码将会为首地址为0x0000000000416eb0的堆内存进行内存初始化。
(延迟绑定:动态函数比静态函数绑定要晚静态函数在a.out生成的时候地址已经被绑定
动态函数需要动态库加载到内存中,然后用动态库里面的函数地址替换掉类似于反汇编出来的@plt,之后得到动态函数的地址进行调用。)


该图对应0x40143c <Base::Base()+28> mov QWORD PTR [rcx], rax执行完。已经发生的事情有:调用了Derive合成默认构造函数,并且在其中又调用了Base合成默认构造函数,首地址为0x0000000000416eb0的堆内存指针赋给了第一参数寄存器rdi0x4020d0立即数(Base虚表地址)赋给了临时寄存器raxrdi寄存器又将堆内存指针赋给了Base合成默认构造函数的rbp-0x8地址上,最后由rax寄存器将Base虚表地址赋给了堆内存指针所指向的地址中。至此new出来的Derive类对象的vptr已经获取到Base类的虚表地址(注意现在还没有获取到Derive类的虚表地址)。


该图对应0x401413 <Derive::Derive()+51> mov QWORD PTR [rcx], rax执行完。已经发生的事情有:Base的合成默认构造函数return,接着0x402070立即数赋给了rax寄存器,rax将其值加上0x10后就得到了Derive类的虚表地址。然后rax将该地址赋给了堆内存指针所指向的地址中。至此伴随着Derive合成默认构造函数的结束,new出来的Derive类对象的vptr获得了自己的虚表地址。

vptr初始化小结

可以发现,vptr的初始化并不是一开始就获得了自己的虚表地址,而是伴随父类合成默认构造函数的结束,先获得父类虚表地址,然后伴随自身合成默认构造函数的结束,再获得自身虚表地址。

编译器会合成出nontrivial default constructor的4种情况(《深度探索c++对象模型》p40)

  • 带有default constructor的member class object
    如果一个class没有任何constructor,但它内含一个member object,而后者有default constructor,那么这个class的implicit default constructor就是nontrivial,编译器需要为该class合成出一个default constructor。
  • 带有default constructor的base class
    如果一个没有任何constructor的class派生自一个带有default constructor的base class,那么这个derived class的default constructor会被视为nontrivial,并因此需要被合成出来。它将调用上一层base classes的default constructor(根据它们的声明顺序)。
  • 带有一个virtual function的class
    对于那些未声明任何constructor的classes,编译器会为它们合成一个default constructor,以便正确地初始化每一个class object的vptr。
  • 带有一个virtual base class的class
    Virtual base class 的实现方法在不同编译器之间有极大的差异。然而,每一种实现方法的共同点在于必须使virtual base class在其每一个derived class object中的位置,能够于执行期准备妥当。在未声明任何constructor的derived class object中,编译器必须为它合成一个default constructor,并安插那些“允许每一个virtual base class的执行期存取操作”的代码。

多重继承下的virtual function,thunk,以及编译期绑定和运行期绑定(动态绑定)

在多重继承中支持virtual function,其复杂度围绕在第二个以及后继的base classes身上,以及“必须在执行期调整this指针”这一点,以下面的class体系为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
class Base1
{
public:
virtual void f()
{
std::cout << "base1::f()" << std::endl;
}
virtual void g()
{
std::cout << "base1::g()" << std::endl;
}
};
//基类2
class Base2
{
public:
virtual void h()
{
std::cout << "base2::h()" << std::endl;
}
virtual void i()
{
std::cout << "base2::i()" << std::endl;
}
};
//子类
class Derived:public Base1,public Base2
{
public:
virtual void f()//覆盖父类1的虚函数
{
std::cout << "derived::f()" << std::endl;
}
virtual void i()//覆盖父类2的虚函数
{
std::cout << "derived::i()" << std::endl;
}

//我们自己的虚函数
virtual void myFunc1()
{
std::cout << "derived::myFunc1()" << std::endl;
}
//非虚函数
void myFunc2()
{
std::cout << "derived::myFunc2()" << std::endl;
}

};
int main()
{
std::cout << sizeof(Base1) << std::endl;
std::cout << sizeof(Base2) << std::endl;
std::cout << sizeof(Derived) << std::endl;

Derived ins;
Base1& b1 = ins;//为了支持多态《深度探索c++对象模型p25》
Base2& b2 = ins;
Derived& d = ins;

typedef void(*Func)(void);
/*获得Base1类的第虚表指针*/
Base1 temp1;
long* base1_cast = reinterpret_cast<long*>(&temp1);//重新解释类对象,获得虚表指针的地址。
long* base1_vptr = reinterpret_cast<long*>(*base1_cast);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
//打印虚函数地址
for(int i = 0; i < 2; i++)
{
printf("base1_vptr[%d] 的内容为: %p\n",i,base1_vptr[i]);
((Func)base1_vptr[i])();
}

std::cout << "----------------------------" << std::endl;

/*获得Base2类的第虚表指针*/
Base2 temp2;
long* base2_cast = reinterpret_cast<long*>(&temp2);//重新解释类对象,获得第虚表指针的地址。
long* base2_vptr = reinterpret_cast<long*>(*base2_cast);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
//打印虚函数地址
for(int i = 0; i < 2; i++)
{
printf("base2_vptr[%d] 的内容为: %p\n",i,base2_vptr[i]);
((Func)base2_vptr[i])();
}

std::cout << "----------------------------" << std::endl;

/*获得derived类的第一个虚表指针*/
long* ins_cast1 = reinterpret_cast<long*>(&ins);//重新解释类对象,获得第一个虚表指针的地址。
long* vptr1 = reinterpret_cast<long*>(*ins_cast1);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
//打印虚函数地址
for(int i = 0; i < 4; i++)
{
printf("vptr1[%d] 的内容为: %p\n",i,vptr1[i]);
((Func)vptr1[i])();
}

std::cout << "----------------------------" << std::endl;

/*获得derived类的第二个虚表指针*/
long* ins_cast2 = ins_cast1 + 1;//偏移到第二个虚表指针的地址上
long* vptr2 = reinterpret_cast<long*>(*ins_cast2);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
//打印虚函数地址
for(int i = 0; i < 2; i++)
{
printf("vptr2[%d] 的内容为: %p\n",i,vptr2[i]);
((Func)vptr2[i])();
}

std::cout << "----------------------------" << std::endl;

//动态绑定
b1.f();
b2.i();
d.f();
d.i();
d.g();
d.myFunc1();
//编译期绑定
d.myFunc2();
}
在这个例子中,我们的子类会有两个虚指针,两张虚表,第一张虚表是重写父类虚函数、自身虚函数以及base1虚函数共享的虚表,第二张则是针对base2的。下面来看下运行结果:
重写的父类虚函数:f()i()、自身虚函数:myFunc1()、base1虚函数:g()都在第一张虚表。而第二张虚表中,h()的出现很正常,但是这个被重写的父类虚函数i(),为什么会再次出现呢?,不应该只在表一出现吗?
这里表二中被重写的父类虚函数i(),其真名叫nonvirtual thunk函数。
咱们用指令来更清晰的观察Derived类对象ins的两张虚表:
(注意: 必须在支持多态的情况下观察Derived类对象ins,具体原因请参考《深度探索c++对象模型》p13)

上文说过“必须在执行期调整this指针”,而thunk函数的真正目的为:

  • 以适当的offset值调整this指针
  • 跳到virtual function去

其实thunk函数就是一段assembly代码,下面我会带大家一起揭开thunk函数的真正面纱!
首先要想知道thunk函数干了什么,就要想个办法去调用它,然后跟踪进去。我们上面的示例代码中有一行代码:b2.i();会调用thunk函数。原因是Base2& b2 = ins;中,b2的this指针指向的ins对象的第二个虚表指针,在调用ins对象的被重写父类虚函数i()时,需要调整this指针。
具体分析过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    b2.i();
401553: 48 8b 45 d8 mov -0x28(%rbp),%rax//rbp-0x28里面存着b2的this指针,指向ins对象的第二个虚表指针
401557: 48 8b 08 mov (%rax),%rcx//将ins虚表二的第一个虚函数指针赋给了rcx寄存器
40155a: 48 89 c7 mov %rax,%rdi//将b2的this指针存到rdi寄存器作为thunk函数的第一参数
40155d: ff 51 08 callq *0x8(%rcx)//偏移0x8字节后调用thunk函数,*0x8(%rcx)的值为0x4017f0

00000000004017f0 <non-virtual thunk to Derived::i()>:
4017f0: 55 push %rbp
4017f1: 48 89 e5 mov %rsp,%rbp
4017f4: 48 89 7d f8 mov %rdi,-0x8(%rbp)//将b2的this指针存放到该thunk函数rbp-0x8的地址中
4017f8: 48 8b 45 f8 mov -0x8(%rbp),%rax//将b2的this指针存放到rax临时寄存器中
4017fc: 48 83 c0 f8 add $0xfffffffffffffff8,%rax//this指针的值(0x00007fffffffe120)加上0xfffffffffffffff8,因为溢出,相当于0x00007fffffffe120-0x8,结果为0x00007fffffffe118即ins对象的首地址
401800: 48 89 c7 mov %rax,%rdi//将偏移后的this指针存到rdi第一参数寄存器中,作为<Derived::i()>的参数
401803: 5d pop %rbp
401804: e9 27 ff ff ff jmpq 401730 <Derived::i()>
401809: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
后面就是调用虚函数<Derived::i()>后的过程,不再赘述。经过分析,可以发现thunk函数就做了两件事,以适当的offset值调整this指针,跳到virtual function去。和上面说的两点完全一致。

编译期绑定和运行期绑定(动态绑定)

还是用上面的示例,可以看到子类中有定义一个非虚函数。咱们就来看下什么是运行期绑定和动态绑定的区别是什么。
在此之前,先看下绑定的概念:调用代码跟函数地址什么时候关联到一起。

1
2
//编译期绑定
d.myFunc2();
1
2
3
d.myFunc2();
401596: 48 8b 7d d0 mov -0x30(%rbp),%rdi
40159a: e8 d1 00 00 00 callq 401670 <Derived::myFunc2()>
编译期绑定:调用代码跟函数在编译期就关联到一起。可以看到函数调用是直接调用指定地址的函数,而且机器码会出现E8的字样,表示直接调用,俗称E8CALL
1
2
3
4
5
6
7
//动态绑定
b1.f();
b2.i();
d.f();
d.i();
d.g();
d.myFunc1();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 b1.f();
401540: 48 8b 4d e0 mov -0x20(%rbp),%rcx
401544: 48 8b 11 mov (%rcx),%rdx
401547: 48 89 cf mov %rcx,%rdi
40154a: 48 89 85 20 ff ff ff mov %rax,-0xe0(%rbp)
401551: ff 12 callq *(%rdx)
b2.i();
401553: 48 8b 45 d8 mov -0x28(%rbp),%rax
401557: 48 8b 08 mov (%rax),%rcx
40155a: 48 89 c7 mov %rax,%rdi
40155d: ff 51 08 callq *0x8(%rcx)
d.f();
401560: 48 8b 45 d0 mov -0x30(%rbp),%rax
401564: 48 8b 08 mov (%rax),%rcx
401567: 48 89 c7 mov %rax,%rdi
40156a: ff 11 callq *(%rcx)
d.i();
40156c: 48 8b 45 d0 mov -0x30(%rbp),%rax
401570: 48 8b 08 mov (%rax),%rcx
401573: 48 89 c7 mov %rax,%rdi
401576: ff 51 10 callq *0x10(%rcx)
d.g();
401579: 48 8b 45 d0 mov -0x30(%rbp),%rax
40157d: 48 89 c1 mov %rax,%rcx
401580: 48 8b 00 mov (%rax),%rax
401583: 48 89 cf mov %rcx,%rdi
401586: ff 50 08 callq *0x8(%rax)
d.myFunc1();
401589: 48 8b 45 d0 mov -0x30(%rbp),%rax
40158d: 48 8b 08 mov (%rax),%rcx
401590: 48 89 c7 mov %rax,%rdi
401593: ff 51 18 callq *0x18(%rcx)
动态绑定:调用代码跟函数在运行期才能关联到一起。通过上面的反汇编代码可以发现,函数的调用地址是基于某个寄存器的偏移值而确定的,无法在编译期确定。而且在函数调用机器码中会出现ff的字样,表示间接调用,俗称FFCALL。动态绑定还有一个别名叫多态,只有虚函数才能是动态绑定。

多重继承下的成员布局以及this指针偏移

经过上文的分析,我们已经熟知多重继承下虚机制是如何工作的。这一小节,我将带大家分析父类含成员变量时多重继承的成员布局。在父类指针支持多态时会发生this指针偏移,delete this指针偏移后的父类指针会导致异常。说的有点繁琐,不要紧,咱们看下文:
代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//基类1
class Base1
{
public:
Base1():b1(1) {}
virtual void f()
{
std::cout << "base1::f()" << std::endl;
}
int b1;
};
//基类2
class Base2
{
public:
Base2():b2(2) {}
virtual void h()
{
std::cout << "base2::h()" << std::endl;
}
int b2;
};
//子类
class Derived:public Base1,public Base2
{
public:
Derived():m(3),n(4) {}
virtual void f()//覆盖父类1的虚函数
{
std::cout << "derived::f()" << std::endl;
}
virtual void h()
{
std::cout << "derived::h()" << std::endl;
}
int m;
int n;
};
int main()
{
printf("Derived类的大小%d\n",sizeof(Derived));
//打印成员变量的偏移值
printf("Derived::b1 = %d\n",&Derived::b1);//b1是基于Base1的偏移
printf("Derived::b2 = %d\n",&Derived::b2);//b2是基于Base2的偏移
printf("Derived::m = %d\n",&Derived::m);//m基于Derived的偏移
printf("Derived::n = %d\n",&Derived::n);//n基于Derived的偏移

Derived obj;
Base1 *pbase1 = &obj;//this指针没有调整。
Base2 *pbase2 = &obj;//this指针向下调整0x10字节。

//现在我们知道Base2在支持多态时会调整this指针。如果我们的obj是new出来的,当我们用delete去删除pbase2时会发生什么?
Base2 *new_pbase2 = new Derived();
//delete new_pbase2;异常
delete (Derived*)new_pbase2;//让编译器重新调整this指针,再delete掉。
}
运行结果如下:

根据数据成员的偏移值,我们可以得出该类的成员布局:

用GDB打印出空间布局,以及虚表内的虚函数:

和上述说明一致!
值得关注的一个问题是,Base2在支持多态时会调整this指针。如果我们的obj是new出来的,当我们用delete去删除pbase2时会报异常,其原因在于直接delete调整后的this指针无法将new出来的derived类对象完整删除。除非我们将该this指针继续调整到derived类对象的开头,或者在父类和基类中添加virtual析构函数。
下一小节咱们继续深入探讨多重继承下virtual析构函数的工作原理以及虚表对应的变化!

多重继承下virtual析构函数的工作原理以及虚表的变化

继上一小节,我们已经知道多重继承下数据成员的分布,为了更好的解决delete this指针偏移后的父类指针,我们必须在父类和基类中添加virtual析构函数。这样编译器就会为虚表多加几个槽,目的在于帮助this指针偏移后的父类指针回调到derived类对象的开头,进而完成delete。
示例代码和上一小节差不多,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//基类1
class Base1
{
public:
Base1():b1(1) {}
virtual void f()
{
std::cout << "base1::f()" << std::endl;
}
virtual ~Base1() {}
int b1;
};
//基类2
class Base2
{
public:
Base2():b2(2) {}
virtual void h()
{
std::cout << "base2::h()" << std::endl;
}
virtual ~Base2() {}
int b2;
};
//子类
class Derived:public Base1,public Base2
{
public:
Derived():m(3),n(4) {}
virtual void f()//覆盖父类1的虚函数
{
std::cout << "derived::f()" << std::endl;
}
virtual void h()
{
std::cout << "derived::h()" << std::endl;
}
virtual ~Derived() {}
int m;
int n;
};
int main()
{
Derived *new_pderived = new Derived();
//打印第一张虚表
long* pderived_cast1 = reinterpret_cast<long*>(new_pderived);//重新解释类对象,获得虚表指针的地址。
long* pderived_vptr1 = reinterpret_cast<long*>(*pderived_cast1);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
for(int i = 0; i < 4; i++)
{
printf("pderived_vptr1[%d] 的内容为: %p\n",i,pderived_vptr1[i]);
}
std::cout << "----------------------------" << std::endl;
//打印第二张虚表
long* pderived_cast2 = pderived_cast1 + 2;//偏移到第二个虚表指针的地址上
long* pderived_vptr2 = reinterpret_cast<long*>(*pderived_cast2);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
for(int i = 0; i < 3; i++)
{
printf("pderived_vptr2[%d] 的内容为: %p\n",i,pderived_vptr2[i]);
}
delete new_pderived;

Base2 *new_pbase2 = new Derived();
delete new_pbase2;
}
其对应的反汇编代码如下(我将有用的部分贴在下面):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
    Base2 *new_pbase2 = new Derived();
40136e: e8 0d fd ff ff callq 401080 <operator new(unsigned long)@plt>
401373: 48 89 c1 mov %rax,%rcx
401376: 48 89 c2 mov %rax,%rdx
401379: 48 89 c7 mov %rax,%rdi
40137c: 48 89 4d 88 mov %rcx,-0x78(%rbp)
401380: 48 89 55 80 mov %rdx,-0x80(%rbp)
401384: e8 87 00 00 00 callq 401410 <Derived::Derived()>
401389: e9 00 00 00 00 jmpq 40138e <main+0x16e>
40138e: 31 c0 xor %eax,%eax
401390: 89 c1 mov %eax,%ecx
401392: 48 8b 55 80 mov -0x80(%rbp),%rdx
401396: 48 83 fa 00 cmp $0x0,%rdx
40139a: 48 89 8d 78 ff ff ff mov %rcx,-0x88(%rbp)
4013a1: 0f 84 11 00 00 00 je 4013b8 <main+0x198>
4013a7: 48 8b 45 80 mov -0x80(%rbp),%rax
4013ab: 48 05 10 00 00 00 add $0x10,%rax
4013b1: 48 89 85 78 ff ff ff mov %rax,-0x88(%rbp)
4013b8: 48 8b 85 78 ff ff ff mov -0x88(%rbp),%rax
4013bf: 48 89 45 a8 mov %rax,-0x58(%rbp)
delete new_pbase2;
4013c3: 48 8b 45 a8 mov -0x58(%rbp),%rax
4013c7: 48 83 f8 00 cmp $0x0,%rax
4013cb: 48 89 85 70 ff ff ff mov %rax,-0x90(%rbp)
4013d2: 0f 84 10 00 00 00 je 4013e8 <main+0x1c8>
4013d8: 48 8b 85 70 ff ff ff mov -0x90(%rbp),%rax
4013df: 48 8b 08 mov (%rax),%rcx
4013e2: 48 89 c7 mov %rax,%rdi
4013e5: ff 51 10 callq *0x10(%rcx)
4013e8: 8b 45 fc mov -0x4(%rbp),%eax
4013eb: 48 81 c4 90 00 00 00 add $0x90,%rsp
4013f2: 5d pop %rbp
4013f3: c3 retq

0000000000401540 <Derived::~Derived()>:
virtual ~Derived() {}
401540: 55 push %rbp
401541: 48 89 e5 mov %rsp,%rbp
401544: 48 83 ec 10 sub $0x10,%rsp
401548: 48 89 7d f8 mov %rdi,-0x8(%rbp)
40154c: 48 8b 45 f8 mov -0x8(%rbp),%rax
401550: 48 89 c1 mov %rax,%rcx
401553: 48 81 c1 10 00 00 00 add $0x10,%rcx
40155a: 48 89 cf mov %rcx,%rdi
40155d: 48 89 45 f0 mov %rax,-0x10(%rbp)
401561: e8 aa 01 00 00 callq 401710 <Base2::~Base2()>
401566: 48 8b 45 f0 mov -0x10(%rbp),%rax
40156a: 48 89 c7 mov %rax,%rdi
40156d: e8 1e 01 00 00 callq 401690 <Base1::~Base1()>
401572: 48 83 c4 10 add $0x10,%rsp
401576: 5d pop %rbp
401577: c3 retq
401578: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
40157f: 00

0000000000401580 <Derived::~Derived()>:
401580: 55 push %rbp
401581: 48 89 e5 mov %rsp,%rbp
401584: 48 83 ec 10 sub $0x10,%rsp
401588: 48 89 7d f8 mov %rdi,-0x8(%rbp)
40158c: 48 8b 45 f8 mov -0x8(%rbp),%rax
401590: 48 89 c7 mov %rax,%rdi
401593: 48 89 45 f0 mov %rax,-0x10(%rbp)
401597: e8 a4 ff ff ff callq 401540 <Derived::~Derived()>
40159c: 48 8b 45 f0 mov -0x10(%rbp),%rax
4015a0: 48 89 c7 mov %rax,%rdi
4015a3: e8 b8 fa ff ff callq 401060 <operator delete(void*)@plt>
4015a8: 48 83 c4 10 add $0x10,%rsp
4015ac: 5d pop %rbp
4015ad: c3 retq
4015ae: 66 90 xchg %ax,%ax

0000000000401610 <non-virtual thunk to Derived::~Derived()>:
401610: 55 push %rbp
401611: 48 89 e5 mov %rsp,%rbp
401614: 48 89 7d f8 mov %rdi,-0x8(%rbp)
401618: 48 8b 45 f8 mov -0x8(%rbp),%rax
40161c: 48 83 c0 f0 add $0xfffffffffffffff0,%rax
401620: 48 89 c7 mov %rax,%rdi
401623: 5d pop %rbp
401624: e9 17 ff ff ff jmpq 401540 <Derived::~Derived()>
401629: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)

0000000000401630 <non-virtual thunk to Derived::~Derived()>:
401630: 55 push %rbp
401631: 48 89 e5 mov %rsp,%rbp
401634: 48 89 7d f8 mov %rdi,-0x8(%rbp)
401638: 48 8b 45 f8 mov -0x8(%rbp),%rax
40163c: 48 83 c0 f0 add $0xfffffffffffffff0,%rax
401640: 48 89 c7 mov %rax,%rdi
401643: 5d pop %rbp
401644: e9 37 ff ff ff jmpq 401580 <Derived::~Derived()>
401649: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
因为virtual析构函数的加入,我将打印的步骤简化了,否则显示会混乱。运行结果如下:

这种情况下GDB打印出来的虚表内容有错误,或者是它故意隐瞒,其原因不得而知,我将补充后的结果贴在这里:
1
2
3
4
5
6
7
8
9
10
gef➤  info vtbl *new_pderived
vtable for 'Derived' @ 0x4020b0 (subobject @ 0x416eb0):
[0]: 0x401500 <Derived::f()>
[1]: 0x401540 <Derived::~Derived()>
[2]: 0x401580 <Derived::~Derived()>
[3]: 0x4015b0 <Derived::h()>
vtable for 'Base2' @ 0x4020e0 (subobject @ 0x416ec0):
[0]: 0x4015f0 <non-virtual thunk to Derived::h()>
[1]: 0x401610 <non-virtual thunk to Derived::~Derived()>//笔者补充上的
[2]: 0x401630 <non-virtual thunk to Derived::~Derived()>//笔者补充上的
我们重点关注最后两行:Base2 *new_pbase2 = new Derived();delete new_pbase2;看编译如何完成this指针的回调:

反汇编代码运行到0x4013c3 <main+419> mov rax, QWORD PTR [rbp-0x58]之前发生的事情有:Derived类对象在0x416eb0堆内存地址上进行构造,0x416eb0+0x10后的堆指针(Base2类对象的this指针)存储在$rbp-0x58(0x7fffffffe0d8)栈内存中。这一过程的结果我用一张图进行表达:


该图在0x4013e5 <main+453> call QWORD PTR [rcx+0x10]还未执行时发生的事情有:编译器将Base2类对象的this指针存储在rdi参数寄存器中,作为<non-virtual thunk to Derived::~Derived()>函数(rcx+0x10)的第一参数。该函数指针指向文本段0x401630处。

反汇编代码运行到0x401644 <non-virtual+0> jmp 0x401580 <Derived::~Derived()>之前发生的事情有:rdi参数寄存器将其值保存到rax临时寄存器中,rax将该值(0x416ec0)加上0xfffffffffffffff0,由于溢出,相当于-0x10,得到的新值(0x416eb0)存储到rax寄存器中。随后调用带operator delete(void*)的virtual析构函数。
至此完成this指针回调,delete可以正常销毁掉new出来Derived类对象。

多重虚继承下的访问虚基类成员变量时虚表的工作原理,以及子类成员布局

在分析之前,咱们先回顾一下《深度探索c++对象模型》p120提出来的一个问题:
每一个对象必须针对其每一个virtual base class背负一个额外的指针,然而理想上我们却希望class object有固定的负担,不因为其virtual base classes的个数而有所变化。想想看这该如何解决?

  • 一般而言有两种解决方法。Microsoft 编译器引入所谓的virtual base class table。每一个class object如果有一个或者多个virtual base classes,就会由编译器安插一个指针,指向virtual base class table。至于真正的virtual base class指针,当然是被放在该表格中。
  • 第二个解决方法,是在virtual function table 中放置virtual base class 的 offset(而不是第一种办法中说的virtual base class指针)。编译器可以通过virtual function table的正负值来索引该offset。如果是正值,显然索引到的是virtual function;如果是负值,则是索引到virtual base class offsets。

正如上面第二种方法所述,clang编译器采取的就是基于vtbl的offset来获取虚基类的this指针,进而完成虚基类数据成员的访问。
咱们来看具体分析过程,如下:
先设计一个菱形继承的Derived类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//爷爷类
class grandpa//类名首字母忘记大写了,抱歉
{
public:
grandpa():pa(1) {}
virtual void f()
{
std::cout << "grandpa::f()" << std::endl;
}
virtual void g()
{
std::cout << "grandpa::g()" << std::endl;
}
virtual ~grandpa() {}
int pa;
};
//父类1
class Base1:virtual public grandpa
{
public:
Base1():b1(2) {}
virtual void h()
{
std::cout << "base1::h()" << std::endl;
}
virtual void i()
{
std::cout << "base1::i()" << std::endl;
}
virtual ~Base1() {}
int b1;
};
//父类2
class Base2:virtual public grandpa
{
public:
Base2():b2(3) {}
virtual void j()
{
std::cout << "base2::j()" << std::endl;
}
virtual void k()
{
std::cout << "base2::k()" << std::endl;
}
virtual ~Base2() {}
int b2;
};
//子类
class Derived:public Base1,public Base2
{
public:
Derived():m(4),n(5) {}
virtual void f()//覆盖爷爷类的虚函数
{
std::cout << "derived::f()" << std::endl;
}
virtual void h()//覆盖父类1的虚函数
{
std::cout << "derived::h()" << std::endl;
}
virtual void j()//覆盖父类2的虚函数
{
std::cout << "derived::j()" << std::endl;
}
virtual void l()//derived类自己的虚函数
{
std::cout << "derived::l()" << std::endl;
}
virtual ~Derived() {}
int m;
int n;
};
int main()
{
Derived *new_pderived = new Derived();
new_pderived->pa = 11;
delete new_pderived;

Base1 *new_pbase1 = new Derived();
new_pbase1->pa = 11;
delete new_pbase1;

Base2 *new_pbase2 = new Derived();
new_pbase2->pa = 11;
delete new_pbase2;
}
需要用到的反汇编代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
  Derived *new_pderived = new Derived();
401227: e8 44 fe ff ff callq 401070 <operator new(unsigned long)@plt>
40122c: 48 89 c1 mov %rax,%rcx
40122f: 48 89 c2 mov %rax,%rdx
401232: 48 89 c7 mov %rax,%rdi
401235: 48 89 4d c8 mov %rcx,-0x38(%rbp)
401239: 48 89 55 c0 mov %rdx,-0x40(%rbp)
40123d: e8 7e 01 00 00 callq 4013c0 <Derived::Derived()>
401242: e9 00 00 00 00 jmpq 401247 <main+0x37>
401247: 48 8b 45 c0 mov -0x40(%rbp),%rax
40124b: 48 89 45 f0 mov %rax,-0x10(%rbp)
new_pderived->pa = 11;
40124f: 48 8b 4d f0 mov -0x10(%rbp),%rcx
401253: 48 8b 11 mov (%rcx),%rdx
401256: 48 8b 52 e8 mov -0x18(%rdx),%rdx
40125a: c7 44 11 08 0b 00 00 movl $0xb,0x8(%rcx,%rdx,1)
401261: 00
delete new_pderived;
401262: 48 8b 4d f0 mov -0x10(%rbp),%rcx
401266: 48 83 f9 00 cmp $0x0,%rcx
40126a: 48 89 4d b8 mov %rcx,-0x48(%rbp)
40126e: 0f 84 0d 00 00 00 je 401281 <main+0x71>
401274: 48 8b 45 b8 mov -0x48(%rbp),%rax
401278: 48 8b 08 mov (%rax),%rcx
40127b: 48 89 c7 mov %rax,%rdi
40127e: ff 51 18 callq *0x18(%rcx)
401281: bf 38 00 00 00 mov $0x38,%edi

Base1 *new_pbase1 = new Derived();
401286: e8 e5 fd ff ff callq 401070 <operator new(unsigned long)@plt>
40128b: 48 89 c1 mov %rax,%rcx
40128e: 48 89 c2 mov %rax,%rdx
401291: 48 89 c7 mov %rax,%rdi
401294: 48 89 4d b0 mov %rcx,-0x50(%rbp)
401298: 48 89 55 a8 mov %rdx,-0x58(%rbp)
40129c: e8 1f 01 00 00 callq 4013c0 <Derived::Derived()>
4012a1: e9 00 00 00 00 jmpq 4012a6 <main+0x96>
4012a6: 48 8b 45 a8 mov -0x58(%rbp),%rax
4012aa: 48 89 45 d8 mov %rax,-0x28(%rbp)
new_pbase1->pa = 11;
4012ae: 48 8b 45 d8 mov -0x28(%rbp),%rax
4012b2: 48 8b 08 mov (%rax),%rcx
4012b5: 48 8b 49 e8 mov -0x18(%rcx),%rcx
4012b9: c7 44 08 08 0b 00 00 movl $0xb,0x8(%rax,%rcx,1)
4012c0: 00
delete new_pbase1;
4012c1: 48 8b 45 d8 mov -0x28(%rbp),%rax
4012c5: 48 83 f8 00 cmp $0x0,%rax
4012c9: 48 89 45 a0 mov %rax,-0x60(%rbp)
4012cd: 0f 84 0d 00 00 00 je 4012e0 <main+0xd0>
4012d3: 48 8b 45 a0 mov -0x60(%rbp),%rax
4012d7: 48 8b 08 mov (%rax),%rcx
4012da: 48 89 c7 mov %rax,%rdi
4012dd: ff 51 18 callq *0x18(%rcx)
4012e0: bf 38 00 00 00 mov $0x38,%edi

Base2 *new_pbase2 = new Derived();
4012e5: e8 86 fd ff ff callq 401070 <operator new(unsigned long)@plt>
4012ea: 48 89 c1 mov %rax,%rcx
4012ed: 48 89 c2 mov %rax,%rdx
4012f0: 48 89 c7 mov %rax,%rdi
4012f3: 48 89 4d 98 mov %rcx,-0x68(%rbp)
4012f7: 48 89 55 90 mov %rdx,-0x70(%rbp)
4012fb: e8 c0 00 00 00 callq 4013c0 <Derived::Derived()>
401300: e9 00 00 00 00 jmpq 401305 <main+0xf5>
401305: 31 c0 xor %eax,%eax
401307: 89 c1 mov %eax,%ecx
401309: 48 8b 55 90 mov -0x70(%rbp),%rdx
40130d: 48 83 fa 00 cmp $0x0,%rdx
401311: 48 89 4d 88 mov %rcx,-0x78(%rbp)
401315: 0f 84 0e 00 00 00 je 401329 <main+0x119>
40131b: 48 8b 45 90 mov -0x70(%rbp),%rax
40131f: 48 05 10 00 00 00 add $0x10,%rax
401325: 48 89 45 88 mov %rax,-0x78(%rbp)
401329: 48 8b 45 88 mov -0x78(%rbp),%rax
40132d: 48 89 45 d0 mov %rax,-0x30(%rbp)
new_pbase2->pa = 11;
401331: 48 8b 45 d0 mov -0x30(%rbp),%rax
401335: 48 8b 08 mov (%rax),%rcx
401338: 48 8b 49 e8 mov -0x18(%rcx),%rcx
40133c: c7 44 08 08 0b 00 00 movl $0xb,0x8(%rax,%rcx,1)
401343: 00
delete new_pbase2;
401344: 48 8b 45 d0 mov -0x30(%rbp),%rax
401348: 48 83 f8 00 cmp $0x0,%rax
40134c: 48 89 45 80 mov %rax,-0x80(%rbp)
401350: 0f 84 0d 00 00 00 je 401363 <main+0x153>
401356: 48 8b 45 80 mov -0x80(%rbp),%rax
40135a: 48 8b 08 mov (%rax),%rcx
40135d: 48 89 c7 mov %rax,%rdi
401360: ff 51 18 callq *0x18(%rcx)
401363: 8b 45 fc mov -0x4(%rbp),%eax
401366: 48 81 c4 80 00 00 00 add $0x80,%rsp
40136d: 5d pop %rbp
40136e: c3 retq
40136f: 48 89 45 e8 mov %rax,-0x18(%rbp)
401373: 89 55 e4 mov %edx,-0x1c(%rbp)
401376: 48 8b 7d c8 mov -0x38(%rbp),%rdi
Derived类的vtbl内容如下(编译器没有完全打印出来,笔者将补充后的列在下面):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
gef➤  info vtbl *new_pderived
vtable for 'Derived' @ 0x402020 (subobject @ 0x416eb0):
[0]: 0x401850 <Derived::h()>
[1]: 0x4015b0 <Base1::i()>
[2]: 0x401890 <Derived::~Derived()>
[3]: 0x4018d0 <Derived::~Derived()>
[4]: 0x401900 <Derived::f()>
[5]: 0x401940 <Derived::j()>
[6]: 0x401980 <Derived::l()>
vtable for 'Base2' @ 0x402070 (subobject @ 0x416ec0):
[0]: 0x4019c0 <non-virtual thunk to Derived::j()>
[1]: 0x401760 <Base2::k()>
[2]: 0x4019e0 <non-virtual thunk to Derived::~Derived()>//笔者补充的
[3]: 0x401a00 <non-virtual thunk to Derived::~Derived()>//笔者补充的
vtable for 'grandpa' @ 0x4020b8 (subobject @ 0x416ed8):
[0]: 0x401a20 <virtual thunk to Derived::f()>
[1]: 0x4016a0 <grandpa::g()>
[2]: 0x401a40 <virtual thunk to Derived::~Derived()>//笔者补充的
[3]: 0x401a60 <virtual thunk to Derived::~Derived()>//笔者补充的
至于为什么Base2类和grandpa类会多出两个thunk函数,上一小节已经分析过。这里补充一点,因为Derived类对象的数据布局最上面是Base1类对象,中间是Base2类对象,virtual grandpa对象永远是在最下面。这样就导致Base2类在支持多态时调用被重写的虚函数以及调用Derived类的virtual析构函数时,需要调整this指针。grandpa类道理一样。 咱们用GDB查看Derived类对象的内存布局,并用图画方式直观表达出来:


上图中,笔者将offset出现的地方明确标明出来了,下面咱们就来看下编译是如何取出这个offsets的!

反汇编代码0x401256 <main+70> mov rdx, QWORD PTR [rdx-0x18]还未执行时,发生的事情有:Derived类对象在0x416eb0堆内存中进行了构造。new_pderived指针的地址为rbp-0x10,它指向0x416eb0堆内存。编译器将Derived类vptr1指向的虚表地址存储到rdx中。
接下来将会发生的事情有:编译器将该虚表地址偏移-0x18字节并获取该地址中的内容(offset),将其值(0x28)存入rdx寄存器中。最后将十进制11存入this指针偏移后的地址中(rcx+rdx*1+0x80x416eb0+0x28*1+0x8 = 0x416ee0,即pa的地址)。

代码段new_pbase1->pa = 11;new_pbase2->pa = 11;同样会出现取offset进行偏移后,然后存取grandpa的成员变量pa的情况。new_pbase1->pa取到的offset值和new_pderived->pa一样是0x28。而new_pbase2->pa取到的值为0x18

如果是虚基类grandpa在支持多态时,且new_pgrandpa->pa进行存取操作,则不用偏移this指针,也就不需要取offset。该虚基类的虚表中也没有存offset

可以看到0x4020b8-0x18地址上的值是个牛马值 🤪


c++对象模型之虚表,虚表指针,thunk,多态,多重继承this指针偏移,多重继承virtual析构函数,多重虚继承下的访问虚基类成员变量时虚表的工作原理
https://howl144.github.io/2022/02/19/00006. c++对象模型之虚表,虚表指针等/
Author
Deng Ye
Posted on
February 19, 2022
Licensed under