返回

结合汇编代码学习 C++ 多态特性以及虚函数表原理

对 C++ 中通过继承实现多态的原理进行整理。

简介

这篇博客主要是将我对 C++ 的继承中不太清晰的点进行整理,主要包括继承中的多态特性是怎么体现的,以及多态特性背后的虚函数的原理。以下测试环境为:

  • gcc 9.3.0
  • Ubuntu 20.04
  • GNU gdb (Ubuntu 9.2-0ubuntu1~20.04) 9.2
  • Compile Explorer: https://godbolt.org/

继承和多态

例子

先来看一个简单的通过继承来实现的多态的例子:

#include <iostream>

class Base
{
public:
    virtual void info() const
    {
        std::cout << "Base::Info()" << std::endl;
    }

};

class Derived: public Base
{
public:
    virtual void info() const override
    {
        std::cout << "Derived::Info()" << std::endl;
    }

};

int main(int argc, char** argv)
{
    Base* actual_base = new Base();
    Base* actual_derived = new Derived();

    actual_base->info();
    actual_derived->info();

    delete actual_base;
    delete actual_derived;
}

这段代码的运行结果为:

$ /home/xt/code_collections/cpp/build/inheritance/basic_example
Base::Info()
Derived::Info()

可以看到,这里我们虽然表面上 actual_baseactual_derived 都是 Base* 的类型,但是实际上程序会自动“识别”他们指向的真正类型,从而调用该类型的 info() 方法。但是这个“识别”过程具体是怎么实现的呢?主要就是通过在派生类和基类的对象中维护一个虚函数表,虚函数表中保存了该类的虚函数中的映射关系,通过虚函数表确定调用的函数。

含有虚函数的派生类初始化过程

接下来我们通过汇编代码来看虚函数表的结构,以及在编译过程中编译器是怎么对派生类进行初始化的。为了避免引入外部函数导致汇编代码难以阅读,采用以下例子:

class Base
{
public:
    bool flag = true;
    virtual ~Base() {}
    virtual void flip()
    {
        flag = !flag;
    }
};

class Derived: public Base
{
public:
    bool extra_flag = true;
    int count = 0;
    virtual ~Derived() {}

    virtual void flip() override
    {
        flag = !flag;
        extra_flag = !extra_flag;
    }

    void iterate()
    {
        count++;
    }
};

int main()
{
    Base* actual_derived = new Derived();
    delete actual_derived;
}

使用 Compile Explorer 查看汇编代码如下:

这个例子中,我们主要关注变量 actual_derived 的构造过程,即图中右侧红框的部分。

先看 main 的部分:

        sub     rsp, 24
        mov     edi, 16                     // 传入立即数 16 作为 new 的入参,表明需要 new 的空间为 16 个字节
        call    operator new(unsigned long) // 分配空间返回地址到 rax
        mov     rbx, rax                    // 复制新变量的地址到 rbx
        mov     QWORD PTR [rbx], 0          // 初始化 rbx 后四个字(8个字节)为 0 -- 对应虚表
        mov     BYTE PTR [rbx+8], 0         // 初始化 rbx 后第 9 个字节 为 0     -- 对应 flag 变量
        mov     BYTE PTR [rbx+9], 0         // 初始化 rbx 后第 10 个字节 为 0    -- 对应 extra_flag 变量
        mov     DWORD PTR [rbx+12], 0       // 初始化 rbx 后第 12 个字节的双字(4个字节) -- 对应 count 变量
        mov     rdi, rbx                    // 将 rbx 复制到 rdi 用于构造函数入参
        call    Derived::Derived() [complete object constructor] // 调用 Derived::Derived() 构造函数
        mov     QWORD PTR [rbp-24], rbx     // 将 rbx 中的地址复制到 rbp-24 中,即将 Derived::Derived() 的变量地址分配给 actual_derived 指针变量

可以看到,在 main 部分中主要进行派生类实例空间的分配,从空间分配中我们可以看到编译器给 *actual_derived 分配了 16 个字节,分为四个部分,并分别进行内存初始化(置 0)。接下来我们看 Derived::Derived() 的部分如何处理这些内存。

