C++虚继承对象内存模型

Published: Wed 28 February 2024
By azjf

In cpp.

tags: cpp

1. 前言

C++允许多重继承,而多重继承会导致一个基类可能被多个子类从多条路径继承多次,进而导致二义性问题。 比如,当B1和B2都继承了A,而C又同时继承了B1和B2时,这将导致C的对象内存中具有两份A的子对象(subobject)。 为了解决这个问题,C++引入了虚继承来确保子类中仅存在一份虚基类对应的subobject。

为了支持虚继承,含虚基类的C++对象内存模型比不含时复杂了不少。 C++标准未规定对象内存布局,因此各个编译器的实现也各不相同。 其中,GCC和Clang采用了Itanium ABI标准。

接下来,本文将通过一个简单的虚继承例子来介绍GCC (12.2.0)所采用的虚继承对象内存模型。 在下文之前,建议先阅读VTable Notes on Multiple Inheritance in GCC C++ Compiler v4.0.1以回顾C++内存模型基础知识。

2. GCC虚继承对象内存模型

本文所采用的示例为菱形继承,代码如下(godbolt):

#include <typeinfo>

struct A
{
    virtual void f() const {}
    virtual void fA() const {}
    virtual ~A() = default;
    long a = 0;
};


struct B1 : virtual A
{
    virtual void f() const override {}
    virtual void fB1() const {}
    long b1 = 0;
};


struct B2 : virtual A
{
    virtual void f() const override {}
    virtual void fB2() const {}
    long b2 = 0;
};


struct C : B1, B2
{
    virtual void f() const override {}
    virtual void fB1() const override {}
    virtual void fC() const {}
    long c = 0;
};


int main()
{
    A a;
    B1 b1;
    B2 b2;
    C c;

    B2 *pb2 = &c;
    pb2->f(); // non-virtual thunk
    pb2->a = 0; // vbase_offset

    A *pa = &c;
    pa->f();  // virtual thunk, vcall_offset

    C *pc = dynamic_cast<C *>(pa);  // dynamic_cast
    const std::type_info &ti = typeid(*pa);  // typeid

    void *pvoid = dynamic_cast<void *>(pa);  // top offset

    return 0;
}

2.1. 内存布局

子类内存可以被分为两部分,第一部分存储多个父类对应的subobject,第二部分则存储子类自己的数据成员。 子类内存中必须包含所有父类完整的subobject,否则子类指针将无法被向上转型为父类指针(指向子类对象相应subobject的父类指针将出现数据成员内存访问问题)。

当具有虚函数时,对象内存的第一块为vptr(vtable指针)。 比如,A的对象内存布局如下:

0 | struct A
0 |   (A vtable pointer)
8 |   long a
  | [sizeof=16, dsize=16, align=8,
  |  nvsize=16, nvalign=8]

对于虚继承,虚基类subobject位于对象内存最后(普通继承基类的subobject位于对象内存最前面)。 比如:

  1. B1的对象内存布局如下:

     0 | struct B1
     0 |   (B1 vtable pointer)
     8 |   long b1
    16 |   struct A (virtual base)
    16 |     (A vtable pointer)
    24 |     long a
       | [sizeof=32, dsize=32, align=8,
       |  nvsize=16, nvalign=8]
    
  2. C的对象内存布局如下:

     0 | struct C
     0 |   struct B1 (primary base)
     0 |     (B1 vtable pointer)
     8 |     long b1
    16 |   struct B2 (base)
    16 |     (B2 vtable pointer)
    24 |     long b2
    32 |   long c
    40 |   struct A (virtual base)
    40 |     (A vtable pointer)
    48 |     long a
       | [sizeof=56, dsize=56, align=8,
       |  nvsize=40, nvalign=8]
    

2.2. 虚函数表

C++子类的虚函数表内容源自扩增和修改后的父类虚函数表内容的集合。 子类vtable必须包含完整的父类vtable,否则指向子类对象的父类指针将无法正确使用虚函数。

C的虚函数表的内容比A/B1/B2丰富不少,因此本文将主要分析C的虚函数表。 C的虚函数表内容如下:

