ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++虚函数表内存布局:单继承与多继承的底层验证

C++虚函数表内存布局:单继承与多继承的底层验证

先说一个现实问题:大部分C++开发者在简历上都敢写“熟悉多态”,但你要是突然问他,Derived继承Base之后,虚函数表里到底装了哪些函数,顺序按什么排,新增虚函数又挂在哪儿,十个里面至少有八个答不完整。更别说多继承——一个对象里有几个虚表指针、第二个基类被覆盖的槽位为什么不是直接指向函数本身,这些光靠背概念根本说不清楚。

所以这篇文章不做理论推导,只做一件最笨也最有效的事:把单继承和多继承对象的虚函数表内存布局实实在在打印出来,用代码和符号表逐槽位对应,验证哪些结论是对的、哪些理解是错的。内容适合刚学完虚函数概念、想深入理解C++对象模型的开发者,也适合那些在排查虚调用崩溃时对布局有疑惑、需要快速定位根因的人。

1. 准备工作:实验环境、验证思路与必要的前置概念

1.1 实验环境与编译参数

虚函数表的布局在不同平台、不同ABI下是有差异的。我这里以Linux x86-64 + GCC/Clang使用的Itanium C++ ABI为基准。这不是唯一的实现,但它是Linux和大部分Unix平台上最通用的标准,也是大多数服务端C++代码实际跑在上面的环境。

编译命令我统一用:

g++ -std=c++17 -O0 -g -no-pie demo.cpp -o demo

几个参数说明一下:

  • -O0:关闭优化。优化器会内联虚函数、合并重复代码,甚至干脆不生成某些函数实体,这会干扰我们的槽位对应关系。验证内存布局时老老实实开O0最省事。
  • -g:生成调试信息,方便GDB查看,也方便后续对符号表。
  • -no-pie:生成非PIE可执行文件。非PIE情况下,程序运行时的函数地址和链接地址一致,nm命令打印出来的符号地址可以直接对照运行时的虚表槽位地址。如果你在macOS或者其他环境,这个参数用不了就忽略,代码逻辑不受影响,只是地址对照没那么直观。

1.2 验证虚函数表的两条路径

验证虚表布局,行业里其实就两条路:

  1. 程序运行时直接读取对象内存,把vptr指向的地址按指针数组打印出来。这是最直观的“亲手验证”方式,结论无可辩驳。
  2. 借助编译器或调试器输出。GCC有-fdump-class-hierarchy,可以直接显示类布局和虚表;GDB有info vtbl,也可以查看虚表内容。

两条路我都建议做。运行时打印是主菜,能看到真实的槽位地址;编译器输出和符号表用来做交叉验证,把单调的地址变成可读的符号名。对初学者来说,两条路都走一遍,才算真正建立信任感。

1.3 前置概念:vptr、虚表槽位与RTTI位置

在进入实验前,先把三个关键概念钉死,避免后面看输出时犯迷糊。

首先是vptr。一个类只要有虚函数,或者继承自一个含虚函数的基类,对象头部就会多出一个隐藏的指针成员,这就是vptr。它指向该类对应的虚函数表。对象可以有一个vptr,也可以有多个——这正是多继承和单继承的核心区别之一。

其次是虚表槽位。虚表本质上是一个指针数组,每个槽位存放一个虚函数的入口地址。调用虚函数时,编译器生成的是间接调用:先从对象头取vptr,再按函数在虚表中固定的偏移取出槽位,跳转过去。这就是动态分派的核心机制。

最后是RTTI。Itanium ABI下,虚表并不是从第一个虚函数槽位开始的。vptr指向的虚表,在它之前还藏着两个重要数据:offset-to-top和typeinfo指针。简单理解,vptr[-2]是当前对象起始地址到完整对象起始地址的偏移,vptr[-1]指向类型信息。这也是后面我们要顺手体验的一个彩蛋。

提示:这一段是给你兜底的,如果你对vptr和虚函数调用机制已经很熟,可以跳过直接看单继承实验。

2. 单继承下的虚表验证:布局比想得更“老实”

2.1 最小实验代码

先看单继承最简单的情况。我们定义这样一个继承结构:

#include <cstdio> #include <cstdint> class Base { public: virtual void f1() { printf("Base::f1\n"); } virtual void f2() { printf("Base::f2\n"); } virtual void f3() { printf("Base::f3\n"); } }; class Derived : public Base { public: void f1() override { printf("Derived::f1\n"); } virtual void f4() { printf("Derived::f4\n"); } }; int main() { Derived d; auto vptr = *reinterpret_cast<uintptr_t**>(&d); for (int i = 0; i < 4; ++i) { printf("slot[%d] addr=%p\n", i, reinterpret_cast<void*>(vptr[i])); } return 0; }

