Skip to content

What does cxx Object Layout Look Like

jiaxw32 edited this page Nov 5, 2023 · 1 revision

What does C++ Object Layout Look Like?

原文链接

Intro

Have you ever thought about how a class is structured in memory? How the data and functions are laid out? What happened when inheritance is involved? In this post, I’ll explain them to you.

你有没有想过一个类在内存中是如何结构化的?数据和函数是如何布局的?当涉及到继承时会发生什么?在这篇文章中,我将向你解释这些问题。

As quoted from § 12.1.17 of N4649(C++17)

根据 N4649(C++17)的第12.1.17节所述

Non-static data members of a (non-union) class with the same access control are allocated so that later members have higher addresses within a class object. The order of allocation of non-static data members with different access control is unspecified. Implementation alignment requirements might cause two adjacent members not to be allocated immediately after each other; so might requirements for space for managing virtual functions and virtual base classes.

非静态数据成员在一个(非联合)类中具有相同访问控制的情况下,按照后面的成员在类对象内部拥有更高的地址进行分配。对于具有不同访问控制的非静态数据成员的分配顺序是未指定的。实现对齐要求可能导致两个相邻成员不会立即紧随其后被分配;管理虚函数和虚基类所需空间也可能产生这种情况。

Non-static data members for a given access specifier will be allocated in order of their declaration. Only the memory layout of standard-layout types(among other restrictions, has all data members with the same access control), which is only a very small subset of kinds of types, is determined. The others are left to the implementation. An object needs space for its data members, subobjects of base classes, virtual function/base classes management, padding, and so on. These are implementation-defined but most compilers adhere to the Itanium ABI specification, part of it specifying how the layout is structured. GCC uses it. So what we’re talking about in this post is mostly implementation things. You can use -fdump-lang-class option in GCC(-fdump-class-hierarchy before GCC8.0) and -cc1 -fdump-record-layouts -fdump-vtable-layouts -emit-llvm option in Clang to dump the object layout for better understanding. The content discussed below is mostly GCC; Clang has some subtle differences. I have no idea what’s behind the MSVC.

给定访问说明符的非静态数据成员将按照它们的声明顺序进行分配。只有标准布局类型(在其他限制条件下,具有相同访问控制的所有数据成员)的内存布局是确定的,而这仅仅是类型种类中非常小的子集。其他类型由实现决定。一个对象需要空间来存储其数据成员、基类子对象、虚函数/基类管理、填充等等。这些都是实现定义的,但大多数编译器遵循Itanium ABI规范,其中一部分规定了布局结构如何组织。GCC使用该规范。因此,在本文中我们主要谈论实现相关事项。您可以在GCC中使用 -fdump-lang-class 选项(GCC 8.0之前为 -fdump-class-hierarchy),以及在Clang中使用 -cc1 -fdump-record-layouts -fdump-vtable-layouts -emit-llvm 选项来转储对象布局以便更好地理解。以下内容主要涉及GCC;Clang存在一些微妙差异,我不清楚MSVC背后是什么情况。

Simple Class

For a class without any inheritance involved,

对于一个没有涉及任何继承的类,

class Objcet {
public:
    double GetVal() const {
      return val;
    }
private:
    double val;
};

The non-static member function Object::GetVal is placed in the code segment and is treated as a global function by the compiler. From the perspective of the compiler, this function may look as follows,

非静态成员函数 Object::GetVal 被放置在代码段中,并且由编译器视为全局函数。从编译器的角度来看,这个函数可能如下所示:

inline double Object::GetVal(const Object* this) {
  return this->val;
}

Data member access is carried out through an implicit class object represented by a pointer to the object this, and be cv-qualified if the member function is declared as cv-qualified. The call may look like this,

通过一个隐式的类对象来访问数据成员,该对象由指向 this 的指针表示,并且如果成员函数被声明为 cv-qualified,则可以进行cv限定。调用可能如下所示:

// double val = obj.GetVal();
double val = GetVal(&obj); // resolved like global free functions

Don’t worry about the name collision after the member function is converted to global free functions because compilers will mangle these function names, to make sure every function has a unique name. For example, the GetVal function is mangled as _ZNK6Object6GetValEv, and you can use some demangling tools like c++filt to get the original function name.

不用担心成员函数转换为全局自由函数后的名称冲突,因为编译器会对这些函数名进行重整,以确保每个函数都有唯一的名称。例如,GetVal 函数被重整为 _ZNK6Object6GetValEv,并且你可以使用像 c++filt 这样的反重整工具来获取原始函数名。

> c++filt _ZNK6Object6GetValEv
Objcet::GetVal() const

Static members are all treated as global variables or global functions. For a simple class instance, it needs space for its data members (putting alignment paddings aside), val here. Objects of type Object are laid out like the image below.

静态成员都被视为全局变量或全局函数。对于一个简单的类实例来说,它需要空间来存储其数据成员(忽略对齐填充),例如这里的 val。Object 类型的对象布局如下图所示。

Single Inheritance

Say I have a base class and a derived class, without any virtual functions.

假设我有一个基类和一个派生类,没有任何虚函数。

class Base {
public:
    double b;
};

class Derived : public Base {
public:
  	double d;
};

Objects of Derived are laid out by simply stacking the Base subobject on the data member of its own.

Derived 的对象是通过将 Base 子对象简单地堆叠在其自身的数据成员上来布局的。

If a pointer to the base class is assigned a derived object, it can only see the subobject part. It can be deemed as an object slicing.

如果将一个指向基类的指针赋值为派生对象,它只能看到子对象部分。这可以被视为对象切片。

Base* pB = new Derived(); // base pointer to derived object
pB->Foo(); // call Base::Foo()
pB->val // access the data in the Base subobject

The span of pB encompasses only the Base subobject of Derived. This rule still applies under multiple inheritance.

pB 的范围仅包括 Derived 中的 Base 子对象。在多重继承下,此规则仍然适用。

Things become complicated when virtual functions get engaged. Mose compilers use virtual tables to manage the virtual functions.

当虚函数参与时,事情变得复杂起来。大多数编译器使用虚表来管理虚函数。

If you have virtual functions,

如果你有虚函数,

class Base {
public:
    virtual ~Base();
    virtual void Foo();
    virtual void Bar();
    double b;
};

class Derived : public Base {
public:
    void Foo() override; // override
    virtual void Baz(); // new virtual function
    double d;
};

The memory layout will be like this,

内存布局将会是这样的,

Base Derived

If a class has any virtual functions, it has a pointer vptr, placed at the beginning of an object, to the associated virtual table, where these virtual function pointers are stored. The virtual table is constructed during compile time. For each virtual function a class has, a slot in the vtable is used to store the function address.

如果一个类有任何虚函数,它会在对象的开头放置一个指向相关虚表的vptr 指针,其中存储了这些虚函数的指针。虚表是在编译时构建的。对于每个类拥有的虚函数,vtable中都使用一个槽来存储函数地址。

Note: In reality, there’ll be two destructors, deleting destructor and complete constructor, generated for a class. For simplicity, I only take one in this post.

注意:实际上,一个类会生成两个析构函数,即删除析构函数和完整的构造函数。为了简单起见,在本文中只讨论其中一个。

When you call the virtual function through a base pointer, even if only the subobject is visible to the pointer, it can call the derived function through the vtpr. At compile time we don’t know the exact type of object the base pointer addresses, but we can determine which slot is used to store the address of the called function.

当你通过基础指针调用虚函数时,即使只有子对象对指针可见,它也可以通过vtpr调用派生函数。在编译时我们无法确定基础指针所引用的对象的确切类型,但我们可以确定哪个槽位被用来存储被调用函数的地址。

Base pB = new Derived();
// Base pB = new Base();
pB->Foo(); // can't determine the exact type until runtime

The compiler can determine Foo is located at second slot in the vtable(counting from the address pointed by vptr) and it internally transforms the call to

编译器可以确定 Foo 位于虚函数表中的第二个槽位(从 vptr 指向的地址开始计数),并在内部将调用转换为

*(pB->vptr[2])(pB);

The vptr is all handled automatically by the compiler, it’s set when the object is constructed.

vptr是由编译器自动处理的,它在对象构造时被设置。

You may notice there’re other things besides the virtual function pointers. Actually, the address contained in the object pointed to the virtual table is not the beginning of the virtual table. It’s called the address point of the virtual table. In this case, above the address point, the virtual table also contains typeinfo pointer and offset to top, we’ll talk about it later.

除了虚函数指针之外,你可能还会注意到其他的东西。实际上,在对象所指向的虚表中包含的地址并不是虚表的起始位置。它被称为虚表的地址点。在这种情况下,在地址点之上,虚表还包含typeinfo指针和偏移量,我们稍后会讨论。

Multiple Inheritance

Under a single inheritance hierarchy, the virtual function mechanism is well behaved; it is both efficient and easily modeled. What the world will be like under a multiple inheritance hierarchy?