C::_ZTV1C: 24 entries
################################### B1 && C ####################################
0     40                                   # 40
8     (int (*)(...))0                      # 0
16    (int (*)(...))(& _ZTI1C)             # typeinfo for C
24    (int (*)(...))C::f                   # C::f() const
32    (int (*)(...))C::fB1                 # C::fB1() const
40    (int (*)(...))C::~C                  # C::~C() [complete object destructor]
48    (int (*)(...))C::~C                  # C::~C() [deleting destructor]
56    (int (*)(...))C::fC                  # C::fC() const
###################################### B2 ######################################
64    24                                   # 24
72    (int (*)(...))-16                    # -16
80    (int (*)(...))(& _ZTI1C)             # typeinfo for C
88    (int (*)(...))C::_ZThn16_NK1C1fEv    # non-virtual thunk to C::f() const
96    (int (*)(...))B2::fB2                # B2::fB2() const
104   (int (*)(...))C::_ZThn16_N1CD1Ev     # non-virtual thunk to C::~C() [complete object destructor]
112   (int (*)(...))C::_ZThn16_N1CD0Ev     # non-virtual thunk to C::~C() [deleting destructor]
####################################### A ######################################
120   18446744073709551576                 # -40
128   0                                    # 0
136   18446744073709551576                 # -40
144   (int (*)(...))-40                    # -40
152   (int (*)(...))(& _ZTI1C)             # typeinfo for C
160   (int (*)(...))C::_ZTv0_n24_NK1C1fEv  # virtual thunk to C::f() const
168   (int (*)(...))A::fA                  # A::fA() const
176   (int (*)(...))C::_ZTv0_n40_N1CD1Ev   # virtual thunk to C::~C() [complete object destructor]
184   (int (*)(...))C::_ZTv0_n40_N1CD0Ev   # virtual thunk to C::~C() [deleting destructor]

在C的虚函数表中,除了虚函数指针外还有其他内容,包括typeinfo指针、top offset、vbase offset和vcall offset,下面将逐一进行分析。

2.2.1. 虚函数指针

虚函数表做最重要的内容当然是虚函数指针,大多数C++编译器通过其来实现运行时多态。 子类vtable内容可以被认为是源自扩增和修改后的父类vtable,扩增的目的是存储子类自己的虚函数,而修改的目的将某些虚函数指针被覆盖成了子类实现的虚函数指针以实现运行时多态。

由于C++支持多重继承,而某些父类对应的subobject起始地址和对象整体的起始地址并不相同(例如,C对象的中的B2 subobject区的起始地址就和C对象起始地址大16)。 此时,若通过父类指针来多态地调用子类函数虚函数实现,就必须调整this指针(否则子类实现的虚函数将无法正确使用子类成员数据),而这是通过thunk函数来实现的。 Thunk函数本质上是在原函数执行之前先执行一段其他代码(此处为调整this指针),相当于Lisp中的advice和Java Spring中的切面。

C++ thunk分为non-virtual thunk和virtual thunk,前者为非虚基类subobject对应的thunk(例如,vtableC + 88为non-virtual thunk to C::f() const),后者为虚基类subobject对应的thunk(例如为vtableC + 160为virtual thunk to C::f() const)。

  1. Non-Virtual Thunk

    对应non-virtual thunk, 可以静态地分析出实现对应虚函数的子类subobject的内存地址,因此non-virtual thunk在调用子类实现的虚函数前仅需根据静态分析出来的offset调整一下this指针即可。

    pb2->f()​对应的汇编代码如下:

    # pb2->f();
    movq    -24(%rbp), %rax  # this ptr
    movq    (%rax), %rax  # vptr
    movq    (%rax), %rdx  # vtable[0], non-virtual thunk to C::f() const
    movq    -24(%rbp), %rax  # this ptr
    movq    %rax, %rdi
    call    *%rdx
    
    # non-virtual thunk to C::f() const
    subq    $16, %rdi  # 调整this指针为this - 16, 而B2 subobject的offset恰好为16,因此调整完后this指针指向C对象内存开始处,从而可以正确地调用子类C实现的虚函数C::f()
    jmp     .LTHUNK3
    
  2. Virtual Thunk

    一个子类对象的各个虚基类subobject偏移各异,因此当子类同时覆盖了多个基类中的具有相同签名的虚函数时,通过不同虚基类指针调用该子类所实现的虚函数时对this指针的调整量也各不相同。 若只为子类所覆盖的某个虚函数实现一个virtual thunk,那么该thunk中的this指针调整量将不能是编译时确定的一个固定值,而是通过在运行时动态地查询vcall offset来获取(后面将详细介绍vcall offset)。 例如,若B覆盖了其两个虚基类A1和A2中共同的虚函数A1/A2::f(),若在virtual thunk to B::f()中写死this指针调整量,那么需要为A1和A2各实现一个仅this指针调整量不同的virtual thunk(其实感觉这也是可以接受的,但是GCC没有选择这种做法)。

    pa->f()​对应的汇编代码如下:

    # pa->f();
    movq    -32(%rbp), %rax  # this ptr
    movq    (%rax), %rax  # vptr
    movq    (%rax), %rdx  # vtable[0], virtual thunk to C::f() const
    movq    -32(%rbp), %rax  # this ptr
    movq    %rax, %rdi
    call    *%rdx
    
    # virtual thunk to C::f() const
    movq    (%rdi), %r10  # vptr
    addq    -24(%r10), %rdi  # 调整this指针为this + vtable[-24], 而vtable[-24] = -40为vcall_offset。调整完后,A subobject的offset恰好为40,因此调整完后this指针指向C对象内存开始处,从而可以正确地调用子类C实现的虚函数C::f()
    jmp     .LTHUNK2
    

2.2.2. Type Info指针

VTable中第一个虚函数指针之前的那个指针为type info指针,其被用于实现Run-Time Type Identification (RTTI)。 dynamiccast()和typeid()函数都是根据type info指针来实现的。

Type info功能本质上和Java的class对象类似,只是其内容没有Java class对象丰富,因此不能以次来实现Java中的运行时反射功能。

dynamiccast()和typeid()和对应的汇编代码如下:

# C *pc = dynamic_cast<C *>(pa);
        movq    -32(%rbp), %rax  # this ptr
        testq   %rax, %rax
        je      .L25
        movq    $-1, %rcx
        movl    $typeinfo for C, %edx
        movl    $typeinfo for A, %esi
        movq    %rax, %rdi
        call    __dynamic_cast  # https://github.com/gcc-mirror/gcc/blob/releases/gcc-12.2.0/libstdc%2B%2B-v3/libsupc%2B%2B/dyncast.cc#L44
        jmp     .L26
.L25:
        movl    $0, %eax
.L26:
        movq    %rax, -40(%rbp)


# const std::type_info &type = typeid(*pa);
        movq    -32(%rbp), %rax  # this ptr
        testq   %rax, %rax
        je      .L27
        movq    (%rax), %rax
        movq    -8(%rax), %rax  # vtable[-8], type info ptr for C
        movq    %rax, -48(%rbp)

从汇编代码可以看出,dynamiccast()和typeid()都是通过vtable中的type info ptr来实现的。

2.2.3. Top Offset

VTable中type info指针之前的8 bits内容为top offset,其主要被用于虚继承时指针向下转型的this指针偏移量计算。 虽然前面展示的​dynamic_cast<C *>(pa)​汇编代码看似仅用到了typeinfo(理论上仅需typeinfo即可判断是否满足继承关系要求),但是将父类指针向下转型到子类指针时所需的this指针偏移量仅当非虚继承时才可以在编译时statically确定。 虚继承时,无法在编译时确定向下转型时所需的this指针偏移量,因为虚基类的偏移时不确定的。 比如,在​VA <- B​和​VA <- B <- C​两种继承关系中,将pVA指针向下转型到pB时所需的this指针偏移量时不同的。 因此,在虚继承时,需要通过top offset来确定向下转型时所需的this指针偏移量,也就是​dynast.cc#__dyncast()​内部应该会消费top offset。

另外,​dynamic_cast<void *>()​这种corner case会直接通过top offset来确定转型为void指针时所需的this指针偏移量,因为void类型没有typeinfo。 ​dynamic_cast<void *>()​对应的汇编代码如下:

# void *pvoid = dynamic_cast<void *>(pa);
        movq    -32(%rbp), %rax  # this ptr
        testq   %rax, %rax
        je      .L29
        jmp     .L34
.L27:
        call    __cxa_bad_typeid
.L34:
        movq    (%rax), %rdx  # vptr
        subq    $16, %rdx
        movq    (%rdx), %rdx  # vtable[-16] = -40, top offset for A, A_in_C subobject相对C对象开始的偏移为-40
        addq    %rdx, %rax  # ptr to C
        jmp     .L30
.L29:
        movl    $0, %eax
.L30:
        movq    %rax, -56(%rbp)

从汇编代码可以看出,​dynamic_cast<void *>()​是通过top offset来实现的。

2.2.4. VBase Offset

含虚基类的子类对应的vtable区中type info指针之前的8 bits内容为vbase offset,其被用于在通过子类指针访问虚基类的数据成员时确定虚基类subobject的内存偏移,因此其仅存在于含虚基类的子类对应vtable区中。 虚基类subobject在不同子类中的偏移各不相同,因此无法在编译时根据子类指针静态地确定虚基类数据成员的偏移。 比如,在​VA <- B​和​VA <- B <- C​两种继承关系中,指针​pB1 = new B​和​pB2 = new C​访问VA的数据成员时所需的偏移量是不同的。 此时,需要根据vbase offset动态地计算出虚基类subobject的位置,然后再获得虚基类数据成员的偏移。

例如,​pb2->a = 0​对应的汇编代码如下:

# pb2->a = 0;
movq    -24(%rbp), %rax  # this ptr
movq    (%rax), %rax  # vptr
subq    $24, %rax
movq    (%rax), %rax  # vtable[-24] = 24, vbase offset, B2_in_C subobject相对A_in_C subobject的偏移为24
movq    %rax, %rdx
movq    -24(%rbp), %rax  # this ptr
addq    %rdx, %rax  # ptr to the A_in_C subobject
movq    $0, 8(%rax)  # ptr to A.a

从汇编代码可以看出,​pb2->a = 0​是通过vbase offset来实现的。

2.2.5. VCall Offset

虚基类vtable区中type info指针之前的8 bits内容为vcall offset,其被用于在通过虚基类指针访问子类覆盖过的虚函数时确定虚基类subobject相对子类subobject的偏移,因此其仅存在于含虚基类对应vtable区中。 ​VCall offset和vbase offset是姊妹关系,vcall offset被用于访问子类实现的虚函数,而vbase ofset则被用于访问虚基类数据成员。​ 两者的出现根本原因都是编译器无法在编译时根据子类指针静态地确定虚基类数据成员的偏移,因此需要vbase offset来获取虚基类suboject的位置,或者需要根据vcall offset来获取子类subobject的位置。

示例中,AinC subobject对应的vtable区中有4个虚函数指针有4个,而仅有3项vcall offset。 可能原因为对应于complete object destructor和deleting destructor的最后两个虚函数指针共享一项vcall offset。

VCall offset的汇编代码示例可以见上文中的virtual thunk

2.3. 对象构造/析构与VTT

除了vtable外,GCC还有virtual table table (VTT), 其主要被用于对象构建时。

在​VA <- B1, B2 <- C​继承体系中,C的对象时如何构建的呢? 一般而言,子类的CTOR会先调用父类的CTOR来构造父类的subobject,然后在再构造子类的subobject。 在菱形继承时,由于从子类到虚基类会有多条路径,因此需要特殊处理。

2.3.1. CTOR

2.3.2. DTOR

3. 总结

本文从一个简单例子出发来分析GCC所使用的C++虚继承的对象内存模型。 需要注意的是,本文所分析虚继承对象内存模型不适用于MSVC(其采用了GCC中没有的虚基类表)。 本人水平有限,如有错误之处请多包涵指正。