sinkinben

本文可以解决下面 3 个问题:

  • 以不同方式继承之后,类的成员变量是如何分布的?
  • 虚函数表及虚函数表指针,在可执行文件中的位置?
  • 单一继承、多继承、虚拟继承之后,类的虚函数表的内容是如何变化的?

在这里涉及的变量有:有无继承、有无虚函数、是否多继承、是否虚继承。

准备工作

在开始探索类的内存布局之前,我们先了解虚函数表的概念,字节对齐的规则,以及如何打印一个类的内存布局。

查看类的内存布局

我们可以使用 clang++ 来查看类的内存布局:

# 查看对象布局, 要求 main 中有 sizeof(class_t)
clang++ -Xclang -fdump-record-layouts xxx.cpp
# 查看虚函数表布局, 要求 main 中实例化一个对象
clang++ -Xclang -fdump-record-layouts xxx.cpp
# 或者
clang -cc1 -fdump-vtable-layouts -emit-llvm xxx.cpp 

虚函数表

  • 每个类都有一个属于自己虚函数表,虚函数表属于类,而不是某一个实例化对象。
  • 如果一个类声明了虚函数,那么在该类的所有实例化对象中,在 [0, 7] 这 8 个字节(假设是 64 位机器),会存放一个虚函数表的指针 vtable
  • 虚函数表中的每一个元素都是一个函数地址,指向代码段的某一虚函数。
  • 虚函数表指针 vtable 是在对象实例化的时候填入的(因此构造函数不能用 virtual 声明为一个虚函数)。
    • 假设 B 继承了 A ,假如我们在运行时有 A *a = new B() ,那么 a->vtable 实际上填入的是类 B 的虚函数表地址。
    • 如何获得 vtable 的值?通过读取对象的起始 8 个字节的内容,即 *(uint64_t *)&object
+---------+                                                   +----------------+
| entity1 |                                                   | .text segment  |
+---------+                                                   +----------------+
| vtable  |-------+                                  +------->| Entity::vfunc1 |
| member1 |       |         +-----------------+      |  +---->| Entity::vfunc2 | 
| member2 |       |         | Entity's vtable |      |  |     |       ...      |
+---------+       |         +-----------------+      |  |     +----------------+
                  +-------->| 0 : vfunc_ptr0  |------+  |     | Entity::func1  |
+---------+       |         | 1 : vfunc_ptr1  |---------+     | Entity::func2  |
| entity2 |       |         |      ...        |               |       ...      |
+---------+       |         +-----------------+               +----------------+
| vtable  |-------+
| member1 |
| member2 |
+---------+

