前言
本系列文章,旨在探究C++虚函数表中除函数地址以外的条目,以及这些条目的设计意图和作用,并介绍与此相关的C++类对象内存布局,最后将两者用图解的形式结合起来,给读者带来全局性的视角。
这是本系列的第一篇文章,让我们从一个简单的类开始。
本系列文章的实验环境如下:
- OS: Ubuntu 22.04.1 LTS x86_64 (virtual machine)
- g++: 11.4.0
- gdb: 12.1
对象与虚函数表内存布局
我们的探究基于下面这段代码。
1 #include <stdlib.h>
2 #include <stdint.h>
3 #include <string.h>
4
5 class Base
6 {
7 public:
8 Base(uint32_t len)
9 : len_(len)
10 {
11 buf_ = (char *)malloc(len_ * sizeof(char));
12 }
13 virtual ~Base()
14 {
15 if (nullptr != buf_)
16 {
17 free(buf_);
18 buf_ = nullptr;
19 }
20 }
21 void set_buf(const char *str)
22 {
23 if (nullptr != str && nullptr != buf_ && len_ > 0)
24 {
25 strncpy(buf_, str, len_);
26 buf_[len_ - 1] = '\0';
27 }
28 }
29
30 private:
31 uint32_t len_;
32 char *buf_;
33 };
34
35 int main(int argc, char *argv[])
36 {
37 Base base(8);
38 base.set_buf("hello");
39 return 0;
40 }
通过Compiler Explorer,可以看到生成的虚函数表的布局以及typeinfo相关内容(这个后文会详细介绍):

接下来,让我们通过gdb调试更深入地探究虚函数表和对象内存布局。
首先,执行下列命令:
g++ -g -O2 -fno-inline -std=c++20 -Wall main.cpp -o main # 编译代码,假设示例代码命名为main.cpp
gdb main # gdb调试可执行文件,此后进入gdb
b 38 # 在38行处打断点
r # run
接下来,打印对象和虚函数表的内存布局

x 命令显示的符号是经过Name Mangling的,可以使用 c++filt 命令将其还原。

整体的内存布局如下。

可以看出:
- Base 对象的虚表指针并没有指向vtable的起始位置,而是指向了偏移了16个字节的位置,即第一个虚函数地址的位置。
- 为了内存对齐, Base 对象中插入了4个字节的padding,它的值无关紧要。
到这里, 可能有些读者会有疑问,比如,什么是top_offset?为什么会有两个析构函数?别急,往下看。
深入探索
vtable在哪个segment?
我们知道,Linux下可执行文件采用ELF (Executable and Linkable Format) 格式,那么,vtable存放在哪个段 (segment)呢?
要回答这个问题,我们可以在gdb调试中使用 info files 命令打印可执行程序的段信息,然后看看vtable的首地址 0x555555557d68 在哪个段。

