C++虚函数表内存布局:单继承与多继承的底层验证
先说一个现实问题大部分C开发者在简历上都敢写“熟悉多态”但你要是突然问他Derived继承Base之后虚函数表里到底装了哪些函数顺序按什么排新增虚函数又挂在哪儿十个里面至少有八个答不完整。更别说多继承——一个对象里有几个虚表指针、第二个基类被覆盖的槽位为什么不是直接指向函数本身这些光靠背概念根本说不清楚。所以这篇文章不做理论推导只做一件最笨也最有效的事把单继承和多继承对象的虚函数表内存布局实实在在打印出来用代码和符号表逐槽位对应验证哪些结论是对的、哪些理解是错的。内容适合刚学完虚函数概念、想深入理解C对象模型的开发者也适合那些在排查虚调用崩溃时对布局有疑惑、需要快速定位根因的人。1. 准备工作实验环境、验证思路与必要的前置概念1.1 实验环境与编译参数虚函数表的布局在不同平台、不同ABI下是有差异的。我这里以Linux x86-64 GCC/Clang使用的Itanium C ABI为基准。这不是唯一的实现但它是Linux和大部分Unix平台上最通用的标准也是大多数服务端C代码实际跑在上面的环境。编译命令我统一用g -stdc17 -O0 -g -no-pie demo.cpp -o demo几个参数说明一下-O0关闭优化。优化器会内联虚函数、合并重复代码甚至干脆不生成某些函数实体这会干扰我们的槽位对应关系。验证内存布局时老老实实开O0最省事。-g生成调试信息方便GDB查看也方便后续对符号表。-no-pie生成非PIE可执行文件。非PIE情况下程序运行时的函数地址和链接地址一致nm命令打印出来的符号地址可以直接对照运行时的虚表槽位地址。如果你在macOS或者其他环境这个参数用不了就忽略代码逻辑不受影响只是地址对照没那么直观。1.2 验证虚函数表的两条路径验证虚表布局行业里其实就两条路程序运行时直接读取对象内存把vptr指向的地址按指针数组打印出来。这是最直观的“亲手验证”方式结论无可辩驳。借助编译器或调试器输出。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_castuintptr_t**(d); for (int i 0; i 4; i) { printf(slot[%d] addr%p\n, i, reinterpret_castvoid*(vptr[i])); } return 0; }这里最核心的一行是*reinterpret_castuintptr_t**(d)。它的意思是把d的起始地址解释成uintptr_t*变量再解引用得到第一个指针值。这个指针值就是vptr也就是虚表数组的首地址。拿到它之后虚表槽位就是一串连续的指针按索引访问即可。注意这种强转方式在工程代码里是明确的禁忌它绕过了类型系统。但做一次性的内存布局验证恰恰需要这种“不讲武德”的访问方式骗过编译器直连对象内存。实验可以这么玩生产代码千万别学。2.2 槽位怎么对应到函数名运行上面代码会得到类似下面的输出slot[0] addr0x401070 slot[1] addr0x4010a0 slot[2] addr0x4010d0 slot[3] addr0x401130地址本身没有意义得把它们翻译成函数名。用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_castuintptr_t(static_castBase1*(d)); uintptr_t p2 reinterpret_castuintptr_t(static_castBase2*(d)); printf(Base1 subobject offset0\n); printf(Base2 subobject offset%ld bytes\n, p2 - p1); printf(--- Base1 vtable ---\n); dump_vtable(static_castBase1*(d), 3); printf(--- Base2 vtable ---\n); dump_vtable(static_castBase2*(d), 2); return 0; }其中dump_vtable就是前面单继承实验里那个打印函数这里不再重复贴。运行输出的前几行sizeof(DerivedMI)16 Base1 subobject offset0 Base2 subobject offset8 bytes这两个数字直接揭示了一个关键结论DerivedMI对象里有两个vptr。Base1子对象位于偏移0处有自己的vptrBase2子对象位于偏移8处也有自己的vptr。由于这个类没有额外的数据成员所以对象大小就是两个指针的大小——16字节。在单继承中派生类对象只有基类子对象一个所以只有一个vptr。在多继承中只要一个直接基类含虚函数每个这样的基类子对象都会携带各自独立的vptr。这个结论用sizeof验证比纯讲概念直观得多也更容易让人信服。3.2 新增虚函数最终挂在哪张表上运行上面程序虚表部分的输出和符号对照如下--- Base1 vtable --- slot[0] addr0x401070 - DerivedMI::a1() slot[1] addr0x4010a0 - Base1::a2() slot[2] addr0x401130 - DerivedMI::c1() --- Base2 vtable --- slot[0] addr0x401150 - Base2::b1() slot[1] addr0x4011b0 - non-virtual thunk to DerivedMI::b2()注意Base1的虚表槽位数是3Base2的是2。Base1虚表的槽位排列顺序是DerivedMI::a1()、Base1::a2()、DerivedMI::c1()。c1是派生类新引入的虚函数它被追加到了第一个基类的虚表末尾而不是单独新建一张表。这是多继承和单继承的一个重大差异多继承新增虚函数不会平均分配到每张虚表而是统一追加到“主基类”虚表里。在Itanium ABI下主基类通常就是继承列表中第一个含有虚函数的直接基类这里就是Base1。如果把继承顺序改成class DerivedMI : public Base2, public Base1那么主基类就变成Base2新增虚函数c1会挂到原来Base2那张虚表上这个规律是跟着继承顺序走的。3.3 覆盖第二个基类时槽位为什么不是函数本身这是多继承虚表验证中最容易让人困惑的地方。看Base2的vtableslot[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的偏移就会变成84再对齐后的结果。这些结论如果只是看书很容易怀疑“这不会是编译器优化后的特例吧”。但当你亲手跑了代码、看了地址、对着符号表逐槽位确认之后就会明白这是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_castFn(slot)(obj); }对单继承对象直接传d对多继承第二张虚表槽位则要传static_castBase2*(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。把这一套方法论练成肌肉记忆以后不管遇到哪种复杂的继承关系都能在半小时内把布局摸清楚。