那么虚函数表(即上图的 Entity's vtable )会存放在哪里呢?

一个直觉是与 static 成员变量一样,存放在 .data segment ,因为二者都属于是类共享的数据。

字节对齐

字节对齐的规则:按照编译器「已经扫描」的最长的数据类型的字节数 (总是为 1, 2, 4, 8 ) 进行对齐,并且尽量填满「空隙」。

编译器是按照声明顺序(从前往后扫描)来解析一个 struct / class 的。

需要注意的是,不同的编译器,其字节对齐的规则会略有差异,但总的来说是大同小异的。本文所使用的编译器均为 clang/clang++ 。


例子一

struct Entity
{
    char c1;
    int val;
};
// sizeof(Entity) = 8
  • 如果把 char c1 换成 short val0 ,那么还是 8 。
  • 如果把 int val 换成 double d ,那么是 16 。

例子二

struct Entity
{
    char cval;
    short ival;
    double dval;
};
/*
*** Dumping AST Record Layout
         0 | struct Entity
         0 |   char cval
         2 |   short ival
         8 |   double dval
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]
*/
  • 如果 short ival 换成 int ival ,那么 ival 的起始位置是 4 (因为编译器扫描到 ival 的时候,看到的最长字节数是 sizeof(int) = 4 )。

例子三

struct Entity
{
    char cval;
    double dval;
    char cval2;
    int ival;
};
/*
*** Dumping AST Record Layout
         0 | struct Entity
         0 |   char cval
         8 |   double dval
        16 |   char cval2
        20 |   int ival
           | [sizeof=24, dsize=24, align=8,
           |  nvsize=24, nvalign=8]
*/

此处的例子,就是为了说明上述的「尽可能填满空隙」,注意到 cval2ival 之间留出了 17, 18, 19 这 3 个字节的空白。

  • cval2, ival 插入任意的一个字节的数据类型(最多插入 3 个),不会影响 sizeof(Entity) 的大小。
  • 如果我们在 cval2, ival 之间插入一个 short sval ,那么 sval 会位于 18 这一位置。

例子四

如果有虚函数,又会怎么样呢?

class Entity
{
    char cval;
    virtual void vfunc() {}
};
/*
*** Dumping AST Record Layout
         0 | class Entity
         0 |   (Entity vtable pointer)
         8 |   char cval
           | [sizeof=16, dsize=9, align=8,
           |  nvsize=9, nvalign=8]
*/

在 64 位机器上,一个指针的大小是 8 字节,所以编译器会按照 8 字节对齐。

单一的类

成员变量

考虑无虚函数的条件下,成员变量的内存布局。

class A
{
    private:
        short val1;
    public:
        int val2;
        double d;
        static char ch;
        void funcA1() {}
};
int main()
{
    __attribute__((unused)) int k = sizeof(A);
}
// clang++ -Xclang -fdump-record-layouts test.cpp

使用上述命令编译之后,输出为:

*** Dumping AST Record Layout
         0 | class A
         0 |   short val1
         4 |   int val2
         8 |   double d
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

从上面的输出可以看出:

  • static 类型的成员并不占用实例化对象的内存(因为 static 类型的成员存放在静态数据区 .data )。
  • 成员函数不占用内存(因为存放在代码段 .text )。
  • 成员变量的权限级别 private, public 不影响内存布局,内存布局只跟声明顺序有关(可能需要字节对齐)。

虚函数表

class A
{
private:
    short val1;

public:
    int val2;
    double d;
    static char ch;
    void funcA1() {}
    virtual void vfuncA1() {}
    virtual void vfuncA2() {}
};
int main()
{
    __attribute__((unused)) int k = sizeof(A);
    // __attribute__((unused)) A a;
}

从这里可以看出,虚函数表的指针默认是存放在一个类的起始位置(一般占用 4 或者 8 字节,视乎机器的字长)。

内存布局:

clang++ -Xclang -fdump-record-layouts test.cpp
*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   short val1
        12 |   int val2
        16 |   double d
           | [sizeof=24, dsize=24, align=8,
           |  nvsize=24, nvalign=8]

clang++ -Xclang -fdump-vtable-layouts test.cpp
Original map
Vtable for 'A' (4 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::vfuncA1()
   3 | void A::vfuncA2()

VTable indices for 'A' (2 entries).
   0 | void A::vfuncA1()
   1 | void A::vfuncA2()

  • offset_to_top(0) : 表示当前这个虚函数表地址距离对象顶部地址的偏移量,因为对象的头部就是虚函数表的指针,所以偏移量为0。如果是多继承的情况,一个类可能存在多个 vtable 的指针。
  • RTTI : 即 Run Time Type Info, 指向存储运行时类型信息 (type_info) 的地址,用于运行时类型识别,用于 typeiddynamic_cast

单一继承

成员变量

class A
{
public:
    char aval;
    static int sival;
    void funcA1();
};
class B : public A
{
public:
    double bval;
    void funcB1();
};
class C : public B
{
public:
    int cval;
    void funcC1() {}
};

内存布局:

clang++ -Xclang -fdump-record-layouts test.cpp

*** Dumping AST Record Layout
         0 | class A
         0 |   char aval
           | [sizeof=1, dsize=1, align=1,
           |  nvsize=1, nvalign=1]

*** Dumping AST Record Layout
         0 | class B
         0 |   class A (base)
         0 |     char aval
         8 |   double bval
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class C
         0 |   class B (base)
         0 |     class A (base)
         0 |       char aval
         8 |     double bval
        16 |   int cval
           | [sizeof=24, dsize=20, align=8,
           |  nvsize=20, nvalign=8]

可以看出,普通的单一继承,成员变量是从上到下依次排列的,并且遵循前面提到的字节对齐规则。

虚函数表

  • A 中有 2 个虚函数 vfuncA1, vfuncA2 .
  • B 重写 (Override) 了 vfuncA1 ,自定义虚函数 vfuncB .
  • C 重写了 vfunc1 ,自定义虚函数 vfuncC .
class A
{
public:
    char aval;
    static int sival;
    virtual void vfuncA1() {}
    virtual void vfuncA2() {}
};
class B : public A
{
public:
    double bval;
    virtual void vfuncA1() {}
    virtual void vfuncB() {}
};
class C : public B
{
public:
    int cval;
    virtual void vfuncA1() {}
    virtual void vfuncC() {}
};

成员变量布局:

clang++ -Xclang -fdump-record-layouts test.cpp

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   char aval
           | [sizeof=16, dsize=9, align=8,
           |  nvsize=9, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     char aval
        16 |   double bval
           | [sizeof=24, dsize=24, align=8,
           |  nvsize=24, nvalign=8]

*** Dumping AST Record Layout
         0 | class C
         0 |   class B (primary base)
         0 |     class A (primary base)
         0 |       (A vtable pointer)
         8 |       char aval
        16 |     double bval
        24 |   int cval
           | [sizeof=32, dsize=28, align=8,
           |  nvsize=28, nvalign=8]

3 个类的虚函数表如下:

clang++ -Xclang -fdump-vtable-layouts test.cpp
Original map
 void C::vfuncA1() -> void B::vfuncA1()
 void B::vfuncA1() -> void A::vfuncA1()
Vtable for 'C' (6 entries).
   0 | offset_to_top (0)
   1 | C RTTI
       -- (A, 0) vtable address --
       -- (B, 0) vtable address --
       -- (C, 0) vtable address --
   2 | void C::vfuncA1()
   3 | void A::vfuncA2()
   4 | void B::vfuncB()
   5 | void C::vfuncC()

VTable indices for 'C' (2 entries).
   0 | void C::vfuncA1()
   3 | void C::vfuncC()

Original map
 void C::vfuncA1() -> void B::vfuncA1()
 void B::vfuncA1() -> void A::vfuncA1()
Vtable for 'B' (5 entries).
   0 | offset_to_top (0)
   1 | B RTTI
       -- (A, 0) vtable address --
       -- (B, 0) vtable address --
   2 | void B::vfuncA1()
   3 | void A::vfuncA2()
   4 | void B::vfuncB()

VTable indices for 'B' (2 entries).
   0 | void B::vfuncA1()
   2 | void B::vfuncB()

Original map
 void C::vfuncA1() -> void B::vfuncA1()
 void B::vfuncA1() -> void A::vfuncA1()
Vtable for 'A' (4 entries).
   0 | offset_to_top (0)
   1 | A RTTI
       -- (A, 0) vtable address --
   2 | void A::vfuncA1()
   3 | void A::vfuncA2()

VTable indices for 'A' (2 entries).
   0 | void A::vfuncA1()
   1 | void A::vfuncA2()

可以看出,在单一继承中,子类的虚函数表通过以下步骤构造出来:

  • 先拷贝上一层次父类的虚函数表。
  • 如果子类有自定义虚函数(例如 B::vfuncB, C::vfuncC),那么直接在虚函数表后追加这些虚函数的地址。
  • 如果子类覆盖了父类的虚函数,使用新地址(例如 B::vfuncA1, C::vfuncA1)覆盖原有地址(即 A::vfunc1)。

多继承

默认大家已经熟悉套路了,现在直接成员变量和虚函数一起来看。

class A
{
    char aval;
    virtual void vfuncA1() {}
    virtual void vfuncA2() {}
};
class B
{
    double bval;
    virtual void vfuncB1() {}
    virtual void vfuncB2() {}
};
class C : public A, public B
{
    char cval;
    virtual void vfuncC() {}
    virtual void vfuncA1() {}
    virtual void vfuncB1() {}
};

内存布局如下(注意类 C 的布局):

clang++ -Xclang -fdump-record-layouts test.cpp

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   char aval
           | [sizeof=16, dsize=9, align=8,
           |  nvsize=9, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   (B vtable pointer)
         8 |   double bval
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class C
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     char aval
        16 |   class B (base)
        16 |     (B vtable pointer)
        24 |     double bval
        32 |   char cval
           | [sizeof=40, dsize=33, align=8,
           |  nvsize=33, nvalign=8]

注意到类 C 的内存布局:

  • 一共 40 字节,有 2 个 vtable 指针。
  • 继承有 primary base 父类和普通 base 父类之分。

实际上就是:

+--------+--------+---------------+
| offset |  size  |   content     |
+--------+--------+---------------+
|   0    |   8    | vtable1       |
|   8    |   1    | aval          |
|   9    |   7    | aligned bytes |
|   16   |   8    | vtable2       |
|   24   |   8    | bval          |
|   32   |   1    | cval          |
|   33   |   7    | aligned bytes |
+--------+--------+---------------+

总的来说,在最底层子类的内存布局中,多继承的成员变量,以及 vtable 指针的排列规则是:

  • 第一个声明的继承是 primary base 父类。
  • 按照继承的声明顺序依次排列,并需要遵循编译器的字节对齐规则。
  • 最后排列最底层子类的成员变量。

虚函数表如下(省略了 A 和 B 的内容):

clang++ -Xclang -fdump-vtable-layouts test.cpp
Original map
 void C::vfuncA1() -> void A::vfuncA1()
Vtable for 'C' (10 entries).
   0 | offset_to_top (0)
   1 | C RTTI
       -- (A, 0) vtable address --
       -- (C, 0) vtable address --
   2 | void C::vfuncA1()
   3 | void A::vfuncA2()
   4 | void C::vfuncC()
   5 | void C::vfuncB1()
   6 | offset_to_top (-16)
   7 | C RTTI
       -- (B, 16) vtable address --
   8 | void C::vfuncB1()
       [this adjustment: -16 non-virtual] method: void B::vfuncB1()
   9 | void B::vfuncB2()

Thunks for 'void C::vfuncB1()' (1 entry).
   0 | this adjustment: -16 non-virtual

VTable indices for 'C' (3 entries).
   0 | void C::vfuncA1()
   2 | void C::vfuncC()
   3 | void C::vfuncB1()

从上面可以看出,C 的虚函数表是由 2 部分组成的:

  • 首先是 「C 继承 A」,按照上述单一继承的虚函数表生成原则,生成了第一个虚函数表。此时 C::vfuncB1() 对于 A 来说是一个自定义的虚函数,因此虚函数表的第一部分有 4 个函数地址。
  • 其次是「C 继承 B」,同样按照单一继承的规则生成,但不用追加 C::vfuncC() ,因为 C::vfuncC() 已经在第一部分填入。

可以发现的是:

  • C 的虚函数表存在一个重复的函数地址 C::vfuncB1
  • 虽然 C 有 2 个 vtable 指针,但仍然只有一个虚函数表(

分类:

C/C++

技术点:

相关文章: