文先生的博客 求职,坐标深圳。(wenfh2020@126.com)

[C++] 深入探索 C++ 多态 ① - 虚函数调用链路

2022-12-27

最近翻阅侯捷先生的两本书:(翻译)《深度探索 C++ 对象模型》 和 《C++ 虚拟与多态》,获益良多。

要理解多态的工作原理,得理解这几个知识点的关系:虚函数虚函数表虚函数指针、以及对象的 内存布局



1. 概述

1.1. 概念

本章主要探索 C++ 动态多态,我们先了解一下它的一些相关概念:

  • 多态 是 C++ 中的一个重要概念,它允许在派生类中 重写 基类中的函数,并以不同的方式处理相同的数据类型;多态的实现依赖于 虚函数动态绑定

  • 虚函数 是一种特殊的成员函数,它允许在派生类中重写基类中的函数。当一个函数被声明为虚函数时,编译器会在该类的虚函数表中添加一个条目,该条目指向该虚函数的地址。如果一个类继承了另一个类的虚函数,那么它将继承该类的虚函数表,并在其中添加自己的虚函数。

  • 虚函数表 是一个包含虚函数地址的表格,每个类都有一个虚函数表。虚函数表中的每个条目都是一个指向虚函数的指针。当一个类包含虚函数时,编译器会在该类的虚函数表中添加一个条目,该条目指向该虚函数的地址。如果一个类继承了另一个类的虚函数,那么它将继承该类的虚函数表,并在其中添加自己的虚函数。

  • 虚函数指针 是一个指向虚函数表的指针,它存储在每个对象的内存中。当一个对象被创建时,它的虚函数指针被初始化为指向该类的虚函数表。当一个虚函数被调用时,编译器会使用虚函数指针来查找该函数在虚函数表中的地址,并调用该函数。

  • 动态绑定 是一种在运行时确定函数调用的机制。当一个函数被声明为虚函数时,编译器会使用动态绑定来确定该函数的实际地址。当一个虚函数被调用时,编译器会使用虚函数指针来查找该函数在虚函数表中的地址,并调用该函数。

部分文字来源:ChatGPT


1.2. 实例

概念比较抽象,写个 demo,配合图片凑合着理解~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* g++ -std='c++11' test.cpp -o t && ./t */
#include <iostream>

class Model {
   public:
    virtual void face() {
        std::cout << "model's face!\n";
    }
};

class Gril : public Model {
   public:
    void face() override {
        std::cout << "girl's face!\n";
    }
};

class Man : public Model {
   public:
    void face() override {
        std::cout << "man's face!\n";
    }
};

class Boy : public Model {
   public:
    void face() override {
        std::cout << "boy's face!\n";
    }
};

void takePhoto(Model& m) {
    m.face();
}

int main() {
    Model model;
    Gril girl;
    Man man;
    Boy boy;

    takePhoto(model);
    takePhoto(girl);
    takePhoto(man);
    takePhoto(boy);
    return 0;
}

// 输出:
// model's face!
// girl's face!
// man's face!
// boy's face!

2. 工作环境

2.1. 系统

1
2
3
4
5
# 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_infovtable_prefix,它们有利于我更好地理解多态的工作原理。

调试方式请参考:《(ubuntu) vscode + gdb 调试 c++》


3.1. 类型信息

type_info 是一个类的类型信息的数据结构,用于在运行时获取对象的类型信息;多态工作机制根据不同应用场景,从 type_info 派生各个类型信息结构类。

  • 基础类型信息结构。
1
2
3
4
5
6
7
8
9
10
11
12
13
/* /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:
    ...
};
  • 单一继承类型信息结构。
1
2
3
4
5
6
7
/* /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;
    ...
};
  • 多重继承或虚拟继承类型信息结构。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* /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

  1. whole_object:我认为改为:top_offset 会更贴切一点。对象内存中的当前虚指针位置,离顶端的偏移位置,因为对象有可能有多个虚表,通过偏移量可以找到对象内存布局上对应的虚指针。
  2. whole_type: 类的类型信息。
  3. origin:虚指针指向虚表的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* /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++ 多态是一个比较复杂的特性,从易到难,我们先了解一下 无继承关系 的多态类对象的虚函数调用工作流程。

  • 链路。
1
this -> vptr -> vbtl -> virtual function
  • 测试源码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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;
}
  • 汇编源码。通过汇编代码观察虚函数的调用流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
  ;...
}
  1. 找到虚指针,a 对象的内存首位存放的是指向虚表的 虚指针 地址。
  2. 通过虚指针,找到虚表保存虚函数的起始位置。
  3. 通过上面虚表保存虚函数的起始位置进行偏移,找到虚表存放对应虚函数的地址,从而找到对应的虚函数。
  4. 将 a(this)指针写入 rdi 寄存器,作为参数传递给虚函数调用。
  5. call 命令调用虚函数(A::vfuncA2(this))。

5. 内存布局

请问 虚函数表虚函数 分别在内存的哪个数据分区?我们可以使用 objdump 工具进行分析。

使用上面的测试 demo 进行分析:虚函数表在内存的 文字常量区,虚函数在内存的 程序代码区

参考:程序变量内存分布(Linux)深入探索 C++ 多态 ② - 继承关系深入探索 C++ 多态 ③ - 虚析构

  • 数据分区。
区域 描述 变量类型
stack 栈区 临时变量
heap 堆区 malloc 分配空间的变量
.data,.bss 全局数据区 全局变量/静态变量
.rodata 文字常量区 只读数据,常量等
.text 程序代码区 程序代码
  • objdump 工具使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 编译测试代码。
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. 引用


作者公众号
微信公众号,干货持续更新~