此时,相关的内存分布为:

[rbx]        ---> 0x00       (8 bytes)
[rbx+8]      ---> 0x00       (1 bytes)
[rbx+9]      ---> 0x00       (1 bytes)
[rbx+10]     ---> rand       (2 bytes)
[rbx+12]     ---> 0x00       (4 bytes)

Derived::Derived() 部分的相关代码如下所示:

        sub     rsp, 16                        // 预留 16 个字节空间
        mov     QWORD PTR [rbp-8], rdi         // 获取派生类实例地址
        mov     rax, QWORD PTR [rbp-8]         // 由于需要调用基类的构造函数,因此重新复制进 rax 然后到 rdi
        mov     rdi, rax
        call    Base::Base() [base object constructor] // 调用基类构造函数
        mov     edx, OFFSET FLAT:vtable for Derived+16 // 让 edx 存放 Derived 虚表起始位置之后第 16 个字节的地址
        mov     rax, QWORD PTR [rbp-8]        // 将变量地址 this 复制到 rax 中,rax 会作为函数返回值返回 
        mov     QWORD PTR [rax], rdx          // 将 rdx 的内容(8 个字节),包含 edx (Derived 的虚表内容) 存放至 rax,即 this 中
        mov     rax, QWORD PTR [rbp-8]        // 类似操作,保存 this 到 rax 中       
        mov     BYTE PTR [rax+9], 1           // 初始化 rax + 9 后的一个字节为 1,对应 bool extra_flag = true;
        mov     rax, QWORD PTR [rbp-8]
        mov     DWORD PTR [rax+12], 0         // 初始化 rax + 12 后的 (4 个字节)赋值为 0,对应 int count = 0;

可以看到,Derived 的构造函数中,主要做了这么几件事情:1. 调用了基类构造函数;2. 复制 Derived 的虚函数表到变量起始位置,占用 8 个字节的空间;在虚表部分之后的第二个字节中初始化 extra_flag,占用 1 个字节空间,4.在 extra_flag 之后的第三个字节位置之后初始化 count,占用 4 个字节空间。对 Derived 变量而言,内存分布为:

this(rax)   ---> vtable for Derived+16 (8 bytes)
this+8      ---> 0                     (1 bytes)
this+9      ---> 1 (bool extra_flag)   (1 bytes)
this+10     ---> rand                  (2 bytes)
this+12     ---> 0 (int count)         (4 bytes)

内存分布中,rax + 8 的内存在构造函数没有涉及,不难想象这是基类中 bool extra_flag 占用的内存空间,而 this+10 之后的两个字节位置只是用于内存对齐,不存放数据。下面先看简单看一下 Base::Base() 的内容,最后再看虚表的具体内容:

Base::Base() 部分的相关代码如下所示:

        mov     QWORD PTR [rbp-8], rdi                 // 获取派生类实例地址
        mov     edx, OFFSET FLAT:vtable for Base+16    // 让 edx 存放 Base 虚表起始位置之后第 16 个字节的地址
        mov     rax, QWORD PTR [rbp-8]                 // 将变量地址 this 复制到 rax 中,rax 会作为函数返回值返回
        mov     QWORD PTR [rax], rdx                   // 将 rdx 的内容(8 个字节),包含 edx (Base 的虚表地址) 存放至 rax,即 this 中
        mov     rax, QWORD PTR [rbp-8]                 // 类似操作,保存 this 到 rax 中
        mov     BYTE PTR [rax+8], 1                    // 初始化 rax + 8 后的一个字节为 1,对应 bool flag = true;

可以看出,这里的操作和 Derived::Derived() 大同小异,基本上都是复制虚表,初始化成员变量。注意在基类中基类的虚表会复制一次,然后在派生类中重新复制以此来覆盖虚表的内容。接下来我们来看一下 Derived 中虚表的内容,如下图红框部分所示:

Derived::Derived() 虚表的相关内容为:

vtable for Derived:
        .quad   0
        .quad   typeinfo for Derived
        .quad   Derived::~Derived() [complete object destructor]
        .quad   Derived::~Derived() [deleting destructor]
        .quad   Derived::flip()
typeinfo for Derived:
        .quad   vtable for __cxxabiv1::__si_class_type_info+16
        .quad   typeinfo name for Derived
        .quad   typeinfo for Base
typeinfo name for Derived:
        .string "7Derived"
typeinfo for Base:
        .quad   vtable for __cxxabiv1::__class_type_info+16
        .quad   typeinfo name for Base
typeinfo name for Base:
        .string "4Base"

可以看到,Derived::Derived() 包含 5 个部分,每个部分占8个字节,共 40 个字节,其作用分别为:

  • 第一个 QWORD:置 0
  • 第二个 QWORD:派生类的基本信息,指向另一块地址
  • 第三个 QWORD:派生类的静态析构函数
  • 第四个 QWORD:派生类的动态析构函数
  • 第五个 QWORD:Derived::flip() 虚函数指针

在运行时由于程序能够通过虚函数表直接确定要调用的函数,因此前两个 DWORD 是不需要的,因此在变量中保存的不是虚函数表的起始地址,而是偏移了 16 个字节之后的地址。最后变量的内存布局为 [this] 表示 this 中指向地址:

this(rax)   ---> vtable for Derived+16 (8 bytes)
        [this-16]  ---> 0
        [this-8]   ---> typeinfo for Derived
        [this]     ---> Derived::~Derived() [complete object destructor]
        [this+8]   ---> Derived::~Derived() [deleting destructor]
        [this+16]  ---> Derived::flip()
this+8      ---> 1 (bool flag)         (1 bytes)
this+9      ---> 1 (bool extra_flag)   (1 bytes)
this+10     ---> rand                  (2 bytes)
this+12     ---> 0 (int count)         (4 bytes)

下面我们使用 vscode 结合 GDB 来观察程序运行时是否对应我们在汇编中观察的现象,注:GDB 需要设置 set print object on 才能正确识别派生类,首先设置好断点,并找到 acutal_derived 以及指向的对象地址以及对象大小。这里可以看到:

  • acutal_derived 地址为:0x55555556aeb0
  • *acutal_derived 地址为:0x555555557d48
  • *acutal_derived 大小为 16 bytes

这里我们可以看到 *acutal_derived 按顺序包含有:<vtable for Derived+16>, flag = true, extra_flag = true, count = 0 符合我们的预期。接下来将该起始对应的内存打印出来,由于一个变量占 16 个字节,因此我们只打印 16 个字节:

-exec x/16xb actual_derived
0x55555556aeb0:	0x48	0x7d	0x55	0x55	0x55	0x55	0x00	0x00
0x55555556aeb8:	0x01	0x01	0x00	0x00	0x00	0x00	0x00	0x00

可以看到第一行为该变量前 8 个字节,为虚表地址。第二行前两个字节分别为:flagextra_flag。最后四个字节为 count 初始化为 0。这里我们得到虚表地址为:0x555555557d48,我们可以进一步将虚表打印出来,由于这个地址是虚表偏移 16 个字节的,因此我们可以减去 16 为 0x555555557d38。我们从汇编中知道虚表占 40 个字节,因此打印出 0x555555557d38 起始的 40 个字节,如下所示:

-exec x/40xb 0x555555557d38
0x555555557d38 <_ZTV7Derived>:	    0x00	0x00	0x00	0x00	0x00	0x00	0x00	0x00  # 置 0
0x555555557d40 <_ZTV7Derived+8>:	0x88	0x7d	0x55	0x55	0x55	0x55	0x00	0x00  # typeinfo for Derived
0x555555557d48 <_ZTV7Derived+16>:	0x3c	0x52	0x55	0x55	0x55	0x55	0x00	0x00  # Derived::~Derived() [complete object destructor]
0x555555557d50 <_ZTV7Derived+24>:	0x6a	0x52	0x55	0x55	0x55	0x55	0x00	0x00  # Derived::~Derived() [deleting destructor]
0x555555557d58 <_ZTV7Derived+32>:	0x9a	0x52	0x55	0x55	0x55	0x55	0x00	0x00  # Derived::flip()

从值中,可以发现第一个 QWORD 为 0,其余 4 个都是地址。根据我们对汇编的观察,_ZTV7Derived+8 应该指向 typeinfo for Derived,我们将其打印出来:

-exec x/24xb 0x555555557d88
0x555555557d88 <_ZTI7Derived>:	    0x98	0xbc	0xf7	0xf7	0xff	0x7f	0x00	0x00
0x555555557d90 <_ZTI7Derived+8>:	0x08	0x60	0x55	0x55	0x55	0x55	0x00	0x00
0x555555557d98 <_ZTI7Derived+16>:	0xa0	0x7d	0x55	0x55	0x55	0x55	0x00	0x00

typeinfo for Derived 的第二部分指向的应该是 typeinfo name for Derived,即:.string "7Derived" 我们来看一下:

-exec x/9xb 0x555555556008
0x555555556008 <_ZTS7Derived>:	    0x37	0x44	0x65	0x72	0x69	0x76	0x65	0x64
0x555555556010 <_ZTS7Derived+8>:	0x00

37 44 65 72 69 76 65 64 转换成字符,正是:7Derived 验证了我们的观察。

我们可以将剩余三个函数的地址打印出来,如下所示:

-exec x/8xb 0x55555555523c
0x55555555523c <Derived::~Derived()>:	0xf3	0x0f	0x1e	0xfa	0x55	0x48	0x89	0xe5

-exec x/8xb 0x55555555526a
0x55555555526a <Derived::~Derived()>:	0xf3	0x0f	0x1e	0xfa	0x55	0x48	0x89	0xe5

-exec x/8xb 0x55555555529a
0x55555555529a <Derived::flip()>:	0xf3	0x0f	0x1e	0xfa	0x55	0x48	0x89	0xe5

通过 GDB 的提示,我们知道分别对应的 Derived 的两个析构函数,和 flip 函数。

调用虚函数过程

接下来,我们加入调用虚函数的过程,同时也加入一个非虚函数的调用做对比。观察其在汇编下的行为有什么不同。示例代码如下:

class Base
{
public:
    bool flag = true;
    virtual ~Base() {}
    virtual void flip()
    {
        flag = !flag;
    }

    void reset()
    {
        flag = true;
    }
};

class Derived: public Base
{
public:
    bool extra_flag = true;
    int count = 0;
    virtual ~Derived() {}

    virtual void flip() override
    {
        flag = !flag;
        extra_flag = !extra_flag;
    }

    void iterate()
    {
        count++;
    }
};

int main()
{
    Base* actual_derived = new Derived();
    actual_derived->flip();
    actual_derived->reset();
    delete actual_derived;
}

我们在基类中加入了一个非虚函数 reset(),在 main 函数中对这两个函数进行调用,汇编结果如下所示,两个函数对应的汇编部分在红框内:

先看 actual_derived->flip(); 对应汇编代码如下所示:

        mov     rax, QWORD PTR [rbp-24]  // 获取 actual_derived 地址
        mov     rax, QWORD PTR [rax]     // 获取 *actual_derived 地址
        add     rax, 16                  // 偏移 16 个字节,此时 rax 指向虚表中第三个 QWORD (对应 XXX::flip() 的地址)
        mov     rdx, QWORD PTR [rax]     // rdx 指向 [rax],即 Derived::flip() 的起始位置
        mov     rax, QWORD PTR [rbp-24]  // 将 actual_derived 地址复制给 rax
        mov     rdi, rax                 // 将 actual_derived 复制给 rdi 作为入参
        call    rdx                      // 通过函数指针调用 rdx 对应的函数

可以发现,在汇编代码中,实际上编译器是不知道具体调用的是哪个函数,它做的只是找到变量的虚表中,找到 flip 对应的函数指针的位置(因此遍历虚表的过程发生在编译而不是运行期),绑定并且调用该函数而已。下面我们来看非虚函数调用:

        mov     rax, QWORD PTR [rbp-24]
        mov     rdi, rax
        call    Base::reset()

非虚函数的调用的就很简单了,直接调用 Base::reset() 即可。因此无需遍历虚表。相对开销也小(省去了虚表指针偏移和绑定的过程)。调用虚函数在运行时才确定调用函数这个过程体现了 C++ 的多态。

