前言
最近翻阅侯捷先生的两本书:(翻译)《深度探索 C++ 对象模型》 和 《C++ 虚拟与多态》,获益良多。
要理解多态的工作原理,得理解这几个知识点的关系:虚函数
、虚函数表
、虚函数指针
、以及对象的 内存布局
。
- 深入探索 C++ 多态 ① - 虚函数调用链路
- 深入探索 C++ 多态 ② - 继承关系
- 深入探索 C++ 多态 ③ - 虚析构
1. 概述
1.1. 概念
本章主要探索 C++ 动态多态,我们先了解一下它的一些相关概念:
-
多态
是 C++ 中的一个重要概念,它允许在派生类中重写
基类中的函数,并以不同的方式处理相同的数据类型;多态的实现依赖于虚函数
和动态绑定
。 -
虚函数
是一种特殊的成员函数,它允许在派生类中重写基类中的函数。当一个函数被声明为虚函数时,编译器会在该类的虚函数表中添加一个条目,该条目指向该虚函数的地址。如果一个类继承了另一个类的虚函数,那么它将继承该类的虚函数表,并在其中添加自己的虚函数。 -
虚函数表
是一个包含虚函数地址的表格,每个类都有一个虚函数表。虚函数表中的每个条目都是一个指向虚函数的指针。当一个类包含虚函数时,编译器会在该类的虚函数表中添加一个条目,该条目指向该虚函数的地址。如果一个类继承了另一个类的虚函数,那么它将继承该类的虚函数表,并在其中添加自己的虚函数。 -
虚函数指针
是一个指向虚函数表的指针,它存储在每个对象的内存中。当一个对象被创建时,它的虚函数指针被初始化为指向该类的虚函数表。当一个虚函数被调用时,编译器会使用虚函数指针来查找该函数在虚函数表中的地址,并调用该函数。 -
动态绑定
是一种在运行时确定函数调用的机制。当一个函数被声明为虚函数时,编译器会使用动态绑定来确定该函数的实际地址。当一个虚函数被调用时,编译器会使用虚函数指针来查找该函数在虚函数表中的地址,并调用该函数。
部分文字来源:ChatGPT
1.2. 实例
概念比较抽象,写个 demo,配合图片凑合着理解~
- 源码。
/* g++ -std='c++11' test.cpp -o t && ./t */
#include <iostream>
#include <memory>
class Model {
public:
virtual void face() { std::cout << "model's face!" << std::endl; }
};
class Gril : public Model {
public:
virtual void face() { std::cout << "girl's face!" << std::endl; }
};
class Man : public Model {
public:
virtual void face() { std::cout << "man's face!" << std::endl; }
};
class Boy : public Model {
public:
virtual void face() { std::cout << "boy's face!" << std::endl; }
};
void take_photo(const std::unique_ptr<Model>& m) { m->face(); }
int main() {
auto model = std::unique_ptr<Model>(new Model);
auto girl = std::unique_ptr<Model>(new Gril);
auto man = std::unique_ptr<Model>(new Man);
auto boy = std::unique_ptr<Model>(new Boy);
take_photo(model);
take_photo(girl);
take_photo(man);
take_photo(boy);
return 0;
}
- 运行结果。
model's face!
girl's face!
man's face!
boy's face!
2. 工作环境
2.1. 系统
# cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)
# cat /proc/version
Linux version 3.10.0-1127.19.1.el7.x86_64 (mockbuild@kbuilder.bsys.centos.org)
(gcc version 4.8.5 20150623 (Red Hat 4.8.5-39) (GCC) )
2.2. 工具
文章分析多态原理,会用到下面的一些工具:
工具 | 描述 |
---|---|
gdb | gdb 是 GNU 调试器的缩写,是一个用于调试程序的工具。 |
-fdump-class-hierarchy | -fdump-class-hierarchy 是 GCC 的一个编译器选项,用于在编译过程中生成类层次结构的信息。它会将类的继承关系以文本形式输出到一个文件中,以便开发人员可以查看和分析类之间的关系。——这个选项在调试和理解代码中的类继承关系时非常有用。 |
c++filt | c++filt 是一个用于解析 C++ 符号的工具。它可以将由 C++ 编译器生成的符号进行反解析,以便更容易理解和阅读。它可以将由C++编译器生成的符号转换为可读的函数名、类名和变量名。 |
objectdump | objectdump 是一个用于分析目标文件的工具。它可以显示目标文件的各个节(section)的内容,包括代码、数据、符号表等。它还可以反汇编目标文件的机器码,以便更深入地了解程序的执行过程。 |
部分文字来源:ChatGPT
3. 多态重要数据结构
我在调试 dynamic_cast 内部源码时,发现一些数据结构:__class_type_info
,vtable_prefix
,它们有利于我更好地理解多态的工作原理。
调试方式请参考:《(ubuntu) vscode + gdb 调试 c++》
3.1. 类型信息
type_info
是一个类的类型信息的数据结构,用于在运行时获取对象的类型信息;多态工作机制根据不同应用场景,从 type_info
派生各个类型信息结构类。
- 基础类型信息结构。
/* /usr/include/c++/4.8.2/typeinfo */
// The type_info class describes type information generated by an implementation.
class type_info {
protected:
const char* __name;
};
/* /usr/include/c++/4.8.2/cxxabi.h */
// Type information for a class.
class __class_type_info : public std::type_info {
public:
...
};
- 单一继承类型信息结构。
/* /usr/include/c++/4.8.2/cxxabi.h */
// Type information for a class with a single non-virtual base.
class __si_class_type_info : public __class_type_info {
public:
const __class_type_info *__base_type;
...
};
- 多重继承或虚拟继承类型信息结构。
/* /usr/include/c++/4.8.2/cxxabi.h */
// Helper class for __vmi_class_type.
class __base_class_type_info {
public:
const __class_type_info *__base_type; // Base class type.
#ifdef _GLIBCXX_LLP64
long long __offset_flags; // Offset and info.
#else
long __offset_flags; // Offset and info.
#endif
...
};
// Type information for a class with multiple and/or virtual bases.
class __vmi_class_type_info : public __class_type_info {
public:
unsigned int __flags; // Details about the class hierarchy.
unsigned int __base_count; // Number of direct bases.
// The array of bases uses the trailing array struct hack so this
// class is not constructable with a normal constructor. It is
// internally generated by the compiler.
__base_class_type_info __base_info[1]; // Array of bases.
...
};
3.2. 虚表描述结构
vtable_prefix
:虚表描述结构,用于表示虚函数表的前缀。一个对象可能有多个虚指针,多个虚表描述结构;对象的每个虚指针指向对应的 vtable_prefix.origin
。
- whole_object:我认为改为:
top_offset
会更贴切一点。对象内存中的当前虚指针位置,离顶端的偏移位置,因为对象有可能有多个虚表,通过偏移量可以找到对象内存布局上对应的虚指针。 - whole_type: 类的类型信息。
- origin:虚指针指向虚表的位置。
/* /usr/src/debug/gcc-4.8.5-20150702/libstdc++-v3/libsupc++/tinfo.h */
// Initial part of a vtable, this structure is used with offsetof, so we don't
// have to keep alignments consistent manually.
struct vtable_prefix {
// Offset to most derived object.
ptrdiff_t whole_object;
// Additional padding if necessary.
#ifdef _GLIBCXX_VTABLE_PADDING
ptrdiff_t padding1;
#endif
// Pointer to most derived type_info.
const __class_type_info *whole_type;
// Additional padding if necessary.
#ifdef _GLIBCXX_VTABLE_PADDING
ptrdiff_t padding2;
#endif
// What a class's vptr points to.
const void *origin;
};
我们可以参考下一章将会讲到的多重继承多态对象的内存布局,去理解虚表描述结构。
4. 虚函数调用链路
C++ 多态是一个比较复杂的特性,从易到难,我们先了解一下 无继承关系
的多态类对象的虚函数调用工作流程。
- 链路。
this -> vptr -> vbtl -> virtual function
- 测试源码。
// g++ -g -O0 -std=c++11 -fdump-class-hierarchy test_virtual.cpp -o t
#include <iostream>
class A {
public:
int m_a = 0;
virtual void vfuncA1() {}
virtual void vfuncA2() {}
};
int main(int argc, char** argv) {
A* a = new A;
a->vfuncA2();
return 0;
}
- 汇编源码。通过汇编代码观察虚函数的调用流程:
int main(int argc, char** argv) {
;...
A* a = new A;
;...
40071d: e8 8e 00 00 00 callq 4007b0 <_ZN1AC1Ev>
; 将 a 的对象(this)指针压栈到 -0x18(%rbp)。
400722: 48 89 5d e8 mov %rbx,-0x18(%rbp)
a->vfuncA2();
; 找到虚指针。
400726: 48 8b 45 e8 mov -0x18(%rbp),%rax
; 通过虚指针,找到虚表保存虚函数的起始位置。
40072a: 48 8b 00 mov (%rax),%rax
; 通过上面起始位置进行偏移,找到虚表存放某个虚函数的地址。
40072d: 48 83 c0 08 add $0x8,%rax
; 找到对应的虚函数。
400731: 48 8b 00 mov (%rax),%rax
; 通过寄存器传递 a 指针作为参数,传给虚函数使用
400734: 48 8b 55 e8 mov -0x18(%rbp),%rdx
400738: 48 89 d7 mov %rdx,%rdi
; 调用虚函数
40073b: ff d0 callq *%rax
return 0;
;...
}
- 找到虚指针,a 对象的内存首位存放的是指向虚表的
虚指针
地址。 - 通过虚指针,找到虚表保存虚函数的起始位置。
- 通过上面虚表保存虚函数的起始位置进行偏移,找到虚表存放对应虚函数的地址,从而找到对应的虚函数。
- 将 a(this)指针写入 rdi 寄存器,作为参数传递给虚函数调用。
- call 命令调用虚函数(A::vfuncA2(this))。
5. 内存布局
请问 虚函数表
和 虚函数
分别在内存的哪个数据分区?我们可以使用 objdump
工具进行分析。
使用上面的测试 demo 进行分析:虚函数表在内存的 文字常量区,虚函数在内存的 程序代码区。
参考:程序变量内存分布(Linux),深入探索 C++ 多态 ② - 继承关系,深入探索 C++ 多态 ③ - 虚析构
- 数据分区。
区域 | 描述 | 变量类型 |
---|---|---|
stack | 栈区 | 临时变量 |
heap | 堆区 | malloc 分配空间的变量 |
.data,.bss | 全局数据区 | 全局变量/静态变量 |
.rodata | 文字常量区 | 只读数据,常量等 |
.text | 程序代码区 | 程序代码 |
- objdump 工具使用。
# 编译测试代码。
g++ -std=c++11 test.cpp -o test
# 使用 objdump 导出执行文件的信息。
objdump -CdStT test > asm.log
# 获取程序代码区信息。
cat asm.log| grep '\.text'
0000000000400610 l d .text 0000000000000000 .text
0000000000400610 g F .text 0000000000000000 _start
00000000004007b0 w F .text 0000000000000020 A::A()
# 虚函数。
000000000040079c w F .text 000000000000000a A::vfuncA1()
00000000004007a6 w F .text 000000000000000a A::vfuncA2()
00000000004007d0 g F .text 0000000000000065 __libc_csu_init
00000000004007b0 w F .text 0000000000000020 A::A()
00000000004006fd g F .text 000000000000004c main
# 获取程序文字常量区信息。
cat asm.log| grep '\.rodata'
0000000000400860 l d .rodata 0000000000000000 .rodata
# 虚表。
0000000000400880 w O .rodata 0000000000000020 vtable for A
00000000004008a0 w O .rodata 0000000000000003 typeinfo name for A
00000000004008b0 w O .rodata 0000000000000010 typeinfo for A
6. 参考
- 《深度探索 C++ 对象模型》
- 《C++ 虚拟与多态》
- 多态及其基本原理
- C++ 多态的实现原理分析
- 再议内存布局
- C++:从技术实现角度聊聊RTTI
- c++对象内存布局
- C++ 对象的内存布局(上)
- C++ 对象的内存布局(下)
- 如何在vscode中编写汇编语言并在终端进行调试(保姆级别)
- C++ Virtual Table Tables(VTT)
- godbolt.org
- What is the VTT for a class?