这里最核心的一行是*reinterpret_cast<uintptr_t**>(&d)。它的意思是:把d的起始地址解释成uintptr_t*变量,再解引用得到第一个指针值。这个指针值就是vptr,也就是虚表数组的首地址。拿到它之后,虚表槽位就是一串连续的指针,按索引访问即可。

注意:这种强转方式在工程代码里是明确的禁忌,它绕过了类型系统。但做一次性的内存布局验证恰恰需要这种“不讲武德”的访问方式,骗过编译器,直连对象内存。实验可以这么玩,生产代码千万别学。

2.2 槽位怎么对应到函数名

运行上面代码会得到类似下面的输出:

slot[0] addr=0x401070 slot[1] addr=0x4010a0 slot[2] addr=0x4010d0 slot[3] addr=0x401130

地址本身没有意义,得把它们翻译成函数名。用nm -C对符号表即可:

nm -C single_demo | grep -E "Derived::|Base::"

输出:

0000000000401070 T Derived::f1() 00000000004010a0 T Base::f2() 00000000004010d0 T Base::f3() 0000000000401130 T Derived::f4()

把程序输出和符号表一对照,结论非常清晰:

槽位索引地址对应函数
slot[0]0x401070Derived::f1()
slot[1]0x4010a0Base::f2()
slot[2]0x4010d0Base::f3()
slot[3]0x401130Derived::f4()

2.3 单继承虚表的关键规律

这组验证结果说明了三件事。

第一,覆盖函数替换对应槽位。Base::f1()被Derived::f1()覆盖后,虚表中的原本放置Base::f1()的槽位被替换成Derived::f1()的地址。虚表不会为派生类重新排列数组,它只是直接在原位置上“换人”。

第二,未覆盖的基类函数保持原样。Base::f2()和Base::f3()没有被覆盖,槽位地址就还是基类实现的地址。这一点看起来天经地义,但在多继承里情况会复杂得多。

第三,新增虚函数追加到末尾。Derived::f4()是派生类新引入的虚函数,它不会插在中间,而是老老实实排在整个虚表数组的最后一个位置。

如果你之前听过“虚函数表按声明顺序排列”这个说法,这里就是它的准确形态:基类虚函数按基类声明顺序占位到前面,派生类新增虚函数按新增顺序追加到最后。

2.4 附赠实验:带虚析构时虚表会变大

上面为了展示对称的槽位顺序,我在Base里没有写虚析构函数。但真实工程里,设计一个多态基类而不写虚析构,等于埋雷。如果把virtual ~Base()加回去,再跑一遍程序,你会发现虚表的槽位数量多出两个,并且这两个槽位对应的符号是:

0000000000401c00 W Derived::~Derived() 0000000000401c50 W Derived::~Derived()

这是因为Itanium ABI把虚析构函数拆成了两个内部入口:一个是完整对象析构,负责调用派生类析构;一个是deleting析构,负责释放内存。它们在虚表里各占一个槽位。很多新手第一次打印虚表时发现函数对不上号,就是被虚析构拆出来的这两个槽位搞懵的。验证时记得把虚析构纳入考虑,不要用“函数个数=虚函数个数”的简单公式去套实际布局。

3. 多继承下的虚表验证:一张对象图,两张虚表

如果说单继承的虚表是一个线性数组,那么多继承的虚表就是一张需要结合对象内存布局才能看懂的地图。我们来看真正有区分度的实验。

3.1 多继承对象里到底有几个vptr

先定义多继承结构:

#include <cstdio> #include <cstdint> class Base1 { public: virtual void a1() { printf("Base1::a1\n"); } virtual void a2() { printf("Base1::a2\n"); } }; class Base2 { public: virtual void b1() { printf("Base2::b1\n"); } virtual void b2() { printf("Base2::b2\n"); } }; class DerivedMI : public Base1, public Base2 { public: void a1() override { printf("DerivedMI::a1\n"); } void b2() override { printf("DerivedMI::b2\n"); } virtual void c1() { printf("DerivedMI::c1\n"); } }; int main() { printf("sizeof(DerivedMI)=%zu\n", sizeof(DerivedMI)); DerivedMI d; uintptr_t p1 = reinterpret_cast<uintptr_t>(static_cast<Base1*>(&d)); uintptr_t p2 = reinterpret_cast<uintptr_t>(static_cast<Base2*>(&d)); printf("Base1 subobject offset=0\n"); printf("Base2 subobject offset=%ld bytes\n", p2 - p1); printf("--- Base1 vtable ---\n"); dump_vtable(static_cast<Base1*>(&d), 3); printf("--- Base2 vtable ---\n"); dump_vtable(static_cast<Base2*>(&d), 2); return 0; }

