虚函数表里有什么?(四)——虚拟继承

简单虚拟继承

示例代码如下:

复制代码
#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为例:

  1. vcall_offset 存在于虚基类的vtable中。

  2. 与虚基类中的虚函数一一对应,每个虚函数对应一个 vcall_offset ,只有一个例外------两个虚析构函数对应一个 vcall_offset 。

  3. 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的后面。

相关推荐
VU-zFaith87034 分钟前
C++概率论算法详解:理论基础与实践应用
c++·算法·概率论
小葡萄202541 分钟前
黑马程序员C++核心编程笔记--4 类和对象--多态
java·c++·笔记
iCxhust1 小时前
Prj09--8088单板机C语言8253产生1KHz方波(1)
c语言·开发语言·c++·单片机·嵌入式硬件·mcu
Yusei_05231 小时前
C++ 模版复习
android·java·c++
YxVoyager2 小时前
OpenCV C++ 学习笔记(五):颜色空间转换、数值类型转换、图像混合、图像缩放
c++·opencv
Bob99982 小时前
Logitech (罗技)单通道、双通道与6通道 Unifying 接收器:USB ID、功能与实用性解析
java·网络·c++·python·stm32·单片机·嵌入式硬件
小wanga2 小时前
【C++项目】负载均衡在线OJ系统-1
开发语言·c++·负载均衡
满天星83035773 小时前
【C++】内存管理
开发语言·c++
dvlinker3 小时前
使用Process Explorer、System Informer(Process Hacker)和Windbg工具排查软件高CPU占用问题
c++·windbg·processexplorer·软件高cpu占用·process hacker·system informer·线程的函数调用堆栈
小林C语言3 小时前
C++条件运算符和条件表达式 | 大写转小写
c++