在单一继承体系下,虚函数机制表现良好;它既高效又易于建模。那么在多重继承体系下世界会是什么样子呢?

struct Base1 {
    virtual ~Base1();
    virtual void Foo();
  	double b1;
};

struct Base2 {
    virtual ~Base2();
    virtual void Bar();
  	double b2;
};

struct Derived: public Base1, public Base2 {
    ~Derived();
    void Foo() override;
    double d;
};

For each base class that has a virtual table, the derived object has a corresponding vptr. It’s reasonable because these subobjects should have their own vtpr. When a base pointer is assigned a derived object, it should podouble to the subobject but not necessarily the beginning of this object. If the base class is the first one to be inherited, it works the same as the single-inheritance case. Consider the following case,

对于每个具有虚表的基类,派生对象都有一个相应的 vptr。这是合理的,因为这些子对象应该有自己的 vptr。当将基指针赋值给派生对象时,它应该指向子对象而不一定是此对象的开头。如果基类是第一个被继承的类,则与单继承情况下相同。考虑以下情况,

Base1* pB = new Derived();
pB->foo();

It works fine. But what if we use Base2 here?

它运行得很好。但是如果我们在这里使用 Base2 会怎样呢?

Base2* pB = new Derived();
// generated code
// Derived* tmp = new Derived();
// Base2* pB = tmp + sizeof(Base1);

The pointer pB is adjusted to the subobject Base2 here. If we call pB->Bar(), the function resolution is like what I said before. How about deleting the object through the base pointer?

这里将指针 pB 调整为子对象 Base2。如果我们调用 pB->Bar(),函数解析就像我之前说的那样。那么通过基类指针删除对象会怎样呢?

Base1* pB = new Derived();
delete pB; // this is OK since pB points to the beginning

Base2* pB = new Derived();
delete pB; // it must be adjusted back to the beginning

Even if the corresponding slot can store the correct destructor ~Derived::Derived(), to make things work, the pointer pB, i.e. *this in the function parameter, must be readjusted to the beginning of the object. Of course, you can do the adjustment at runtime. But for runtime efficiency, the thunk function is generated at compile time since the offset is already known. What the thunk function might look as follows:

即使相应的槽位可以存储正确的析构函数 ~Derived::Derived(),为了使事情正常工作,指针 pB,即函数参数中的 *this 必须重新调整到对象的开头。当然,你可以在运行时进行调整。但是为了运行效率,在编译时生成 thunk 函数是更好的选择,因为偏移量已经知道。thunk 函数可能如下所示:

void thunk_to_Derived_Desturctor(Base2* this) {
    this -= sizeof(Base1);
    Derived::~Derived(this);
}

This explains what the second slot of the Base2 subobject vtable, “thunk to ~Derived::Derived” is used for.

这解释了 Base2 子对象 vtable 的第二个槽位“thunk to ~Derived::Derived”的用途。

Little Compiler Trick

When the leftmost base class has no virtual functions, compilers will move the first base class with virtual functions to the beginning of the object, so that the beginning of the object is always the vptr. This base class is also called the primary base. Little changes in the above case, where Base1 doesn’t have any virtual functions anymore.

当最左边的基类没有虚函数时,编译器会将第一个具有虚函数的基类移动到对象的开头,以便对象的开头始终是vptr。这个基类也被称为主要基类。在上述情况中稍有变化,Base1 不再具有任何虚函数。

struct Base1 {
    void Foo();
    double b1;
};

The memory will be like,

内存将会如下所示

Although Base1 is the leftmost base class of Derived, due to the lack of any virtual functions for Base1, the compiler changes the order in which the final object is composed.

尽管 Base1 是 Derived 的最左边的基类,但由于缺乏任何虚函数来支持Base1,编译器会改变最终对象组合的顺序。

Virtual Inheritance

To solve the Diamond Inheritance Problem, C++ introduces the virtual inheritance mechanism to guarantee only one copy of virtually inherited bases under the diamond hierarchy. Consider the following code fragment,

为了解决菱形继承问题,C++引入了虚拟继承机制,以确保在菱形层次结构下只有一个虚拟继承基类的副本。考虑以下代码片段,

struct VBase {
    virtual void Foo();
    double v;
};

struct Base1 : virtual public VBase {
    void Foo() override;
    virtual void Bar();
    double b1;
};

struct Base2 : virtual public VBase {
    virtual void Baz();
    double b2;
};

struct Derived: public Base1, public Base2 {
    double d;
};

To keep only one virtual base in the derived object, a class can be spliced into two parts, the virtual base subobject, which will be shared in the diamond inheritance case, and the other parts. The virtual base is accessed through a virtual table pointer either. Let’s first see the memory layout of Base1 because there is no big difference between Derived and Base in essence.

为了在派生对象中只保留一个虚基类,可以将一个类分割成两个部分:虚基类子对象,在菱形继承情况下会被共享;以及其他部分。通过虚表指针来访问虚基类。首先我们来看一下 Base1 的内存布局,因为 DerivedBase 在本质上没有太大区别。

There’re two kinds of new things added to the vtable, vbase offset and vcall offset.

在 vtable 中添加了两种新的内容,即“vbase offset”和“vcall offset”。

Virtual Base(Vbase) Offset

This offset is used when you are to access the virtual bases of an object. Suppose you’re using a pointer of VBase* addressing at a Base1 object. To get the real address of the subobject, the pointer is adjusted through this offset.

这个偏移量用于访问对象的虚基类。假设你正在使用一个指向Base1对象的VBase*指针,为了获取子对象的真实地址,通过这个偏移量来调整指针。

VBase* pV = new Base1();
// generated code
// Base1* tmp = new Base1();
// VBase* pV = tmp + vbase_offset;
// or 
// pV = tmp + *(tmp->vptr + index) // index is determined at compile time, -3 here

The vbase offset is 16 here, it tells the compiler to jump over 16 bytes to get the virtual base. The real reason to do that is to deal with different offset when virtual base is inherited in different classes. There’ll be no such problem when it comes to non-virtual base class since the offset is determined.

vbase 偏移量是 16,它告诉编译器跳过 16 个字节以获取虚基类。这样做的真正原因是为了处理在不同类中继承虚基类时出现的不同偏移量问题。对于非虚基类来说,由于偏移量已确定,就不会出现这样的问题。

Virtual Call(Vcall) Offset

Vcall offsets play a similar role to vbase offsets. Consider the case

Vcall 偏移量起到了与 vbase 偏移量类似的作用。考虑以下情况

VBase pV = new Base1();
pV->Foo();

Since the function Foo is overridden, the pointer needs to be readjusted back to the beginning of Base1 object. Like what is done in thunk functions, add the offset to *this pointer and call the correct function instance. The difference here is the offset is stored in the vcall offset slot. Two indirections are performed to invoke the function. The special thunk is called virtual thunks. Generated code may be,

由于函数 Foo 被重写,指针需要重新调整回到 Base1 对象的开头。就像在thunk函数中所做的那样,将偏移量添加到*this指针上,并调用正确的函数实例。这里的区别是偏移量存储在 vcall offset 槽中。执行该函数需要进行两次间接引用。这种特殊的thunk称为虚拟thunks。生成的代码可能如下:

void virtual_thunk_to_Base1_Foo(VBase* this) {
    int vcall_offset = *(this->vptr + index); // every virtual call has a corresponding index
    this += vcall_offset;
    Base1::Foo(this);
}

Now, the more complicated Derived class memory layout can be understood.

现在,我们可以理解更复杂的Derived类内存布局了。

Let’s compare the layout with Base1’s. Due to the int member d and subobject Base2, the offset of VBase is different from its in Base1. To share the same thunk to Base1::Foo between both Base1 and Derived, the vcall offset and virtual thunk are used. This will result in fewer thunks which may cause fewer instruction cache misses. The trade-off is one more time load before the offset is added. Quoted from the Itanium C++ ABI Examples, this trade-off is worthwhile.

让我们将布局与 Base1 进行比较。由于 int 成员 d 和子对象 Base2 的存在,VBase 的偏移量与其在 Base1 中不同。为了在 Base1Derived 之间共享相同的thunk到 Base1::Foo,使用 vcall offset 和 virtual thunk。这将导致更少的 thunks,可能会减少指令缓存未命中次数。权衡之处是在添加偏移量之前需要多加载一次时间。引用自 Itanium C++ ABI Examples,这种权衡是值得的。

Since the offset is smaller than the code for a thunk, the load should miss in cache less frequently, so better cache miss behavior should produce better results in spite of the 2 or more cycles required for the vcall offset load.

由于偏移量小于thunk的代码,加载操作在缓存中不会频繁失效,因此更好的缓存失效行为应该能够产生更好的结果,尽管vcall偏移量加载需要2个或多个周期。

Pure Virtual Functions

Pure virtual functions are a kind of special function which means they’re not implemented and classes with pure virtual functions are disallowed to be instantiated.

纯虚函数是一种特殊的函数,意味着它们没有被实现,并且具有纯虚函数的类不允许被实例化。