其中dump_vtable就是前面单继承实验里那个打印函数,这里不再重复贴。

运行输出的前几行:

sizeof(DerivedMI)=16 Base1 subobject offset=0 Base2 subobject offset=8 bytes

这两个数字直接揭示了一个关键结论:DerivedMI对象里有两个vptr。Base1子对象位于偏移0处,有自己的vptr;Base2子对象位于偏移8处,也有自己的vptr。由于这个类没有额外的数据成员,所以对象大小就是两个指针的大小——16字节。

在单继承中,派生类对象只有基类子对象一个,所以只有一个vptr。在多继承中,只要一个直接基类含虚函数,每个这样的基类子对象都会携带各自独立的vptr。这个结论用sizeof验证比纯讲概念直观得多,也更容易让人信服。

3.2 新增虚函数最终挂在哪张表上

运行上面程序,虚表部分的输出和符号对照如下:

--- Base1 vtable --- slot[0] addr=0x401070 -> DerivedMI::a1() slot[1] addr=0x4010a0 -> Base1::a2() slot[2] addr=0x401130 -> DerivedMI::c1() --- Base2 vtable --- slot[0] addr=0x401150 -> Base2::b1() slot[1] addr=0x4011b0 -> non-virtual thunk to DerivedMI::b2()

注意Base1的虚表槽位数是3,Base2的是2。Base1虚表的槽位排列顺序是:DerivedMI::a1()、Base1::a2()、DerivedMI::c1()。c1是派生类新引入的虚函数,它被追加到了第一个基类的虚表末尾,而不是单独新建一张表。

这是多继承和单继承的一个重大差异:多继承新增虚函数,不会平均分配到每张虚表,而是统一追加到“主基类”虚表里。在Itanium ABI下,主基类通常就是继承列表中第一个含有虚函数的直接基类,这里就是Base1。如果把继承顺序改成class DerivedMI : public Base2, public Base1,那么主基类就变成Base2,新增虚函数c1会挂到原来Base2那张虚表上,这个规律是跟着继承顺序走的。

3.3 覆盖第二个基类时,槽位为什么不是函数本身

这是多继承虚表验证中最容易让人困惑的地方。

看Base2的vtable,slot[1]对应的符号居然不是DerivedMI::b2(),而是non-virtual thunk to DerivedMI::b2()。b2()明明被DerivedMI覆盖了,虚表里放的却不是DerivedMI::b2()函数的直接地址,而是另一个东西。

原因在于this指针的偏移调整。当通过Base2*调用b2()时,传入的this指向的是对象内部的Base2子对象,也就是偏移8字节的那个位置。但DerivedMI::b2()这个成员函数接收的this必须是DerivedMI*,也就是偏移0的完整对象地址。两者差8字节。

C++ ABI的做法是:虚表中这个被覆盖的槽位放一个中转函数,它先做一次指针调整,把this从Base2子对象位置减去8字节换算成DerivedMI起点,再跳转到真正的DerivedMI::b2()。这个中转函数就是non-virtual thunk。

用生活场景类比,thunk就像一个前台接线员,电话打进来先查明分机号,再把电话转给真正办事的人。没有这个接线员,来电就不知道把话传给谁。

我们可以用objdump把thunk的反汇编拉出来看,更直观:

objdump -d --demangle multi_demo | grep -A5 "thunk to DerivedMI::b2()"

反汇编大概长这样:

lea rax, [rdi - 0x8] jmp DerivedMI::b2()

rdi存放的就是this指针,先减8,再跳转到真正的函数实现。看到这段汇编,整个过程就彻底清晰了。

3.4 多继承虚表验证的完整结论

到这一步,我们其实已经回答了几个容易产生误解的问题:

  • 派生类覆盖第二基类函数后,第二张虚表槽位不一定直接指向最终实现,而是可能指向thunk。只有第一基类(主基类)被覆盖的槽位因为不需要调整偏移,才会直接指向函数本体。
  • 一个多继承对象有几个vptr,取决于含有虚函数的直接基类数量,而不是虚函数总数。每个带虚函数的基类子对象都维护自己的虚表和相应的vptr。
  • 普通数据成员不影响vptr数量,只影响子对象的偏移和数据区大小。上面例子Base2偏移8,是因为Base1子对象占了一个vptr的8字节。如果Base1还有一个int成员,Base2的偏移就会变成8+4再对齐后的结果。

这些结论如果只是看书,很容易怀疑“这不会是编译器优化后的特例吧”。但当你亲手跑了代码、看了地址、对着符号表逐槽位确认之后,就会明白这是ABI层面的规则,是稳定可复现的。

