简单虚拟继承
示例代码如下:
#include <iostream>
struct VBase
{
void zoo() { std::cout << "VBase::zoo()\n"; }
int vb_data = 100;
};
struct Derived : virtual public VBase
{
void foo() { std::cout << "Derived::foo()\n"; }
int d_data = 200;
};
int main(int argc, char *argv[])
{
Derived d;
d.foo();
return 0;
}
上面的代码中,并没有虚函数,那么,编译器会为它生成vtable吗?Compilerr Explorer直观地给出了答案。

既然连虚函数都没有,那要vtable还有什么用呢?为了搞懂这个问题,我们先来看看对象和vtable的内存布局,以及vtable中都有什么。

▲ 图1 简单虚拟继承下,对象和vtable的内存布局
vbase_offset
接下来,我们考虑下面的代码:
Derived *pd = new Derived;
pd->zoo();
delete pd;
因为 zoo 是虚基类VBase的函数,通过指向派生类对象的指针 pd 调用时,就需要调整 this 指针,让我们来看看对应的汇编代码:
movq -24(%rbp), %rax ; -24(%rbp)里存放的是pd指针,即上图中的0x7fffffffdf60
movq (%rax), %rax ; 指针解引用,得到对象的前8字节内容,即vtable for Derived+24,即上图中的0x555555557d58
subq $24, %rax ; 虚函数表地址减去24,得到指向vbase offset的指针,即上图的0x555555557d40
movq (%rax), %rax ; 解引用,得到vbase offset的值,即上图的12
movq %rax, %rdx ; 将vbase offset存到rdx
movq -24(%rbp), %rax ; 再次取出对象首地址,即this指针,存入rax
addq %rdx, %rax ; 即 this = this + vbase_offset = 0x7fffffffdf60 + 12 = 0x7fffffffdf6c,调整this指针,使this指针指向虚基类子对象VBase
movq %rax, %rdi ; 新的this指针存入rdi,作为调用VBase::zoo()的参数
call VBase::zoo()
也就是说,当我们需要通过派生类对象访问虚基类子对象的时候,需要通过两者之间的偏移,来调整 this 指针,这个偏移量保存在派生类的虚函数表中,即 vbase_offset 。那么,为什么当存在虚基类时,就需要 vbase_offset 呢?我们在后文中揭晓答案。
VTT
通常,vptr指向vtable中第一个虚函数,但本例中,没有虚函数,因此vptr实际上指向了vtable的外部(vtable中一共有3个条目,vptr指向了偏移量为24的位置,正好是vtable的下方)。本例中, 0x555555557d58 (即vptr指向的位置)实际指向了一个名为VTT(Virtual Table Table)的结构,顾名思义,该结构中的每一个条目都是一个指向vtable的指针,即vptr。本例中,VTT中只有一个条目,就是 vtable for Derived + 24 。这里有一点绕,我们再来捋一下。由于没有虚函数,因此vptr指向了vtable的外部,这恰好[1]是VTT,确切说是VTT中的第一个条目的位置。而本例中,VTT中有且仅有一个条目,就是指向 Derived 的虚表的vptr。因此,出现了vptr指向的内容就是vptr本身(address和value都是 0x555555557d58 )的现象。关于VTT的作用(为什么需要VTT?),光靠本例还不能很好地说明,等到后文讲到菱形继承时,再为大家详细解释。
__vmi_class_type_info成员解释
让我们再来看一下本例中 __vmi_class_type_info 的成员,不了解 __vmi_class_type_info 的读者,可以先去上篇文章学习相关的基础知识。这里仅讲解笔者认为需要重点关注的、和之前取值不一样的成员。
__flags & __base_count
__flags :本例中,既没有重复的基类(对应 __non_diamond_repeat_mask = 0x1 ),又不是菱形继承(对应 __diamond_shaped_mask = 0x2 ),因此 __flags 是0。
__base_count :本例中,只有一个直接基类,因此 __base_count 等于1。
__base_class_type_info::offset_flags
Itanium C++ ABI对此的解释是
All but the lower 8 bits of
__offset_flags
are a signed offset. For a non-virtual base, this is the offset in the object of the base subobject. For a virtual base, this is the offset in the virtual table of the virtual base offset for the virtual base referenced (negative).
即 __offset_flags 中除低8 bit以外的部分,是一个偏移量。对于非虚基类而言,表示该基类子对象在完整对象中的偏移;对于虚基类而言,表示 vtable_offset 的地址相对于vptr的偏移量(这个偏移量是一个负数)。如图1,本例中, vtable_offset 的地址是 0x555555557d40 ,vptr是 0x555555557d58 ,即 vtable_offset 相对vptr的偏移量是 0x555555557d40 - 0x555555557d58 = -24 。而 __offset_flags 的值是 0xffffffffffffe803 , 0xffffffffffffe803 >> 8 = 0xffffffffffffe8 ,正好是-24的补码表示。
__offset_flags 的低8 bit表示flags,本例中,基类 VBase 既是虚基类(对应 __virtual_mask = 0x1 ),又是public基类(对应 __public_mask = 0x2 ),因此 __offset_flags 的低8 bit是 0x03 ( __virtual_msat | __public_mask )。
为什么需要虚函数表?
现在我们知道了,当存在虚基类时,即使没有虚函数,我们也需要在虚函数表中记录必要的信息以供运行时使用,比如通过派生类对象访问虚基类子对象时所需的 vbase_offset ,动态类型转换( dynamic_cast )时所需的 typeinfo 等。
菱形继承
示例代码如下:
class GrandParent
{
public:
GrandParent() {}
virtual ~GrandParent() {}
virtual void foo() {}
virtual void zoo() {}
int grandparent_data = 100;
};
class Parent1 : virtual public GrandParent
{
public:
Parent1() {}
virtual ~Parent1() {}
virtual void foo() override {}
int parent1_data = 200;
};
class Parent2 : virtual public GrandParent
{
public:
Parent2() {}
virtual ~Parent2() {}
virtual void zoo() override {}
int parent2_data = 300;
};
class Child : public Parent1, public Parent2
{
public:
Child() {}
virtual ~Child() {}
int child_data = 400;
};
int main()
{
Child *p_child = new Child;
Parent1 *p_parent1_sub = p_child;
GrandParent *p_gp1 = p_parent1_sub;
p_gp1->foo();
Parent1 *p_parent1 = new Parent1;
GrandParent *p_gp2 = p_parent1;
p_gp2->foo();
delete p_child;
delete p_parent1;
return 0;
}
为什么需要VTT?
前文已经提到过VTT,它是一个列表,表中的每一项都是一个虚表指针。该表在对象的构造/析构过程中发挥了重要作用。本文将以 Child 类对象的构造过程为例,探究为什么需要VTT。不过在此之前,我们先给出VTT及其指向的vtable的内存布局。