早绑定和晚绑定

上述的例子中,编译时不指定具体的函数调用而是通过虚表中函数指针的调用叫晚绑定,即运行时才绑定要调用的函数。但是在这个例子中,程序逻辑很简单,我们很明显可以知道 actual_derived->flip(); 调用的是 Derived::flip()。有没有办法省去从虚函数中获取函数指针的过程,直接调用 Derived::flip() 呢?事实上,现代编译器大部分都会对这种情况进行优化,在可以确定基类指针指向对象的类型的话直接调用该类的虚函数。这次我们使用 -O1 优化选项进行编译,查看汇编代码,如下所示:

可以发现,优化之后两个函数的调用直接变成了两行:

        mov     BYTE PTR [rax+9], 0
        mov     BYTE PTR [rax+8], 1

这里编译器的优化过程大致为:通过语法分析判断 actual_derived 指向的是一个 Derived 类型,然后绑定该函数调用。确定调用的函数之后,再对 flip()reset() 进行优化,省去了函数调用的过程,直接对对象进行操作。因此最后的结果为:actual_derived->extra_flag = 0, actual_derived->flag = 1。这种在编译期就对虚函数进行绑定的现象就是早绑定。这种时候从汇编代码来看实际上是体现不出来多态特性的。

那如果我们将派生类的类型依赖于用户输入呢?实例代码如下所示:

class Base
{
public:
    bool flag = true;
    virtual ~Base() {}
    virtual void flip()
    {
        flag = !flag;
    }

    void reset()
    {
        flag = true;
    }
};

class Derived: public Base
{
public:
    bool extra_flag = true;
    int count = 0;
    virtual ~Derived() {}

    virtual void flip() override
    {
        flag = !flag;
        extra_flag = !extra_flag;
    }

    void iterate()
    {
        count++;
    }
};

int main(int argc, char** argv)
{
    Base* maybe_derived = nullptr;
    if (argc > 1)
    {
        maybe_derived = new Derived();
    }
    else
    {
        maybe_derived = new Base();
    }
    maybe_derived->flip();
    maybe_derived->reset();
    delete maybe_derived;
}

我们将 maybe_derived 的类型依赖于输入参数的个数,然后查看汇编代码,可以发现,虽然开了 -O1 同样进行了部分优化。但是虚函数指针偏移这一步没办法进行优化了,因为编译器不知道在运行时 maybe_derived 到底指向哪个类。

注意事项

在之前的例子中,我们一直用基类指针指向派生类来体现 C++ 的多态特性,那么如果我们不用指针,而用一个派生类来初始化基类局部变量呢?在实际编译之前可以不妨先思考一下其中的逻辑,显然由于派生类的大小和基类不一定相同(可能有额外的成员变量),因此编译器在分配内存时没办法确定派生类的大小,因此只能分配基类的大小。那么用派生类来初始化基类对象时有两种可能:

  • 直接复制内存,这样显然内存会越界因此不太可能
  • 通过静态类型转换将派生转换为基类然后用拷贝构造来初始化基类对象,这样相当于损失了派生类的性质,也没有了多态特性。

接下来直接看代码:

class Base
{
public:
    bool count = true;
    virtual ~Base() {}
    virtual void flip()
    {
        flag = !flag;
    }

    void reset()
    {
        flag = true;
    }
};

class Derived: public Base
{
public:
    bool extra_flag = true;
    int count = 0;
    virtual ~Derived() {}

    virtual void flip() override
    {
        flag = !flag;
        extra_flag = !extra_flag;
    }

    void iterate()
    {
        count++;
    }
};

int main(int argc, char** argv)
{
    Base fake_derived = Derived();
    fake_derived.flip();
}

汇编代码如下所示:可以发现,虽然构造了一个 Derived,但是并没有使用其本身信息,而是从中获取变量位置和 count 值,然后调用了 Base 类的拷贝构造函数。调用 flip() 时也是直接调用了 Base::flip() 。因此验证了我们的猜想,在局部变量的情况下不会体现多态特性。

参考

Built with Hugo
Theme Stack designed by Jimmy