4. 验证路上的坑与排查经验

验证虚函数表布局这件事本身不难,但实验过程中有不少容易踩空的细节,踩过之后印象特别深。

4.1 槽位地址看到了,怎么稳定地对应函数名

最直接的问题是运行时打印出来的地址是“裸”的,它可以是任意值。建议用nm -C而不是自己空格位猜,但有几个细节会影响对照。

如果在编译时开了PIE,那么运行时地址会加载到一个随机基地址上,和nm打印的链接时地址存在一个固定偏移差。这时要么用no-pie消除这个差,要么在代码里打印函数指针值作为参照。一个常见的做法是把已知函数的地址也打出来,比如printf("f1=%p\n", &Base::f1),可惜成员函数指针不能直接当普通指针打,还得依赖宏或ABI技巧。

所以我在文章开头就强调-no-pie。这个参数在Linux下对验证体验的提升是实打实的:nm -C输出的地址可以直接和程序输出对上,不需要做加减法。

4.2 不要直接调用槽位地址验证函数

不少人在打印出槽位地址后,想验证“这个地址真的能跑对应的虚函数”,于是直接把它转成函数指针来调用。

在x86-64 SysV ABI下,成员函数的调用约定中this指针作为第一个参数传入寄存器,所以你可以写一个这样的实验代码:

using Fn = void (*)(void*); void invoke(uintptr_t slot, void* obj) { reinterpret_cast<Fn>(slot)(obj); }

对单继承对象直接传&d,对多继承第二张虚表槽位则要传static_cast<Base2*>(&d)。如果传错了、传成了DerivedMI对象起始地址,thunk里的偏移调整会把this变成错误的地址,结果大概率就是一次崩溃。

这段代码本身有很重的UB味道,它依赖调用约定细节,跨平台、跨编译器都可能失效。它只能作为实验时的辅助验证手段,绝不能出现在任何实际项目的代码里。

4.3 vptr在构造和析构期间会“变心”

验证虚表时会发现一个出人意料的现象:在构造函数的函数体里调用虚函数,并不会触发动态绑定。

比如在Base构造函数里调用f1(),在Derived构造函数里再调用一次,打印的结果是Base::f1()和Derived::f1()。为什么?因为构造执行到Base阶段时,对象头部的vptr已经初始化指向Base的虚表,还没切换到Derived的虚表;只有构造推进到Derived阶段,vptr才会更新为Derived的虚表。

析构则反向进行:Derived析构执行完毕后,vptr再次切回Base虚表。所以析构函数里调用虚函数也可能出现和预期不同的结果。

这不是“虚函数表被篡改”,而是ABI故意设计的阶段性可见性。它保证了基类构造和析构期间,虚拟调用不会冒然进入派生类中那些可能依赖尚未初始化成员的函数。

4.4 验证过程中常见的其他问题速查

现象根因处理方式
打印出的虚表槽位顺序和预期不符没考虑析构函数占位,或者开了优化用-O0编译;对比nm -C符号表
第二个基类被覆盖的槽位符号是thunk多继承this指针需要偏移调整这是正常现象,反汇编看lea/jmp确认
memset对象后虚调用崩溃vptr被清零或覆盖不要用memset初始化含虚函数的对象
删除基类指针时不调用派生类析构基类析构函数不是虚函数多态基类必须声明虚析构函数
多继承的成员函数指针sizeof为16指针之外还有this偏移调整信息不要跨ABI假设成员函数指针大小
-fdump-class-hierarchy生成的输出没找到GCC会把输出写到指定文件注意查看命令执行目录下生成的.class后缀文件

提示:如果在GDB里想要更快的虚表查看方式,可以用info vtbl <对象名>。它会直接列出虚表槽位对应的函数符号,做交叉验证特别方便。

写在最后的一个实操体会

做完了这一轮验证,我自己的最大感受是:虚函数表不是一个需要死记硬背的抽象概念,而是一个可以用代码亲手触碰的真实内存结构。单继承的虚表一眼就能看懂,多继承的虚表加上thunk偏移调整后,也只是一层窗户纸——捅破之后,很多看似神秘的现象,比如为什么某个槽位不是函数本体、为什么第二个基类指针调用需要中转,都能解释得明明白白。

如果你也想彻底吃透这一块,我建议你顺着这个思路,自己把虚继承、菱形继承也各写一版实验代码跑一遍。虚继承的虚表里会多出vbase偏移项,布局规则比普通多继承还要绕,但是验证方法和本文完全一致:打印vptr、对符号表、反汇编thunk。把这一套方法论练成肌肉记忆,以后不管遇到哪种复杂的继承关系,都能在半小时内把布局摸清楚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进