▲ 图2 VTT及vtable的内存布局
上图中,cod表示complete object destructor,dd表示deleting destructor,这两种析构函数的区别在之前的文章中已详细说明,此处不再赘述。
从构造过程看VTT作用
step1:构造虚基类子对象
从下面的汇编代码可以看出,程序首先为完整对象 Child 分配了内存,并在分配的内存中构造了虚基类 GrandParent 的对象。
main:
pushq %rbp
movl $48, %edi ; operator new的参数,分配48字节内存
pushq %rbx
subq $8, %rsp
call operator new(unsigned long) ; 分配内存
movq %rax, %rbp ; new返回的指针存入%rbp
movq %rax, %rdi ; new返回的指针存入%rbp,作为参数调用Child::Child()
call Child::Child() [complete object constructor]
Child::Child() [complete object constructor]:
pushq %rbx
movq %rdi, %rbx ; %rdi保存的是new返回的地址,也是Child对象的首地址
leaq 32(%rdi), %rdi ; 首地址偏移32字节,作为参数调用虚基类构造函数
call GrandParent::GrandParent() [base object constructor]
GrandParent::GrandParent() [base object constructor]:
movq $vtable for GrandParent+16, (%rdi) ; 设置虚表指针
movl $100, 8(%rdi) ; 设置数据成员
ret
构造完成后,内存布局如下所示。

