Skip to content

Commit

Permalink
Publish c++_object_model
Browse files Browse the repository at this point in the history
  • Loading branch information
selfboot committed May 10, 2024
1 parent 1865c2e commit 53e3220
Show file tree
Hide file tree
Showing 2 changed files with 27 additions and 7 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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 掉。但是当时并没有展开细聊下面的问题:
Expand Down Expand Up @@ -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 多次运行程序,对象的**虚拟内存地址每次都一样**,这是为什么呢?
Expand Down

0 comments on commit 53e3220

Please sign in to comment.