Since pure virtual function has no definition, the ABI specifies a special function __cxa_pure_virtual as the placeholder for all pure functions. The corresponding virtual function pointer points to this placeholder in the virtual table.

由于纯虚函数没有定义,ABI规定了一个名为 __cxa_pure_virtual 的特殊函数作为所有纯函数的占位符。相应的虚函数指针在虚表中指向这个占位符。

Other Components in the Virtual Table

So far, we have covered only three things in the virtual table, virtual function pointers, vbase offset, and vcall offset. The remaining two things, offset to top, and typeinfo pointer is explained here. They have little relationship with the content above.

到目前为止,我们只讨论了虚表中的三个内容:虚函数指针、vbase 偏移和 vcall 偏移。剩下的两个内容,即 offset to top 和 typeinfo 指针,在这里进行解释。它们与上面的内容关系不大。

Offset to Top

This is the displacement to the top of the object from the vtable pointer. It’s used especially in dynamic_cast<>. Let’s explore more details here.

这是从vtable指针到对象顶部的位移。它在 dynamic_cast<> 中特别使用。让我们在这里探索更多细节。

Recall the multiple inheritance case, suppose I already have a pointer of Base2* to a Derived object, how can we safely down-cast to a Derived pointer? Since this pointer is addressed at the subobject, rather than the top of Derived. To cast it back, the offset is added. The way looks like in the following Pseudo C++ Code,

回想一下多重继承的情况,假设我已经有一个指向 Derived 对象的 Base2* 指针,我们如何安全地将其向下转换为 Derived 指针?由于该指针是定位在子对象上而不是 Derived 顶部,所以需要添加偏移量来进行转换。具体操作可以参考以下伪代码:

Derived* pD = pB + *(pB->vptr + index);

The index is determined by the compiler since the whole vtable is structured at compile time. dynamic_cast only works on polymorphic classes, i.e. classes with virtual functions. If Base2 is non-polymorphic, there’s no virtual table pointer and the cast will fail.

索引是由编译器确定的,因为整个虚函数表在编译时结构化。dynamic_cast 只能用于多态类,即具有虚函数的类。如果 Base2 不是多态的,则没有虚表指针,转换将失败。

But what if the pB is just a pointer to Base2 instead of Derived. If can retrieve the offset through its vptr and get a “looking good” pointer? To solve this problem, the compiler needs to access the object’s RTTI(Run-Time Type Identification). dynamic_cast<T> will firstly check if our object is of type T via the type info and keep doing the cast if it is. That’s why the typeinfo pointer is introduced.

但是如果 pB 只是指向 Base2 而不是 Derived 的指针呢?如果可以通过它的 vptr 获取偏移量并得到一个“看起来不错”的指针呢?为了解决这个问题,编译器需要访问对象的运行时类型信息(RTTI)。dynamic_cast<T> 将首先通过类型信息检查我们的对象是否属于类型 T,并在满足条件时进行转换。这就是为什么引入typeinfo指针。

Typeinfo Pointer

C++ supports a mechanism for runtime type information through typeid operator. The typeid expression refers to a compile-time generated object of the type std::type_info, which holds meta information about a type, e.g. its name. It is also what the typeinfo pointer in the virtual tables points to. All typeinfo pointers in a vtable shall point to the same object. They are always valid pointers for polymorphic classes, but in a virtual table of a class without virtual functions, i.e. virtual bases only, these pointers are null pointers.

C++通过 typeid 运算符支持一种运行时类型信息的机制。typeid 表达式指向一个在编译时生成的 std::type_info 类型的对象,该对象保存了关于类型的元信息,例如它的名称。这也是虚函数表中 typeinfo 指针所指向的内容。虚函数表中所有 typeinfo 指针都应该指向同一个对象。对于多态类来说,它们始终是有效的指针;但对于只有虚基类而没有虚函数的类,在其虚函数表中这些指针将为空指针。

Typeinfo pointers and offset to top are always present in a virtual table.

类型信息指针和偏移量始终存在于虚表中。

Now, let’s complete the memory layout of Derived under virtual inheritance.

现在,让我们完成虚继承下 Derived 的内存布局。

Ending Words

There’s something interesting but not mentioned yet, including virtual table tables. Also how these vtables are constructed is quite fun. I plan to delve into them in future posts.

还有一些有趣但尚未提及的内容,包括虚拟表格。而且这些虚拟表格是如何构建的也非常有趣。我计划在以后的文章中深入探讨它们。

Reference

Clone this wiki locally