▲ 图3 虚基类子对象及其vtable的内存布局
step2:构造基类Parent1子对象
前面的文章中讲到,在构造过程中,当前正在构造谁,谁就是"临时的"完整对象,整个对象的类型就是谁,因此,虚函数指针会不断调整。但跟之前不同的是,这里使用了construction vtable这种特殊的vtable。
Child::Child() [complete object constructor]:
pushq %rbx
movq %rdi, %rbx ; %rdi保存的是new返回的地址,也是Child对象的首地址
leaq 32(%rdi), %rdi ; 首地址偏移32字节,作为参数调用虚基类构造函数
call GrandParent::GrandParent() [base object constructor]
=> movq %rbx, %rdi ; 将Child对象首地址作为调用Parent1::Parent1()的第1个参数,即this指针
movl $VTT for Child+8, %esi ; 将VTT中第2个条目的地址作为调用Parent1::Parent1()的第2个参数
call Parent1::Parent1() [base object constructor]
Parent1::Parent1() [base object constructor]:
movq (%rsi), %rax ; 将VTT中第2个条目的内容(即虚表指针construction vtable for Parent1-in-Child+24,简称虚指针1)存入%rax
movq 8(%rsi), %rdx ; 将VTT中第3个条目的内容(即虚表指针construction vtable for Parent1-in-Child+88,简称虚指针2)存入%rax
movq %rax, (%rdi) ; 将虚指针1设置到this指针指向的内存,作为Parent1的虚表指针
movq -24(%rax), %rax ; 虚指针1向上偏移24字节,取对应内容(即vbase_offset),存入%rax
movq %rdx, (%rdi,%rax) ; %rdi表示Parent1对象的首地址,%rax表示vbase_offset,两者相加,即为虚基类子对象GrandParent的地址,此句将虚指针2设置为虚基类的虚表指针
movl $200, 8(%rdi) ; 设置Parent1的数据成员
ret
构造完成后,内存布局如下所示。

▲ 图4 Parent1 作为基类子对象时,对象和Construction vtable的内存布局
为什么不能直接使用 Parent1 的vtable呢?我们不妨先看一下 Parent1 作为完整对象时,对象和vtable的内存布局。

▲ 图5 Parent1 作为完整对象时,对象和vtable的内存布局
可见,同样是 Parent1 对象,但作为基类子对象(图4)和作为完整对象(图5)相比,整体的内存布局是不一样的,即虚基类子对象 GrandParent 与 Parent1 对象的偏移量是不一样的。图4中,因为需要预留16字节给 Parent2 对象,所以 GrantParent 和 Parent1 离得更远。反映到vtable上,就是与偏移相关的条目,如 vbase_offset 和 vcall_offset ,值会不一样,图4中是32和-32,图5中是16和-16。因此,如果图5中直接使用了 Parent1 的vtable,在基类子对象 Parent1 和虚基类子对象 GrandParent 相互转换时(即 this 指针的调整),就会因使用了错误的偏移量而出错。
综上,按C++标准规定,构造到 Parent1 时, Parent1 就是"完整对象",应当使用 Parent1 的vtable。但这个"完整对象"的内存布局,和真正的完整对象的内存布局,还可能不一样,差别在虚基类子对象的偏移上。若直接使用 Parent1 的vtable,偏移量相关的条目,就会与实际情况对不上。因此,就需要准备一张"临时"的vtable,在里面填上正确的偏移量,供构造时使用,这就是所谓的construction vtable。另外,这些构造过程中使用的vtable的地址,被统一保存到了另一个表里,即VTT。还需说明的是,VTT及construction vtable,不仅在对象构造过程中会被使用,在对象析构过程中,也会被使用,因为析构是构造的逆过程。关于VTT,Itanium C++ ABI的描述如下:
To ensure that the virtual table pointers are set to the appropriate virtual tables during proper base class construction, a table of virtual table pointers, called the VTT, which holds the addresses of construction and non-construction virtual tables is generated for the complete class. The constructor for the complete class passes to each proper base class constructor a pointer to the appropriate place in the VTT where the proper base class constructor can find its set of virtual tables. Construction virtual tables are used in a similar way during the execution of proper base class destructors.
除了使用construction vtable外,对象的构造过程与上篇文章讲到的并无差别,本文不再赘述。
vtable条目解析
上文给出了虚继承条件下的vtable,包括construction vtable,但并未深入其中的各个条目,现在,让我们来一探究竟。
construction vtable里的虚析构函数指针为什么是0?
细心的读者可能观察到了,在construction vtable中,虚析构函数的地址是0,即虚析构函数被禁掉了。关于这一点,C++标准并没有明确的规定,而是GCC编译器独有的行为,比如,Clang编译器就没有这个行为。GCC这么做的原因,笔者猜测,可能是为了防止基类子对象被重复析构。比如,在构造 Parent1 时,调用了 ~Parent1() (当然,在构造函数里调用析构函数,本身就是十分奇怪的,正常不会这么用,但话说回来,C++并没有禁止这么用),这样,虚基类 GrandParet 的虚函数也会被调用,虚基类也会被析构。等到构造 Parent2 时, ~Parent2() 也可能被调用,这样, GrandParent 会再次被析构,这样就会出现double free的问题。当然,这只是笔者的一个猜测,如果读者有其它的观点,欢迎不吝赐教。
vbase_offset的作用
考虑下面的代码:
int main()
{
Child *p_child = new Child;
Parent1 *p_parent1_sub = p_child;
GrandParent *p_gp1 = p_parent1_sub; // Parent1作为Child的基类子对象,转为虚基类GrandParent
p_gp1->foo();
Parent1 *p_parent1 = new Parent1;
GrandParent *p_gp2 = p_parent1; // Parent1作为Child的基类子对象,转为虚基类GrandParent
p_gp2->foo();
delete p_child;
delete p_parent1;
return 0;
}
当拿到一个 Parent1 类型的指针时,程序并不知道这个指针指向的是一个完整的 Parent1 对象,还是某个其它对象的基类子对象。而从图4和图5我们了解到,同样是 Parent1 对象,但作为基类子对象和完整对象时,到虚基类对象的距离是不一样的。因此,在向虚基类子对象转换时,是不能使用一个固定的偏移量的,而是需要一个"随机应变"的偏移量,这正是保存在vtable中的 vbase_offset 。
让我们通过汇编代码探究一下,当派生类指针/引用向基类指针/引用转换时,是如何利用 vbase_offset 调整指针值的。
main:
pushq %rbp
movl $48, %edi ; 需要分配的内存大小
pushq %rbx
subq $8, %rsp
; 建议配合图6来理解下面的代码
call operator new(unsigned long) ; 分配48字节内存
movq %rax, %rbp ; 分配的内存的首地址存入%rbp和%rdi
movq %rax, %rdi
call Child::Child() [complete object constructor] ; 构造Child对象
; 因为Parent1子对象的首地址和Child对象的首地址是一样的,因此,
; Parent1 *p_parent1_sub = p_child; 一句不需要调整指针,在-O2优化级别下,
; 没有生成汇编代码。下面的代码,是 Parent1* 向 GrandParent* 转换的汇编代码
movq 0(%rbp), %rax ; 在对象首地址处取出虚表指针,存入%rax
movq -24(%rax), %rdi ; 虚表指针向上偏移24字节,正好指向vbase_offset,将vbase_offset的值(这里是32)存入%rdi
addq %rbp, %rdi ; 这里相当于%rdi = %rbp + %rdi = Parent1子对象首地址 + vbase_offset(32),因此得到的是虚基类子对象GrandParent的首地址
movq (%rdi), %rax ; 从首地址取出GrandParent的虚表指针
call *16(%rax) ; 虚表指针加16,正是virtual trunk to Parent1::foo()条目的地址,
; 取出该地址处的值,正是virtual trunk to Parent1::foo()的地址,调用该函数
; 建议配合图5来理解下面的代码
movl $32, %edi
call operator new(unsigned long) ; 为构造Parent1对象,先分配32字节
movq %rax, %rbx ; 分配的内存的首地址存入%rbp和%rdi
movq %rax, %rdi
call Parent1::Parent1() [complete object constructor] ; 构造对象
movq (%rbx), %rax ; 在对象首地址处取出虚表指针,存入%rax
movq -24(%rax), %rdi ; 虚表指针向上偏移24字节,正好指向vbase_offset,将vbase_offset的值(这里是16)存入%rdi
addq %rbx, %rdi ; 这几句和上面相同,不再赘述
movq (%rdi), %rax
call *16(%rax)
下面给出 Child 对象及其vtable的内存布局,方便读者对照着理解上面的汇编代码。

▲ 图6 Child 对象及其vtable的内存布局
两种constructor
在上文中,一共出现了两种构造函数, complete object constructor 和 base object constructor 。顾名思义, complete object constructor 用来构造完整对象,它不仅会构造自己,还会构造其基类;而 base object constructor 是在 complete object constructor 中被调用的,用来构造基类子对象(base object)。
当 complete object constructor 调用 base object constructor 时,会传递两个参数,第一个是指向基类子对象的this指针,第二个是基类子对象vptr的地址(注意是vptr的地址而不是vptr),这个地址就是VTT中的某个条目。在 base object constructor 中,会使用第二个参数为基类子对象设置虚表指针,还会使用第二个参数寻址虚基类的vptr,为虚基类子对象设置虚表指针。
以上过程,在上文的代码中得到了完整的体现,读者可以配合代码来加深理解。
vcall_offset详解
考虑下面的代码:
Child *p_child = new Child;
Parent1 *p_parent1_sub = p_child;
GrandParent *p_gp1 = p_parent1_sub;
p_gp1->foo();
由于 Parent1 重写了 GrandParent 的虚函数,根据C++多态特性,这里应该调用 Parent1::foo() ,但此时 this 指向的是虚基类子对象 GrandParent ,并不是 Parent1 ,因此需要先调整 this 指针再调用正确的函数,那么,谁来做这件事情?怎么做?答案是 virtual trunk to Parent1::foo() 函数利用 vcall_base 来做。让我们通过汇编代码一探究竟。
virtual thunk to Parent1::foo():
movq (%rdi), %r10 ; %rdi指向虚基类子对象,这里是取出虚基类的虚表指针,存入%r10
addq -32(%r10), %rdi ; 如图6,虚表指针减32,正好是foo()函数对应的vcall_offset
jmp .LTHUNK2 ; .LTHUNK2是Parent1::foo()的别名
现在我们知道了,由派生类访问基类,靠的是 vbase_offset ,由基类访问派生类,靠的是 vcall_offset 。
具体地,以图6为例:
-
vcall_offset 存在于虚基类的vtable中。
-
与虚基类中的虚函数一一对应,每个虚函数对应一个 vcall_offset ,只有一个例外------两个虚析构函数对应一个 vcall_offset 。
-
vbase_offset 在vtable中的排列顺序,与虚函数的排列顺序正好相反。以图6为例,从低地址到高地址,虚函数排布依次是两个 virtual trunk to Child::~Child() 、 virtual trunk to Parent1::foo() 、 virtual trunk to Parent2::zoo() ,与此相对, vcall_offset 的排布依次对应 virtual trunk to Parent2::zoo() 、 virtual trunk to Parent1::foo() 、两个 virtual trunk to Child::~Child() 。
为什么需要多个 vcall_offset ?因为重写各个虚函数的派生类可能是不一样的, this 指针需要调整到不同的派生类,偏移量也是不一样的。例如,对于虚析构函数,根据多态性,应该调用完整类型 Child 的析构函数,因此 this 指针应该偏移-32以便指向完整类型;对于 foo() ,因为被 Parent1 重写了,所以 this 指针需要指针 Parent1 子对象,偏移量也是-32;对于 zoo() ,因为被 Parent2 重写了,所以 this 指针需要指向 Parent2 子对象,偏移量就变成了-16。
特别的,如图5所示,当 Parent1 作为完整对象时,由于 Parent1 并没有重写 zoo() ,因此调用的仍然是 GrandParent::zoo() ,不需要调整 this 指针,因此 vcall_offset 条目被置0。
有读者可能要问了,如果 Parent2 也重写了 foo() ,结果会怎么样呢?结果会编译报错。因为会产生歧义,编译器不允许这么做。读者可以自行试验。
总结
本文首先以简单虚拟继承为例,向读者展示了在虚继承条件下,即使没有虚函数,也会存在虚函数表,用来记录 this 指针调整、动态类型转换等所需的信息。接下来,以菱形继承为例,详细介绍了VTT以及的construction vtable,深入探讨了该结构存在的原因以及在对象构造/析构中的作用。最后,详细讲解了虚析构函数、构造函数、 vbase_offset 和 vcall_offset 等与虚继承相关的虚表条目。
由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。
备注
1\] 之所以说是"恰好",是因此编译器将内存程序运行的结构紧凑排布,恰好将VTT排在了 Derived 的vtable的后面。