本文分析了在不同场景下的C++对象模型
1. 前言
1.1 测试环境
- Linux ubuntu18arm64 4.15.0-76-generic #86-Ubuntu SMP Fri Jan 17 17:25:58 UTC 2020 aarch64 aarch64 aarch64 GNU/Linux
- gcc version 7.4.0 (Ubuntu/Linaro 7.4.0-1ubuntu1~18.04.1)
- glibc 2.27
- C++11
1.2 测试代码
1 | class Base { |
各类的继承关系图如下
2. 调试分析
2.1 普通类
普通类可以有虚函数, 也可以没有. 这里以带虚函数的Base b对象为例
2.1.1 内存分析
查看对象信息
1 | (gdb) p /x &b |
查看_vptr.Base
1 | (gdb) x/8xg 0x0000aaaaaaabcc58 |
2.1.2 对象模型
根据前面的分析, 对Base类(包含虚函数)的实例b, 其对象模型如下
1 | +-----------+ |
这里vtable中有两个版本的Base::~Base()
- 第1个Base::~Base()完成Base类的析构函数功能. 以前提到的栈对象/局部静态对象/全局对象析构时会调用该版本
- 第2个Base::~Base()先调用第1个Base::~Base(),然后释放对象占用的内存. 堆对象析构时会调用该版本.
2.1.3 代码分析
通过实例直接调用虚函数
1 | b.foo(); |
反汇编如下
1 | 0x0000aaaaaaaab2d8 <+84>: add x0, x29, #0x48 |
通过指针调用虚函数
1 | pb = &b; |
反汇编如下
1 | 0x0000aaaaaaaab2e0 <+92>: add x0, x29, #0x48 |
流程如下
- 确定当前对象地址(x0 = x29 + 0x48)
- 找到vptr(gcc下默认就是[x0])
- 在vtable中找到要调用的虚函数(vtable[2])
- 跳转到虚函数执行
2.2 单一继承
这里以Base1 b1对象为例
2.2.1 内存分析
查看对象信息
1 | (gdb) p /x &b1 |
查看_vptr.Base
1 | (gdb) x/8xg 0x0000aaaaaaabcc28 |
2.2.2 对象模型
根据前面的分析,对Base1类(包含虚函数 + 单一继承)的实例b1, 其对象模型如下
1 | +-----------+ |
- 单一继承情况下, 虽然_vptr.Base包含基类名, 但实际是Base1类的vptr
- Base1 override基类Base的foo(), 但没有override基类Base的bar(), 所以Base1虚表中的foo()是Base1::foo(), bar()是Base::bar().
2.2.3 代码分析
通过实例直接调用虚函数
1 | b1.foo(); |
反汇编如下
1 | 0x0000aaaaaaaab300 <+124>: add x0, x29, #0x58 |
反汇编如下
1 | 0x0000aaaaaaaab310 <+140>: add x0, x29, #0x58 |
2.3 虚继承1
先看虚继承简单的情形, 这里以Base2 b2对象为例
2.3.1 内存分析
查看对象信息
1 | (gdb) p /x &b2 |
这里有两个vptr
- _vptr.Base2 = 0x0000aaaaaaabcba8
- _vptr.Base = 0x0000aaaaaaabcbe8
查看_vptr.Base2
1 | (gdb) x/8xg 0x0000aaaaaaabcba8 |
查看_vptr.Base
1 | (gdb) x/8xg 0x0000aaaaaaabcbe8 |
几点说明:
- 这里的_vptr.Base(0x0000aaaaaaabcbe8)并不是Base类真正的vptr(0x0000aaaaaaabcc58)
- 前面的三个函数是编译器生成的thunk函数,用于修正对象地址(this指针由Base子对象地址切换到Base2对象地址),跳转到Base2 override的虚函数。
- 最后一个虚函数是Base::bar(), Base2没有override, 这里直接存储其地址, this指针为Base子对象地址
2.3.2 对象模型
根据前面的分析,对Base2类(虚继承)的实例b2, 其对象模型如下
1 | +-----------+ |
2.3.3 代码分析
通过实例直接调用虚函数
1 | b2.foo(); |
反汇编如下
1 | 0x0000aaaaaaaab390 <+268>: add x0, x29, #0x68 |
通过基类指针调用虚函数
1 | pb = &b2; |
对应汇编如下:
1 | 0x0000aaaaaaaab348 <+196>: add x0, x29, #0x68 |
通过上面代码可以看到, 虚继承和单一继承在通过基类指针访问派生类对象过程中,
流程不同.
- 派生类对象地址加上特定的偏移得到基类子对象地址(编译器在处理pb = &b2后, 此时的pb已经不是b2的地址了, 而是&b2 + 0x10, 指向Base子对象)
- 根据基类子对象地址, 找到_vptr.Base
- 根据要调用的虚函数, 在vtable找到对应项: thunk函数或基类版本
- thunk函数是一段汇编代码, 通过修正对象地址(基类子对象地址切换到派生类对象地址), 最终跳转到派生类版本执行
2.4 虚继承2
最后看看虚继承最复杂的情形, 这里以Derived d对象为例
2.4.1 内存分析
查看对象信息
1 | (gdb) p /x &d |
查看_vptr.Base2
1 | (gdb) x/8xg 0x0000aaaaaaabc950 |
Base2类是Derived派生类列表的第一个直接基类, 这里的_vptr.Base2实际是Derived类的vptr
查看_vptr.Base3
1 | (gdb) x/8xg 0x0000aaaaaaabc980 |
Base3类是Derived派生类列表的第二个直接基类, 这里的_vptr.Base3并不是Base3类的vptr.
_vptr.Base
1 | (gdb) x/8xg 0x0000aaaaaaabc9c0 |
2.4.2 对象模型
根据前面的分析,对Derived类(虚函数 + 虚继承)的实例d, 其对象模型如下
1 | +-------------+ |
2.4.3 代码分析
通过实例直接调用虚函数
1 | d.foo(); |
反汇编如下
1 | => 0x0000aaaaaaaab408 <+388>: add x0, x29, #0xa8 |
通过基类指针调用虚函数
通过Base2基类指针访问d对象
1 | Base2 *pb2 = &d; |
反汇编如下
1 | 0x0000aaaaaaaab398 <+276>: add x0, x29, #0xa8 |
通过Base3基类指针访问d对象
1 | Base3 *pb3 = &d; |
反汇编如下
1 | 0x0000aaaaaaaab3c0 <+316>: add x0, x29, #0xa8 |
通过Base基类指针访问d对象
1 | pb = &d; |
反汇编如下
1 | 0x0000aaaaaaaab3e4 <+352>: add x0, x29, #0xa8 |
3. 总结
- 普通类的对象模型主要由vptr(虚函数) + 当前类非静态成员变量组成
- 单一继承类的对象模型主要由vptr + 基类非静态成员变量 + 派生类非静态成员变量组成
- 虚继承的对象模型最复杂, 主要由多个vptr + 直接继承类非静态成员变量 + 派生类非静态成员变量 + 虚基类非静态成员变量组成