C++多继承内存布局:虚析构与虚函数表相关技术咨询
测试代码
我编写了如下所示的main.cpp文件:
#include <stdio.h> class Base1 { public: int ibase1; Base1() : ibase1(10) {} virtual void f_b1_1() { printf("Base1::f_b1_1()()\n"); } virtual void f_b1_2() { printf("Base1::f_b1_2()()\n"); } virtual ~Base1() { printf("Base1::~Base1()\n"); } }; class Base2 { public: int ibase2; Base2() : ibase2(20) {} virtual void f_b2_1() { printf("Base2::f_b2_1()()\n"); } virtual void f_b2_2() { printf("Base2::f_b1_2()()\n"); } virtual ~Base2() { printf("Base2::~Base2()\n"); } }; class Base3 { public: int ibase3; Base3() : ibase3(30) {} virtual void f_b3_1() { printf("Base3::f_b3_1()\n"); } virtual void f_b3_2() { printf("Base3::f_b3_2()\n"); } virtual ~Base3() { printf("Base3::~Base3()\n"); } }; class Derive : public Base1, public Base2, public Base3 { public: int iderive; Derive() : iderive(100) {} virtual void f_b1_1() { printf("Derive::f_b1_1()\n"); } virtual void f_b2_1() { printf("Derive::f_b2_1()\n"); } virtual void f_b3_1() { printf("Derive::f_b2_1()\n"); } virtual void f_d_1() { printf("Derive::f_d_1()\n"); } virtual ~Derive() { printf("Derive::~Derive()\n"); } }; int main() { Derive d; long **pVtab = (long **)&d; for (int i = -2; i <= 18; ++i) { printf("vtab offset=%d addr=%lx\n", (i + 2) * 8, pVtab[0][i]); } return 0; }
使用g++ -fdump-lang-class -c main.cpp命令查看对象内存布局,执行后生成了main.cpp.001l.class文件,类布局文件部分内容如下:
上述代码的运行输出结果如下:
待解答疑问
- 类布局文件截图中红框标注的内容是什么含义?
Derive::_ZThn16_N6Derive6f_b2_1Ev与Derive::f_b2_1之间存在什么关系?Derive::_ZThn16_N6Derive6f_b3_1Ev与Derive::f_b3_1之间存在什么关系?Derive::_ZThn16_N6DeriveD1Ev、Derive::_ZThn16_N6DeriveD0Ev、Derive::_ZThn32_N6DeriveD1Ev、Derive::_ZThn32_N6DeriveD0Ev这几个符号是否与Derive::~Derive相关?- 上述现象是否与“C++ trunk”有关?
- 运行环境为Ubuntu 20.04、g++ 9.4.0,学习该部分内容可以参考哪些资料?
问题解答
1. 红框内容含义
红框标注的是多继承场景下,非首基类虚表中存储的thunk桩函数条目。
多继承时派生类对象会按继承顺序依次排布各个基类的子对象:首基类Base1的起始地址和整个Derive对象的起始地址一致,调用虚函数不需要调整this指针;但后续的Base2、Base3子对象在对象内存中有固定偏移(Base2偏移16字节,Base3偏移32字节),当用非首基类类型的指针指向派生类对象时,指针实际指向对应基类子对象的起始位置,调用虚函数时必须先把this指针调整回Derive对象的真实首地址,才能正确访问派生类成员,thunk就是专门做this指针调整+跳转到实际虚函数的极小段代码。
2. _ZThn16_N6Derive6f_b2_1Ev与Derive::f_b2_1的关系
前者是后者对应的thunk桩函数。当通过Base2类型的指针/引用调用f_b2_1()时,从Base2子对象对应的虚表中拿到的是这个thunk的地址:它首先将传入的this指针减去16(对应Base2子对象相对于Derive对象首地址的16字节偏移),得到Derive对象的真实起始地址,之后直接跳转到真正的Derive::f_b2_1函数执行,没有重复的函数逻辑。
3. _ZThn*_N6Derive6f_b3_1Ev与Derive::f_b3_1的关系
你问题里写的_ZThn16_前缀属于笔误,实际类布局中f_b3_1对应的thunk前缀为_ZThn32_,它和Derive::f_b3_1的关系与上一问完全一致:当通过Base3类型指针调用f_b3_1()时,thunk会先把this指针减去32(对应Base3子对象的32字节偏移),再跳转到真正的Derive::f_b3_1执行。
补充:你的测试代码中Derive::f_b3_1的printf输出字符串写错了,实际打印的是Derive::f_b2_1(),属于笔误。
4. 析构相关thunk符号的含义
这几个符号全部和Derive::~Derive直接相关,是不同场景下析构函数对应的thunk和析构版本:
- 后缀
D1Ev对应完整对象析构函数,包含销毁派生类成员、调用所有基类析构的完整逻辑,用于非delete场景的对象析构; - 后缀
D0Ev对应删除析构函数,在完整析构逻辑的基础上会额外调用operator delete释放对象内存,对应delete基类指针指向派生类对象的场景; - 前缀
_ZThn16_是供Base2子对象虚表使用的析构thunk,调用时先将this指针减16再跳转到对应析构函数; - 前缀
_ZThn32_是供Base3子对象虚表使用的析构thunk,调用时先将this指针减32再跳转到对应析构函数。
这套机制保证了无论用Base1/Base2/Base3哪种类型的指针销毁派生类对象,都能正确调整this指针,执行完整的析构逻辑,不会出现内存错误。
5. 与C++ trunk的相关性
完全无关。thunk机制是Itanium C++ ABI中明确规定的标准实现方案,g++、clang等遵循Itanium ABI的编译器从很早的版本开始就用这套逻辑处理多继承的虚函数调用问题,不是C开发主线版本(trunk)新增的特性,你使用的g 9.4是非常稳定的发布版本,观察到的现象是C++多继承的常规底层实现。
6. 学习参考资料
- 《深度探索C对象模型》:专门讲解C对象内存布局、虚表构造、多继承this调整、虚函数调用流程的经典书籍,是入门C++底层对象模型的首选资料;
- Itanium C++ ABI官方文档:g++、clang实际遵循的二进制接口规范,里面明确规定了虚表结构、符号命名、thunk实现、析构函数分类的所有细节;
- 实操调试:可以用gdb单步调试测试程序,查看虚表内存布局、反汇编thunk函数的指令,直观看到this指针调整的逻辑;也可以用
g++ -S main.cpp生成汇编代码,直接查看thunk的实现,逻辑非常简单,通常只有2-3条指令。
内容的提问来源于stack exchange,提问作者xxllxx666

