diff --git a/images/20240510_c++_object_model_virtual_derived_more.png b/images/20240510_c++_object_model_virtual_derived_more.png new file mode 100644 index 0000000000..bebb9de919 Binary files /dev/null and b/images/20240510_c++_object_model_virtual_derived_more.png differ diff --git a/source/_drafts/c-object-model.md b/source/_posts/2024-05-10-c++_object_model.md similarity index 84% rename from source/_drafts/c-object-model.md rename to source/_posts/2024-05-10-c++_object_model.md index 233d9fc4f1..3f1b061441 100644 --- a/source/_drafts/c-object-model.md +++ b/source/_posts/2024-05-10-c++_object_model.md @@ -1,9 +1,11 @@ --- title: 结合实例深入理解 C++ 对象的内存布局 -tags: [C++] +tags: + - C++ category: 程序设计 toc: true -description: +date: 2024-05-10 22:32:35 +description: 通过实例来深入理解 C++ 对象的内存布局,包括基础数据类、带方法的类、私有成员、静态成员、类继承等。通过 GDB 查看对象的内存布局,探讨成员变量、成员方法、虚函数表等在内存中的存储位置和实现细节,帮助大家对 C++ 类成员变量和函数在内存布局有个直观的理解。 --- 在前面 [Bazel 依赖缺失导致的 C++ 进程 coredump 问题分析](https://selfboot.cn/2024/03/15/object_memory_coredump/) 这篇文章,因为二进制使用了不同版本的 proto 对象,对象的内存布局不一致导致读、写成员的内存地址错乱,进而导致进程 crash 掉。但是当时并没有展开细聊下面的问题: @@ -355,18 +357,36 @@ int main() { } ``` -在上述代码中,`Basic* ptr = &derivedObj;` 这一行演示了多态性的基础:一个基类指针指向派生类对象。当通过基类指针调用虚函数 `ptr->printInfo();`时,将在运行时解析为 `Derived::printInfo()` 方法,这是多态性的直接体现。对于 `ptr->printB();` 调用,由于派生类中没有定义 `printB()` 方法,所以会调用基类的 `printB()` 方法。 +上面代码中,`Basic* ptr = &derivedObj;` 这一行用一个基类指针指向派生类对象,当通过基类指针调用虚函数 `ptr->printInfo();`时,将在运行时解析为 `Derived::printInfo()` 方法,这是就是运行时多态。对于 `ptr->printB();` 调用,由于派生类中没有定义 `printB()` 方法,所以会调用基类的 `printB()` 方法。 -有虚函数的继承情况下,对象的内存布局是什么样?虚函数的多态调用又是怎么实现的呢?实践出真知,我们可以通过 GDB 来查看对象的内存布局,在此基础上可以验证虚函数表指针,虚函数表以及多态调用的实现细节。 - -这里重点看下 Derived 类对象的内存布局,如下图: +那么在有虚函数继承的情况下,对象的内存布局是什么样?虚函数的多态调用又是怎么实现的呢?实践出真知,我们可以通过 GDB 来查看对象的内存布局,在此基础上可以验证虚函数表指针,虚函数表以及多态调用的实现细节。这里先看下 Derived 类对象的内存布局,如下图: ![带虚函数的继承类内存布局](https://slefboot-1251736664.file.myqcloud.com/20240509_c++_object_model_virtual_derived_pointer.png) -可以看到派生类对象的开始部分有一个 8 字节的虚函数表指针 vptr,这个指针指向一个虚函数表(vtable),虚函数表中存储了虚函数的地址。在这个例子中,`Derived` 类有两个虚函数,`printInfo` 和 `printB`,所以虚函数表中有两个函数指针。这个虚函数表是在编译时生成的,存储在程序的数据段中,是只读的。基类的情况类似,下面画一个图来描述更清晰些: +可以看到派生类对象的开始部分(地址 `0x7fffffffe370` 处)有一个 8 字节的虚函数表指针 vptr(指针地址 `0x555555557d80`),这个指针指向一个虚函数表(vtable),虚函数表中存储了虚函数的地址,一共有两个地址 `0x55555555538c` 和 `0x555555555336`,分别对应`Derived` 类中的两个虚函数 `printInfo` 和 `printB`。基类的情况类似,下面画一个图来描述更清晰些: ![带虚函数的继承类内存布局示意图](https://slefboot-1251736664.file.myqcloud.com/20240509_c++_object_model_virtual_pointer_demo.png) +现在搞清楚了虚函数在类对象中的内存布局。在编译器实现中,**虚函数表指针是每个对象实例的一部分,占用对象实例的内存空间**。对于一个实例对象,**通过其地址就能找到对应的虚函数表,然后通过虚函数表找到具体的虚函数地址,实现多态调用**。那么为什么**必须通过引用或者指针才能实现多态调用**呢?看下面 3 个调用,最后一个没法多态调用。 + +```c++ +Basic& ref = derivedObj; +Basic* ptr = &derivedObj; +Basic dup = derivedObj; // 没法实现多态调用 +``` + +我们用 GDB 来看下这三种对象的内存布局,如下图: + +![3 种对象的内存布局区别,深入理解多态](https://slefboot-1251736664.file.myqcloud.com/20240510_c++_object_model_virtual_derived_more.png) + +指针和引用在编译器底层没有区别,ref 和 ptr 的地址一样,就是原来派生类 derivedObj 的地址`0x7fffffffe360`,里面的虚函数表指针指向派生类的虚函数表,所以可以调用到派生类的 printInfo。而这里的 dup 是通过拷贝构造函数生成的,编译器执行了隐式类型转换,从派生类截断了基类部分,生成了一个基类对象。dup 中的虚函数表指针指向的是基类的虚函数表,所以调用的是基类的 printInfo。 + +从上面 dup 虚函数表指针的输出也可以看到,虚函数表不用每个实例一份,**所有对象实例共享同一个虚函数表即可**。虚函数表是每个多态类一份,由编译器在编译时创建。 + +当然,这里是 Mac 平台下 Clang 编译器对于多态的实现。C++ 标准本身没有规定多态的实现细节,没有说一定要有虚函数表(vtable)和虚函数表指针(vptr)来实现。这是因为C++标准关注的是行为和语义,确保我们使用多态特性时能够得到正确的行为,但它不规定底层的内存布局或具体的实现机制,这些细节通常由编译器的实现来决定。 + +不同编译器的实现也可能不一样,许多编译器为了访问效率,**将虚函数表指针放在对象内存布局的开始位置**。这样,虚函数的调用可以快速定位到虚函数表,然后找到对应的函数指针。如果类有多重继承,情况可能更复杂,某些编译器可能会采取不同的策略来安排虚函数表指针的位置,或者一个对象可能有多个虚函数表指针。 + ## 地址空间布局随机化 前面的例子中,如果用 GDB 多次运行程序,对象的**虚拟内存地址每次都一样**,这是为什么呢?