可以看到,是存储在.data.rel.ro段。这是一个什么段呢?.data表示数据段,.rel表示重定位 (relocation),.ro表示只读 (readonly)。.data和.ro都好理解,毕竟vtable显然应该是一种只读的数据,在程序运行期间不应该被修改。那为什么需要重定位呢?
考虑下面这段代码。
1 // base.h
2 class Base
3 {
4 public:
5 virtual bool is_odd(int n);
6 virtual ~Base() {}
7 };
8
9 // base.cpp
10 #include "base.h"
11
12 bool Base::is_odd(int n)
13 {
14 return 0 == n % 2 ? false : true;
15 }
16
17 // derived.h
18 #include "base.h"
19
20 class Derived : public Base
21 {
22 public:
23 virtual bool is_even(int n);
24 virtual ~Derived() {}
25 };
26
27 // derived.cpp
28 #include "derived.h"
29
30 bool Derived::is_even(int n)
31 {
32 return !is_odd(n);
33 }
34
35 // main.cpp
36 #include "derived.h"
37 #include <iostream>
38
39 int main()
40 {
41 Derived *p = new Derived;
42 std::cout << p->is_even(10) << '\n';
43 delete p;
44 return 0;
45 }
对于 Derived 类对象,其vtable中指向 is_odd 函数的指针,其实就是指向 Base::is_odd ,而在编译derived.cpp时,编译器是不知道这个地址的,因为 Base::is_odd 定义在base.cpp,是另一个编译单元。因此,只有在链接时, Derived 类对象的vtable中,才能填入正确的地址,这就是所谓的链接时重定位(还有一种加载时重定位,如加载.so或者.ddl,这里不再详述)。
vtable这种数据,它具有只读属性,但编译时又不能确定,只能在链接时重定位,因此它被放入了.data.rel.ro段中。
什么是top_offset?
在没有虚拟继承的情况下(正如本文中的例子),vtable的起始8个字节中会保存一个偏移量,通常被称为top_offset。它记录了基类子对象首地址到最派生类对象(也称为完整对象,就是继承体系中辈分最小的那个对象)首地址的偏移量。它的本质作用就是通过基类子对象找到完整对象,具体地,通常在以下场景中使用。
- 调用虚函数时调整 this 指针。
- 通过 dynamic_cast 进行向下类型转换。
- 在异常处理中需要定位完整对象。
需要注意的是,这里的基类子对象必须是多态对象(polymorphic objects),即声明或继承了至少一个虚拟函数的对象。如果一个基类不是多态对象,那么在派生类的虚函数表中,就不会有对应的top_offset条目,因为非多态对象的相关信息在编译时都确定了,不存在运行时多态,用不到top_offset。另外,一个vtable里可能有多个top_offset条目,这出现在多重继承中。
在本文的例子中,由于只有一个对象,"基类子对象"就是它本身,因此,偏移量是0。
读者可能对本小节的内容感到疑惑,没有关系,这里只需有个笼统的印象,等后续文章讲到类的继承体系时,我们会继续探讨这一主题,到时结合例子,大家就能有一个具体而深刻的认识了。
什么是typeinfo?
typeinfo与RTII (Run-Time Type Information) 相关,它记录了一个对象的运行时类型信息。通过gdb看,vtable中的typeinfo同样是存放在.data.rel.ro段的一个指针,它指向了一个 __cxxabiv1::__class_type_info 实例。 __class_type_info 是一个在命名空间 __cxxabiv1 中的类,它继承自 std::type_info ,其中 std::type_info 中的 __name 成员保存了一个字符串常量,即类型的名字。( std::type_info 在typeinfo中定义, __class_type_info 在cxxabi.h中定义)


typeinfo通常在多态场景下发挥作用,一般有两个用途:
- 获取运行时类型信息:通过 typeid 操作符获取对象的类型信息。即使我们只有一个基类的指针,我们也能在运行时知道该指针指向的对象的具体类型。
- 确保动态类型转换的安全性: dynamic_case 会根据typeinfo的信息,判断转换是否可行,若不可行, dynamic_cast 将返回 nullptr (对于指针)或者抛出一个 std::bad_cast 异常(对于引用)。
在后面的系列中,我们会继续深入探讨在类的继承体系下,C++是如何通过typeinfo来获取指针或引用所指向的对象的运行时类型信息的。本篇文章,让我们先通过 typeid 的实现来感受下typeinfo的工作原理。
有vtable时typeid的实现
我们来看一个简单的例子。
1 #include <iostream>
2 #include <typeinfo>
3
4 class Base
5 {
6 public:
7 virtual ~Base() {}
8 };
9
10 const char *get_type(const Base *p)
11 {
12 const std::type_info &info = typeid(*p);
13 return info.name();
14 }
15
16 int main(int argc, char *argv[])
17 {
18 Base *p = new Base;
19 std::cout << get_type(p) << '\n';
20 delete p;
21 return 0;
22 }
同样,我们用gdb来调试,在第13行打断点后run代码,打印出git_type函数的汇编代码:

这段汇编代码的大致含义是:先检查入参,若是 nullptr ,则抛出 bad_typeid 异常(注意这里传给 typeid 的是 *p ,因此会校验空指针,因为空指针不能解引用);否则,从虚函数表开始,逐步找到typeinfo name信息,即 std::type_info::__name 成员,然后参数执行 std::type_info::name() 返回字符串常量。详细解释下实现 typeid 的3句核心汇编代码。
# %rdi是我们定义的get_type函数的第一个入参,这里就是对象的首地址,根据内存布局,
# vtable指针恰好是对象的前8个字节,因此
# %rdi: 对象首地址,
# (%rdi): 对象前8个字节的内容,即vtable指针
mov (%rdi),%rax
# 如前所述,vtable指针并不指向vtable的起始位置,而是指向+16字节的偏移处,因此,
# %rax: vtable指针,即vtable首地址 + 16的位置
# %rax - 8: vtable首地址 + 8的位置,如前所述,这里存放的是指向typeinfo信息,
即__cxxabiv1::__class_type_info对象的指针
# -0x8(%rax): 取 vtable首地址 + 8 这个地址里的内容,
即__cxxabiv1::__class_type_info的首地址
mov -0x8(%rax),%rax
# 如前所述,__cxxabiv1::__class_type_info的前8个字节也是一个vtable指针,
# 接下来才是指向typeinfo name这个字符串常量的指针,因此,
# 0x8(%rax): 取出__cxxabiv1::__class_type_info对象首地址 + 8处的内容,
即指向typeinfo name的指针
mov 0x8(%rax),%rdi
用gdb手动还原上述3句汇编:

结合下面的图示,可以更好地理解。

无vtable时typeid的实现
如果我们将上述代码的第6行和第7行,即虚析构函数删除,那么就不会有vtable了,此时的 typeid 是如何实现的呢?
可以看到,这种情况下编译器不会再生成typeinfo,但会生成typeinfo name这个表示类型信息的字符串常量, typeid 会直接读取该常量。


关于destructor
背景知识
按Itanium C++ ABI描述,一共有三个destructor,符号中分别带D2、D1、D0,对应名称和作用如下:
`<ctor-dtor-name> ::= C1 # complete object constructor ::= C2 # base object constructor ::= C3 # complete object allocating constructor ::= D0 # deleting destructor ::= D1 # complete object destructor ::= D2 # base object destructor`
base object destructor of a class T
A function that runs the destructors for non-static data members of T and non-virtual direct base classes of T.
complete object destructor of a class T
A function that, in addition to the actions required of a base object destructor, runs the destructors for the virtual base classes of T.
deleting destructor of a class T
A function that, in addition to the actions required of a complete object destructor, calls the appropriate deallocation function (i.e,.
operator delete
) for T.
即:
- D2 -- base object destructor:负责销毁类的非静态数据成员以及非虚直接基类。
- D1 -- complete object destructor:相比base object destructor,还会析构虚基类。
- D0 -- deleting destructor:相比complete object destructor,还会调用释放内存相关的函数(比如 operator delete 操作符)来释放对象占用的内存。
细心的读者可能发现了,根据前面的截图,vtable中的析构函数应该是base object destructor( _ZN4BaseD2Ev ),而我给出的图示中,写的却是complete object destructor( _ZN4BaseD1Ev ),为什么?
因为在我们的例子中,不涉及虚基类,因此complete object destructor和base object destructor的实现可以是一样的,编译器将符号 _ZN4BaseD1Ev 作为 _ZN4BaseD2Ev 的别名,两者对应相同的地址,共享同一份函数代码。
假设我们的例子名为example.cpp,使用 g++ -S example.cpp 将代码编译成汇编代码example.s,然后在example.s中就能看到下面的语句。

使用 g++ -g -O2 -fno-inline -std=c++20 -Wall example.cpp -o example 命令得到最终的二进制文件,查看符号,两者的地址确实是一样的。

既然两者实际上是一个函数,那为什么我的图示中要写complete object destructor,而不是base object destructor呢?这也是ABI规定好的。
The entries for virtual destructors are actually pairs of entries. The first destructor, called the complete object destructor, performs the destruction without calling delete() on the object. The second destructor, called the deleting destructor, calls delete() after destroying the object. Both destroy any virtual bases; a separate, non-virtual function, called the base object destructor, performs destruction of the object but not its virtual base subobjects, and does not call delete().
即,base object destructor是non-virtual function,因此它不在虚拟表里边。
为什么虚拟表里要有两个destructor
对一个对象而言,它可能是临时对象或者 static 对象,这种情况下不需要显式 delete ;它也可能是在堆上 new 出来的,需要显式 delete ,这时会有两件事发生,一是destructor会被调用,以完成类的非静态成员以及基类子对象的销毁(如关闭文件描述符等等),二是调用 operator delete ,以释放这个对象本身占用的堆内存。这里顺带说一句, delete p 中的 delete 和 operator delete 中的 delete 不是一回事,前者是delete expression,后者是deallocation functions,详情可以参考cppreference。
因此,需要两个destructor,complete object destructor内部不调用 operator delete ,用于析构临时对象或者 static 对象;deleting destructor内部会调用 operator delete ,用于释放堆对象。通常,deleting destructor内部会先调用complete object destructor,再调用 operator delete 。比如,本文开头的例子,deleting destructor对应的汇编代码是这样的:

为什么不是三个?
为什么不把base object destructor也放入虚函数表呢?分两种情况讨论:
- 如果对象 obj 没有虚基类,那么complete object destructor和base object destructor共享同一份代码,没必要在虚函数表中重复登记。
- 如果对象 obj 有虚基类,那么编译器不会生成base object destructor,因为用不到。(是的,编译器并不会总是为对象生成这3个函数,它们是按需生成的)。
为什么不是一个?
complete object destructor和deleting destructor的区别在于,后者多了一个在内部调用 operator delete 的步骤。为什么非要在destructor内部调用 operator delete 呢?完全可以删掉deleting destructor,只保留complete object destructor呀?如果不是动态创建的对象,那么只需要调用complete object destructor就可以了;如果是堆上的对象,那么在调用complete object destructor后,再调用一次 operator delete 不就可以了吗?这么看来,deleting destructor似乎是多余的。
其实不是。
C++规定,不管是否显式声明, operator delete 都是 static 的,因此它不能是虚函数,但是,它又是可以overload的。假设指针 p_base 指向了一个派生类对象,而该派生类overload了 operator delete ,那么我们希望在 delete p_base 时,调用派生类中定义的 operator delete ,即我们希望操作符 operator delete 表现得像虚函数一样。如何能做到这一点呢?那就是在destructor内部调用 operator delete 。关于这部分内容,StackOverflow上的回答和Eli Bendersky的博文已经讲得很清楚了,我这里就不再班门弄斧了,读者可以参考这些资料进行更深入的了解。
#include <iostream>
class Base
{
public:
virtual ~Base() {}
static void *operator new(std::size_t sz)
{
std::cout << "new in Base called" << '\n';
return ::operator new(sz);
}
static void operator delete(void *ptr)
{
std::cout << "delete in Base called" << '\n';
::operator delete(ptr);
}
};
class Derived : public Base
{
public:
virtual ~Derived() {}
static void *operator new(std::size_t sz)
{
std::cout << "new in Derived called" << '\n';
return ::operator new(sz);
}
static void operator delete(void *ptr)
{
std::cout << "delete in Derived called" << '\n';
::operator delete(ptr);
}
};
int main()
{
Base *p_base = new Derived; // new in Derived called
delete p_base; // delete in Derived called
return 0;
}
示例代码:operator delete表现得像虚函数
综上,不能把在deleting destructor内部调用 operator delete 的行为换成在外面调用。
但是,确实可以把complete object destructor和deleting destructor合成一个,只需要加一个额外参数来控制是否要调用 operator delete 就可以了。MSVC编译器就是这么干的。
总结
本文从一个包含虚函数的简单对象(没有继承)入手,探索了vtable以及vtable中的各个条目。
- vtable位于.data.rel.ro段,这是因为,一方面,vtable是只读的,另一方面,它的内容,只有在链接时通过重定位才能确定。
- top_offset记录了基类子对象(必须是多态对象)到完整对象的偏移,用于从基类子对象地址转换到完整对象地址。后续文章中还会详细介绍它。
- typeinfo记录了对象的类型信息,用于RTII。本文仅仅窥得了它的冰山一角,后续文章会继续探索。
- C++中一共有3种destructor,其中两种位于vtable中。deleting destructor用于析构堆对象,complete object destructor用于析构其它类型的对象(栈对象、static对象、基类子对象等)。需要两个需析构函数的原因是想让 operator delete 也表现出"多态性"。当然,使用两个析构函数并不是实现这种"多态性"的唯一方式,比如,MSVC就采用了"一个destructor + 额外参数"的方案。
由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。