c++对象模型之虚表,虚表指针,thunk,多态,多重继承this指针偏移,多重继承virtual析构函数,多重虚继承下的访问虚基类成员变量时虚表的工作原理


前言

本篇文章为笔者的读书笔记,未经允许请勿转载。如果对你有帮助记得点个赞(●’◡’●)
上两篇文章将c++的核心部件(Value categories)讲清楚了,这篇文章将会带大家分析c++对象模型的底层原理。笔者这里用的编译器是clang version 10.0.0-4ubuntu1,不同编译器对数据布局的处理可能会不同(《深度探索c++对象模型》中已阐述原因,感兴趣的读者可以自行阅读)。
友情提示:本文章涉及ATT式intel式汇编代码的相关知识。


虚表和虚表指针(vtbl & vptr)

  • 每个class产生出一堆指向virtual function的指针,放在表格之中,这个表格被称为virtual table(vtbl)。
  • 每一个class object被安插一个指针,指向相关的virtual table。通常这个指针被称为vptr。vptr的设定(setting)和重置(resetting)都由每一个class的constructor、destructor和copy assignment运算符自动完成。每一个class所关联的type_info object(用以支持runtime type identification,RTTI)也经由virtual table被指出来,通常放在表格的第一个slot(clang++不是放在第一个slot,文章后面会讲到)。

接下来笔者写两个测试用例来帮大家分析clang++编译器下的vtbl & vptr。

首先咱们来看下vptr放在对象的什么位置:

class A 
{
public:
    int i;
    virtual void testfunc(){}
};

int main()
{
    //虚函数表指针位置分析
    //类:有虚函数,这个类会产生一个虚函数表。
    //类对象,有一个指针,指针(vptr)会指向这个虚函数表的开始地址。
    A obj;
    int objLen = sizeof(obj);
    std::cout<< objLen << std::endl; //x86-64 16字节

    /*p1获取对象首地址,p2获取对象数据成员i的地址,两者都以低层次char *去解释,以便用旁观者角度去比较*/
    char *p1 = reinterpret_cast<char *>(&obj);//reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。
    char *p2 = reinterpret_cast<char *>(&(obj.i));
    if(p1 == p2)//说明obj.i和obj的位置相同,说明i在对象obj内存布局的上边。虚函数表指针vptr在下边
    {
        std::cout<< "虚函数表指针位于对象内存的 末尾 " <<std::endl;
    }
    else
    {
        std::cout<< "虚函数表指针位于对象内存的 开头 " <<std::endl;
    }
    return 0;
}

运行结果如下:
请添加图片描述
可以看到 obj对象的大小是16字节,vptr位于对象的开头。


接下里咱们不走virtual机制,直接从虚表中获取虚函数并且运行对应虚函数。

class Base
{
public:
    virtual void f() { std::cout << "Base::f()" << std::endl; }
    virtual void g() { std::cout << "Base::g()" << std::endl; }
    virtual void h() { std::cout << "Base::h()" << std::endl; }
};
class Derive : public Base
{
    virtual void g() { std::cout << "Derive::g()" << std::endl; }
};
int main()
{
    Derive* pd = new Derive();//派生类指针。
    long* pd_cast = reinterpret_cast<long*>(pd);//获取较低层次上的重新解释。
    long* vptr_pd = reinterpret_cast<long*>(*pd_cast);
    /*解引用派生类指针得到虚表指针,而不是得到派生类,因为做了位模式的低层次转换。
    得到虚表指针后,将虚表指针的类型从long重新解释为long*。
    使用long过渡,是因为long在32位编译器和64位编译器所占用的空间大小和指针是一样的*/

    for(int i = 0; i < 3; i++)
    {
        printf("vptr_pd[%d] 的内容为: %p\n",i,vptr_pd[i]);
    }

    typedef void(*Func)(void);//定义一个函数指针类型
    Func f_pd = (Func)vptr_pd[0];
    Func g_pd = (Func)vptr_pd[1];
    Func h_pd = (Func)vptr_pd[2];

    f_pd();
    g_pd();
    h_pd();

    Base* pb = new Base();//派生类指针。
    long* pb_cast = reinterpret_cast<long*>(pb);
    long* vptr_pb = reinterpret_cast<long*>(*pb_cast);

    for(int i = 0; i < 3; i++)
    {
        printf("vptr_pb[%d] 的内容为: %p\n",i,vptr_pb[i]);
    }

    typedef void(*Func)(void);//定义一个函数指针类型
    Func f_pb = (Func)vptr_pb[0];
    Func g_pb = (Func)vptr_pb[1];
    Func h_pb = (Func)vptr_pb[2];

    f_pb();
    g_pb();
    h_pb();

    delete pd;
    delete pb;
}

运行结果为:
请添加图片描述
或者用GDB命令打印出vtbl的样子:
gef➤ info vtbl pd
请添加图片描述
和上面代码运行的结果一致!值得关注的一点是,vtbl是存在于只读数据段,而vptr在堆中(vptr也可以在栈中)。
下面用一张图来描述上述代码的行为:
请添加图片描述


vptr & vtbl的创建时机以及vptr初始化问题

引用《深度探索C++对象模型》p45中的一段话:
下面两个扩张行动会在编译期间发生:

  • 一个virtual function table会被编译器产生出来,内放class的virtual functions地址
  • 在每一个class object中,一个额外的pointer member会被编译器合成出来,内含相关的class vtbl的地址

在c++中,virtual functions可以在编译时期获知。此外,这一组地址是固定不变的,执行期不可能新增或替换它。由于程序执行时,表格的大小和内容都不会改变,所以其构建和存取皆可以由编译器完全掌握,不需要执行期的任何介入。
值得注意的是vptr的初始化问题:
这里笔者就不带大家画堆栈图,过程如下!
相关ATT式反汇编代码如下:

    Derive* pd = new Derive();//派生类指针。
  401222:	bf 08 00 00 00       	mov    $0x8,%edi
  401227:	e8 64 fe ff ff       	callq  401090 <operator new(unsigned long)@plt>
  40122c:	31 f6                	xor    %esi,%esi
  40122e:	48 89 c1             	mov    %rax,%rcx
  401231:	48 89 cf             	mov    %rcx,%rdi
  401234:	ba 08 00 00 00       	mov    $0x8,%edx
  401239:	48 89 45 80          	mov    %rax,-0x80(%rbp)
  40123d:	e8 0e fe ff ff       	callq  401050 <memset@plt>
  401242:	48 8b 7d 80          	mov    -0x80(%rbp),%rdi
  401246:	e8 95 01 00 00       	callq  4013e0 <Derive::Derive()>
  40124b:	48 8b 45 80          	mov    -0x80(%rbp),%rax
  40124f:	48 89 45 f0          	mov    %rax,-0x10(%rbp)
  
  00000000004013e0 <Derive::Derive()>:
class Derive : public Base
  4013e0:	55                   	push   %rbp
  4013e1:	48 89 e5             	mov    %rsp,%rbp
  4013e4:	48 83 ec 10          	sub    $0x10,%rsp
  4013e8:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
  4013ec:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  4013f0:	48 89 c1             	mov    %rax,%rcx
  4013f3:	48 89 cf             	mov    %rcx,%rdi
  4013f6:	48 89 45 f0          	mov    %rax,-0x10(%rbp)
  4013fa:	e8 21 00 00 00       	callq  401420 <Base::Base()>
  4013ff:	48 b8 70 20 40 00 00 	movabs $0x402070,%rax
  401406:	00 00 00 
  401409:	48 05 10 00 00 00    	add    $0x10,%rax
  40140f:	48 8b 4d f0          	mov    -0x10(%rbp),%rcx
  401413:	48 89 01             	mov    %rax,(%rcx)
  401416:	48 83 c4 10          	add    $0x10,%rsp
  40141a:	5d                   	pop    %rbp
  40141b:	c3                   	retq   
  40141c:	0f 1f 40 00          	nopl   0x0(%rax)

0000000000401420 <Base::Base()>:
class Base
  401420:	55                   	push   %rbp
  401421:	48 89 e5             	mov    %rsp,%rbp
  401424:	48 b8 d0 20 40 00 00 	movabs $0x4020d0,%rax
  40142b:	00 00 00 
  40142e:	48 05 10 00 00 00    	add    $0x10,%rax
  401434:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
  401438:	48 8b 4d f8          	mov    -0x8(%rbp),%rcx
  40143c:	48 89 01             	mov    %rax,(%rcx)
  40143f:	5d                   	pop    %rbp
  401440:	c3                   	retq   
  401441:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  401448:	00 00 00 
  40144b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

请添加图片描述
该图对应mov %rax,-0x80(%rbp)执行完。已经发生的事情有:new表达式通过operator new(unsigned long)这个动态函数申请了首地址为0x0000000000416eb0的堆内存,并将其赋给了栈空间为rbp-0x80(0x00007fffffffe060)的地址中,下一条反汇编代码将会为首地址为0x0000000000416eb0的堆内存进行内存初始化。
(延迟绑定:动态函数比静态函数绑定要晚静态函数在a.out生成的时候地址已经被绑定
动态函数需要动态库加载到内存中,然后用动态库里面的函数地址替换掉类似于反汇编出来的@plt,之后得到动态函数的地址进行调用。)


请添加图片描述
该图对应0x40143c <Base::Base()+28> mov QWORD PTR [rcx], rax执行完。已经发生的事情有:调用了Derive合成默认构造函数,并且在其中又调用了Base合成默认构造函数,首地址为0x0000000000416eb0的堆内存指针赋给了第一参数寄存器rdi0x4020d0立即数(Base虚表地址)赋给了临时寄存器raxrdi寄存器又将堆内存指针赋给了Base合成默认构造函数的rbp-0x8地址上,最后由rax寄存器将Base虚表地址赋给了堆内存指针所指向的地址中。至此new出来的Derive类对象的vptr已经获取到Base类的虚表地址(注意现在还没有获取到Derive类的虚表地址)。


请添加图片描述
该图对应0x401413 <Derive::Derive()+51> mov QWORD PTR [rcx], rax执行完。已经发生的事情有:Base的合成默认构造函数return,接着0x402070立即数赋给了rax寄存器,rax将其值加上0x10后就得到了Derive类的虚表地址。然后rax将该地址赋给了堆内存指针所指向的地址中。至此伴随着Derive合成默认构造函数的结束,new出来的Derive类对象的vptr获得了自己的虚表地址。


vptr初始化小结

可以发现,vptr的初始化并不是一开始就获得了自己的虚表地址,而是伴随父类合成默认构造函数的结束,先获得父类虚表地址,然后伴随自身合成默认构造函数的结束,再获得自身虚表地址。


编译器会合成出nontrivial default constructor的4种情况(《深度探索c++对象模型》p40)

  • 带有default constructor的member class object

如果一个class没有任何constructor,但它内含一个member object,而后者有default constructor,那么这个class的implicit default constructor就是nontrivial,编译器需要为该class合成出一个default constructor。

  • 带有default constructor的base class

如果一个没有任何constructor的class派生自一个带有default constructor的base class,那么这个derived class的default constructor会被视为nontrivial,并因此需要被合成出来。它将调用上一层base classes的default constructor(根据它们的声明顺序)。

  • 带有一个virtual function的class

对于那些未声明任何constructor的classes,编译器会为它们合成一个default constructor,以便正确地初始化每一个class object的vptr。

  • 带有一个virtual base class的class

Virtual base class 的实现方法在不同编译器之间有极大的差异。然而,每一种实现方法的共同点在于必须使virtual base class在其每一个derived class object中的位置,能够于执行期准备妥当。在未声明任何constructor的derived class object中,编译器必须为它合成一个default constructor,并安插那些“允许每一个virtual base class的执行期存取操作”的代码。


多重继承下的virtual function,thunk,以及编译期绑定和运行期绑定(动态绑定)

在多重继承中支持virtual function,其复杂度围绕在第二个以及后继的base classes身上,以及“必须在执行期调整this指针”这一点,以下面的class体系为例:

class Base1
{
public:
    virtual void f()
    {
        std::cout << "base1::f()" << std::endl;
    }
    virtual void g()
    {
        std::cout << "base1::g()" << std::endl;
    }
};
//基类2
class Base2
{
public:
    virtual void h()
    {
        std::cout << "base2::h()" << std::endl;
    }
    virtual void i()
    {
        std::cout << "base2::i()" << std::endl;
    }
};
//子类
class Derived:public Base1,public Base2
{
public:
    virtual void f()//覆盖父类1的虚函数
    {
        std::cout << "derived::f()" << std::endl;
    }
    virtual void i()//覆盖父类2的虚函数
    {
        std::cout << "derived::i()" << std::endl;
    }

    //我们自己的虚函数
    virtual void myFunc1()
    {
        std::cout << "derived::myFunc1()" << std::endl;
    }
    //非虚函数
    void myFunc2()
    {
        std::cout << "derived::myFunc2()" << std::endl;
    }

};
int main()
{
    std::cout << sizeof(Base1) << std::endl;
    std::cout << sizeof(Base2) << std::endl;
    std::cout << sizeof(Derived) << std::endl;

    Derived ins;
    Base1& b1 = ins;//为了支持多态《深度探索c++对象模型p25》
    Base2& b2 = ins;
    Derived& d = ins;

    typedef void(*Func)(void);
    /*获得Base1类的第虚表指针*/
    Base1 temp1;
    long* base1_cast = reinterpret_cast<long*>(&temp1);//重新解释类对象,获得虚表指针的地址。
    long* base1_vptr = reinterpret_cast<long*>(*base1_cast);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
    //打印虚函数地址
    for(int i = 0; i < 2; i++)
    {
        printf("base1_vptr[%d] 的内容为: %p\n",i,base1_vptr[i]);
        ((Func)base1_vptr[i])();
    }

    std::cout << "----------------------------" << std::endl;

    /*获得Base2类的第虚表指针*/
    Base2 temp2;
    long* base2_cast = reinterpret_cast<long*>(&temp2);//重新解释类对象,获得第虚表指针的地址。
    long* base2_vptr = reinterpret_cast<long*>(*base2_cast);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
    //打印虚函数地址
    for(int i = 0; i < 2; i++)
    {
        printf("base2_vptr[%d] 的内容为: %p\n",i,base2_vptr[i]);
        ((Func)base2_vptr[i])();
    }

    std::cout << "----------------------------" << std::endl;

    /*获得derived类的第一个虚表指针*/
    long* ins_cast1 = reinterpret_cast<long*>(&ins);//重新解释类对象,获得第一个虚表指针的地址。
    long* vptr1 = reinterpret_cast<long*>(*ins_cast1);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
    //打印虚函数地址
    for(int i = 0; i < 4; i++)
    {
        printf("vptr1[%d] 的内容为: %p\n",i,vptr1[i]);
        ((Func)vptr1[i])();
    }

    std::cout << "----------------------------" << std::endl;

    /*获得derived类的第二个虚表指针*/
    long* ins_cast2 = ins_cast1 + 1;//偏移到第二个虚表指针的地址上
    long* vptr2 = reinterpret_cast<long*>(*ins_cast2);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
    //打印虚函数地址
    for(int i = 0; i < 2; i++)
    {
        printf("vptr2[%d] 的内容为: %p\n",i,vptr2[i]);
        ((Func)vptr2[i])();
    }

    std::cout << "----------------------------" << std::endl;

    //动态绑定
    b1.f();
    b2.i();
    d.f();
    d.i();
    d.g();
    d.myFunc1();
    //编译期绑定
    d.myFunc2();
}

在这个例子中,我们的子类会有两个虚指针,两张虚表,第一张虚表是重写父类虚函数、自身虚函数以及base1虚函数共享的虚表,第二张则是针对base2的。下面来看下运行结果:
请添加图片描述
重写的父类虚函数:f()i()、自身虚函数:myFunc1()、base1虚函数:g()都在第一张虚表。而第二张虚表中,h()的出现很正常,但是这个被重写的父类虚函数i(),为什么会再次出现呢?,不应该只在表一出现吗?
这里表二中被重写的父类虚函数i(),其真名叫nonvirtual thunk函数。
咱们用指令来更清晰的观察Derived类对象ins的两张虚表:
(注意: 必须在支持多态的情况下观察Derived类对象ins,具体原因请参考《深度探索c++对象模型》p13)
请添加图片描述
上文说过“必须在执行期调整this指针”,而thunk函数的真正目的为:

  • 以适当的offset值调整this指针
  • 跳到virtual function去

其实thunk函数就是一段assembly代码,下面我会带大家一起揭开thunk函数的真正面纱!
首先要想知道thunk函数干了什么,就要想个办法去调用它,然后跟踪进去。我们上面的示例代码中有一行代码:b2.i();会调用thunk函数。原因是Base2& b2 = ins;中,b2的this指针指向的ins对象的第二个虚表指针,在调用ins对象的被重写父类虚函数i()时,需要调整this指针。
具体分析过程如下:

    b2.i();
  401553:	48 8b 45 d8          	mov    -0x28(%rbp),%rax//rbp-0x28里面存着b2的this指针,指向ins对象的第二个虚表指针
  401557:	48 8b 08             	mov    (%rax),%rcx//将ins虚表二的第一个虚函数指针赋给了rcx寄存器
  40155a:	48 89 c7             	mov    %rax,%rdi//将b2的this指针存到rdi寄存器作为thunk函数的第一参数
  40155d:	ff 51 08             	callq  *0x8(%rcx)//偏移0x8字节后调用thunk函数,*0x8(%rcx)的值为0x4017f0

00000000004017f0 <non-virtual thunk to Derived::i()>:
  4017f0:	55                   	push   %rbp
  4017f1:	48 89 e5             	mov    %rsp,%rbp
  4017f4:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)//将b2的this指针存放到该thunk函数rbp-0x8的地址中
  4017f8:	48 8b 45 f8          	mov    -0x8(%rbp),%rax//将b2的this指针存放到rax临时寄存器中
  4017fc:	48 83 c0 f8          	add    $0xfffffffffffffff8,%rax//this指针的值(0x00007fffffffe120)加上0xfffffffffffffff8,因为溢出,相当于0x00007fffffffe120-0x8,结果为0x00007fffffffe118即ins对象的首地址
  401800:	48 89 c7             	mov    %rax,%rdi//将偏移后的this指针存到rdi第一参数寄存器中,作为<Derived::i()>的参数
  401803:	5d                   	pop    %rbp
  401804:	e9 27 ff ff ff       	jmpq   401730 <Derived::i()>
  401809:	0f 1f 80 00 00 00 00 	nopl   0x0(%rax)

后面就是调用虚函数<Derived::i()>后的过程,不再赘述。经过分析,可以发现thunk函数就做了两件事,以适当的offset值调整this指针,跳到virtual function去。和上面说的两点完全一致。


编译期绑定和运行期绑定(动态绑定)

还是用上面的示例,可以看到子类中有定义一个非虚函数。咱们就来看下什么是运行期绑定和动态绑定的区别是什么。
在此之前,先看下绑定的概念:调用代码跟函数地址什么时候关联到一起。

//编译期绑定
d.myFunc2();
  d.myFunc2();
  401596:	48 8b 7d d0          	mov    -0x30(%rbp),%rdi
  40159a:	e8 d1 00 00 00       	callq  401670 <Derived::myFunc2()>

编译期绑定:调用代码跟函数在编译期就关联到一起。可以看到函数调用是直接调用指定地址的函数,而且机器码会出现E8的字样,表示直接调用,俗称E8CALL

//动态绑定
b1.f();
b2.i();
d.f();
d.i();
d.g();
d.myFunc1();
 b1.f();
401540:	48 8b 4d e0          	mov    -0x20(%rbp),%rcx
401544:	48 8b 11             	mov    (%rcx),%rdx
401547:	48 89 cf             	mov    %rcx,%rdi
40154a:	48 89 85 20 ff ff ff 	mov    %rax,-0xe0(%rbp)
401551:	ff 12                	callq  *(%rdx)
  b2.i();
401553:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
401557:	48 8b 08             	mov    (%rax),%rcx
40155a:	48 89 c7             	mov    %rax,%rdi
40155d:	ff 51 08             	callq  *0x8(%rcx)
  d.f();
401560:	48 8b 45 d0          	mov    -0x30(%rbp),%rax
401564:	48 8b 08             	mov    (%rax),%rcx
401567:	48 89 c7             	mov    %rax,%rdi
40156a:	ff 11                	callq  *(%rcx)
  d.i();
40156c:	48 8b 45 d0          	mov    -0x30(%rbp),%rax
401570:	48 8b 08             	mov    (%rax),%rcx
401573:	48 89 c7             	mov    %rax,%rdi
401576:	ff 51 10             	callq  *0x10(%rcx)
  d.g();
401579:	48 8b 45 d0          	mov    -0x30(%rbp),%rax
40157d:	48 89 c1             	mov    %rax,%rcx
401580:	48 8b 00             	mov    (%rax),%rax
401583:	48 89 cf             	mov    %rcx,%rdi
401586:	ff 50 08             	callq  *0x8(%rax)
  d.myFunc1();
401589:	48 8b 45 d0          	mov    -0x30(%rbp),%rax
40158d:	48 8b 08             	mov    (%rax),%rcx
401590:	48 89 c7             	mov    %rax,%rdi
401593:	ff 51 18             	callq  *0x18(%rcx)

动态绑定:调用代码跟函数在运行期才能关联到一起。通过上面的反汇编代码可以发现,函数的调用地址是基于某个寄存器的偏移值而确定的,无法在编译期确定。而且在函数调用机器码中会出现ff的字样,表示间接调用,俗称FFCALL。动态绑定还有一个别名叫多态,只有虚函数才能是动态绑定。


多重继承下的成员布局以及this指针偏移

经过上文的分析,我们已经熟知多重继承下虚机制是如何工作的。这一小节,我将带大家分析父类含成员变量时多重继承的成员布局。在父类指针支持多态时会发生this指针偏移,delete this指针偏移后的父类指针会导致异常。说的有点繁琐,不要紧,咱们看下文:
代码示例:

//基类1
class Base1
{
public:
    Base1():b1(1) {}
    virtual void f()
    {
        std::cout << "base1::f()" << std::endl;
    }
    int b1;
};
//基类2
class Base2
{
public:
    Base2():b2(2) {}
    virtual void h()
    {
        std::cout << "base2::h()" << std::endl;
    }
    int b2;
};
//子类
class Derived:public Base1,public Base2
{
public:
    Derived():m(3),n(4) {}
    virtual void f()//覆盖父类1的虚函数
    {
        std::cout << "derived::f()" << std::endl;
    }
    virtual void h()
    {
        std::cout << "derived::h()" << std::endl;
    }
    int m;
    int n;
};
int main()
{
    printf("Derived类的大小%d\n",sizeof(Derived));
    //打印成员变量的偏移值
    printf("Derived::b1 = %d\n",&Derived::b1);//b1是基于Base1的偏移
    printf("Derived::b2 = %d\n",&Derived::b2);//b2是基于Base2的偏移
    printf("Derived::m = %d\n",&Derived::m);//m基于Derived的偏移
    printf("Derived::n = %d\n",&Derived::n);//n基于Derived的偏移

    Derived obj;
    Base1 *pbase1 = &obj;//this指针没有调整。
    Base2 *pbase2 = &obj;//this指针向下调整0x10字节。

    //现在我们知道Base2在支持多态时会调整this指针。如果我们的obj是new出来的,当我们用delete去删除pbase2时会发生什么?
    Base2 *new_pbase2 = new Derived();
    //delete new_pbase2;异常
    delete (Derived*)new_pbase2;//让编译器重新调整this指针,再delete掉。
}

运行结果如下:
请添加图片描述
根据数据成员的偏移值,我们可以得出该类的成员布局:
请添加图片描述
用GDB打印出空间布局,以及虚表内的虚函数:
请添加图片描述
和上述说明一致!
值得关注的一个问题是,Base2在支持多态时会调整this指针。如果我们的obj是new出来的,当我们用delete去删除pbase2时会报异常,其原因在于直接delete调整后的this指针无法将new出来的derived类对象完整删除。除非我们将该this指针继续调整到derived类对象的开头,或者在父类和基类中添加virtual析构函数。
下一小节咱们继续深入探讨多重继承下virtual析构函数的工作原理以及虚表对应的变化!


多重继承下virtual析构函数的工作原理以及虚表的变化

继上一小节,我们已经知道多重继承下数据成员的分布,为了更好的解决delete this指针偏移后的父类指针,我们必须在父类和基类中添加virtual析构函数。这样编译器就会为虚表多加几个槽,目的在于帮助this指针偏移后的父类指针回调到derived类对象的开头,进而完成delete。
示例代码和上一小节差不多,如下:

//基类1
class Base1
{
public:
    Base1():b1(1) {}
    virtual void f()
    {
        std::cout << "base1::f()" << std::endl;
    }
    virtual ~Base1() {}
    int b1;
};
//基类2
class Base2
{
public:
    Base2():b2(2) {}
    virtual void h()
    {
        std::cout << "base2::h()" << std::endl;
    }
    virtual ~Base2() {}
    int b2;
};
//子类
class Derived:public Base1,public Base2
{
public:
    Derived():m(3),n(4) {}
    virtual void f()//覆盖父类1的虚函数
    {
        std::cout << "derived::f()" << std::endl;
    }
    virtual void h()
    {
        std::cout << "derived::h()" << std::endl;
    }
    virtual ~Derived() {}
    int m;
    int n;
};
int main()
{
    Derived *new_pderived = new Derived();
    //打印第一张虚表
    long* pderived_cast1 = reinterpret_cast<long*>(new_pderived);//重新解释类对象,获得虚表指针的地址。
    long* pderived_vptr1 = reinterpret_cast<long*>(*pderived_cast1);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
    for(int i = 0; i < 4; i++)
    {
        printf("pderived_vptr1[%d] 的内容为: %p\n",i,pderived_vptr1[i]);
    }
    std::cout << "----------------------------" << std::endl;
    //打印第二张虚表
    long* pderived_cast2 = pderived_cast1 + 2;//偏移到第二个虚表指针的地址上
    long* pderived_vptr2 = reinterpret_cast<long*>(*pderived_cast2);//虚表指针的类型从long重新解释为long*。让它体现出指针的性质而不是long数值
    for(int i = 0; i < 3; i++)
    {
        printf("pderived_vptr2[%d] 的内容为: %p\n",i,pderived_vptr2[i]);
    }
    delete new_pderived;

    Base2 *new_pbase2 = new Derived();
    delete new_pbase2;
}

其对应的反汇编代码如下(我将有用的部分贴在下面):

    Base2 *new_pbase2 = new Derived();
  40136e:	e8 0d fd ff ff       	callq  401080 <operator new(unsigned long)@plt>
  401373:	48 89 c1             	mov    %rax,%rcx
  401376:	48 89 c2             	mov    %rax,%rdx
  401379:	48 89 c7             	mov    %rax,%rdi
  40137c:	48 89 4d 88          	mov    %rcx,-0x78(%rbp)
  401380:	48 89 55 80          	mov    %rdx,-0x80(%rbp)
  401384:	e8 87 00 00 00       	callq  401410 <Derived::Derived()>
  401389:	e9 00 00 00 00       	jmpq   40138e <main+0x16e>
  40138e:	31 c0                	xor    %eax,%eax
  401390:	89 c1                	mov    %eax,%ecx
  401392:	48 8b 55 80          	mov    -0x80(%rbp),%rdx
  401396:	48 83 fa 00          	cmp    $0x0,%rdx
  40139a:	48 89 8d 78 ff ff ff 	mov    %rcx,-0x88(%rbp)
  4013a1:	0f 84 11 00 00 00    	je     4013b8 <main+0x198>
  4013a7:	48 8b 45 80          	mov    -0x80(%rbp),%rax
  4013ab:	48 05 10 00 00 00    	add    $0x10,%rax
  4013b1:	48 89 85 78 ff ff ff 	mov    %rax,-0x88(%rbp)
  4013b8:	48 8b 85 78 ff ff ff 	mov    -0x88(%rbp),%rax
  4013bf:	48 89 45 a8          	mov    %rax,-0x58(%rbp)
    delete new_pbase2;
  4013c3:	48 8b 45 a8          	mov    -0x58(%rbp),%rax
  4013c7:	48 83 f8 00          	cmp    $0x0,%rax
  4013cb:	48 89 85 70 ff ff ff 	mov    %rax,-0x90(%rbp)
  4013d2:	0f 84 10 00 00 00    	je     4013e8 <main+0x1c8>
  4013d8:	48 8b 85 70 ff ff ff 	mov    -0x90(%rbp),%rax
  4013df:	48 8b 08             	mov    (%rax),%rcx
  4013e2:	48 89 c7             	mov    %rax,%rdi
  4013e5:	ff 51 10             	callq  *0x10(%rcx)
  4013e8:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4013eb:	48 81 c4 90 00 00 00 	add    $0x90,%rsp
  4013f2:	5d                   	pop    %rbp
  4013f3:	c3                   	retq  

0000000000401540 <Derived::~Derived()>:
    virtual ~Derived() {}
  401540:	55                   	push   %rbp
  401541:	48 89 e5             	mov    %rsp,%rbp
  401544:	48 83 ec 10          	sub    $0x10,%rsp
  401548:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
  40154c:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  401550:	48 89 c1             	mov    %rax,%rcx
  401553:	48 81 c1 10 00 00 00 	add    $0x10,%rcx
  40155a:	48 89 cf             	mov    %rcx,%rdi
  40155d:	48 89 45 f0          	mov    %rax,-0x10(%rbp)
  401561:	e8 aa 01 00 00       	callq  401710 <Base2::~Base2()>
  401566:	48 8b 45 f0          	mov    -0x10(%rbp),%rax
  40156a:	48 89 c7             	mov    %rax,%rdi
  40156d:	e8 1e 01 00 00       	callq  401690 <Base1::~Base1()>
  401572:	48 83 c4 10          	add    $0x10,%rsp
  401576:	5d                   	pop    %rbp
  401577:	c3                   	retq   
  401578:	0f 1f 84 00 00 00 00 	nopl   0x0(%rax,%rax,1)
  40157f:	00 

0000000000401580 <Derived::~Derived()>:
  401580:	55                   	push   %rbp
  401581:	48 89 e5             	mov    %rsp,%rbp
  401584:	48 83 ec 10          	sub    $0x10,%rsp
  401588:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
  40158c:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  401590:	48 89 c7             	mov    %rax,%rdi
  401593:	48 89 45 f0          	mov    %rax,-0x10(%rbp)
  401597:	e8 a4 ff ff ff       	callq  401540 <Derived::~Derived()>
  40159c:	48 8b 45 f0          	mov    -0x10(%rbp),%rax
  4015a0:	48 89 c7             	mov    %rax,%rdi
  4015a3:	e8 b8 fa ff ff       	callq  401060 <operator delete(void*)@plt>
  4015a8:	48 83 c4 10          	add    $0x10,%rsp
  4015ac:	5d                   	pop    %rbp
  4015ad:	c3                   	retq   
  4015ae:	66 90                	xchg   %ax,%ax

0000000000401610 <non-virtual thunk to Derived::~Derived()>:
  401610:	55                   	push   %rbp
  401611:	48 89 e5             	mov    %rsp,%rbp
  401614:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
  401618:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  40161c:	48 83 c0 f0          	add    $0xfffffffffffffff0,%rax
  401620:	48 89 c7             	mov    %rax,%rdi
  401623:	5d                   	pop    %rbp
  401624:	e9 17 ff ff ff       	jmpq   401540 <Derived::~Derived()>
  401629:	0f 1f 80 00 00 00 00 	nopl   0x0(%rax)

0000000000401630 <non-virtual thunk to Derived::~Derived()>:
  401630:	55                   	push   %rbp
  401631:	48 89 e5             	mov    %rsp,%rbp
  401634:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
  401638:	48 8b 45 f8          	mov    -0x8(%rbp),%rax
  40163c:	48 83 c0 f0          	add    $0xfffffffffffffff0,%rax
  401640:	48 89 c7             	mov    %rax,%rdi
  401643:	5d                   	pop    %rbp
  401644:	e9 37 ff ff ff       	jmpq   401580 <Derived::~Derived()>
  401649:	0f 1f 80 00 00 00 00 	nopl   0x0(%rax)

因为virtual析构函数的加入,我将打印的步骤简化了,否则显示会混乱。运行结果如下:
在这里插入图片描述
这种情况下GDB打印出来的虚表内容有错误,或者是它故意隐瞒,其原因不得而知,我将补充后的结果贴在这里:

gef➤  info vtbl *new_pderived
vtable for 'Derived' @ 0x4020b0 (subobject @ 0x416eb0):
[0]: 0x401500 <Derived::f()>
[1]: 0x401540 <Derived::~Derived()>
[2]: 0x401580 <Derived::~Derived()>
[3]: 0x4015b0 <Derived::h()>
vtable for 'Base2' @ 0x4020e0 (subobject @ 0x416ec0):
[0]: 0x4015f0 <non-virtual thunk to Derived::h()>
[1]: 0x401610 <non-virtual thunk to Derived::~Derived()>//笔者补充上的
[2]: 0x401630 <non-virtual thunk to Derived::~Derived()>//笔者补充上的

我们重点关注最后两行:Base2 *new_pbase2 = new Derived();delete new_pbase2;看编译如何完成this指针的回调:
在这里插入图片描述
反汇编代码运行到0x4013c3 <main+419> mov rax, QWORD PTR [rbp-0x58]之前发生的事情有:Derived类对象在0x416eb0堆内存地址上进行构造,0x416eb0+0x10后的堆指针(Base2类对象的this指针)存储在$rbp-0x58(0x7fffffffe0d8)栈内存中。这一过程的结果我用一张图进行表达:
请添加图片描述

在这里插入图片描述
该图在0x4013e5 <main+453> call QWORD PTR [rcx+0x10]还未执行时发生的事情有:编译器将Base2类对象的this指针存储在rdi参数寄存器中,作为<non-virtual thunk to Derived::~Derived()>函数(rcx+0x10)的第一参数。该函数指针指向文本段0x401630处。
在这里插入图片描述
反汇编代码运行到0x401644 <non-virtual+0> jmp 0x401580 <Derived::~Derived()>之前发生的事情有:rdi参数寄存器将其值保存到rax临时寄存器中,rax将该值(0x416ec0)加上0xfffffffffffffff0,由于溢出,相当于-0x10,得到的新值(0x416eb0)存储到rax寄存器中。随后调用带operator delete(void*)的virtual析构函数。
至此完成this指针回调,delete可以正常销毁掉new出来Derived类对象。


多重虚继承下的访问虚基类成员变量时虚表的工作原理,以及子类成员布局

在分析之前,咱们先回顾一下《深度探索c++对象模型》p120提出来的一个问题:
每一个对象必须针对其每一个virtual base class背负一个额外的指针,然而理想上我们却希望class object有固定的负担,不因为其virtual base classes的个数而有所变化。想想看这该如何解决?

  • 一般而言有两种解决方法。Microsoft 编译器引入所谓的virtual base class table。每一个class object如果有一个或者多个virtual base classes,就会由编译器安插一个指针,指向virtual base class table。至于真正的virtual base class指针,当然是被放在该表格中。
  • 第二个解决方法,是在virtual function table 中放置virtual base class 的 offset(而不是第一种办法中说的virtual base class指针)。编译器可以通过virtual function table的正负值来索引该offset。如果是正值,显然索引到的是virtual function;如果是负值,则是索引到virtual base class offsets。

正如上面第二种方法所述,clang编译器采取的就是基于vtbl的offset来获取虚基类的this指针,进而完成虚基类数据成员的访问。
咱们来看具体分析过程,如下:
先设计一个菱形继承的Derived类。

//爷爷类
class grandpa//类名首字母忘记大写了,抱歉
{
public:
    grandpa():pa(1) {}
    virtual void f()
    {
        std::cout << "grandpa::f()" << std::endl;
    }
    virtual void g()
    {
        std::cout << "grandpa::g()" << std::endl;
    }
    virtual ~grandpa() {}
    int pa;
};
//父类1
class Base1:virtual public grandpa
{
public:
    Base1():b1(2) {}
    virtual void h()
    {
        std::cout << "base1::h()" << std::endl;
    }
    virtual void i()
    {
        std::cout << "base1::i()" << std::endl;
    }
    virtual ~Base1() {}
    int b1;
};
//父类2
class Base2:virtual public grandpa
{
public:
    Base2():b2(3) {}
    virtual void j()
    {
        std::cout << "base2::j()" << std::endl;
    }
    virtual void k()
    {
        std::cout << "base2::k()" << std::endl;
    }
    virtual ~Base2() {}
    int b2;
};
//子类
class Derived:public Base1,public Base2
{
public:
    Derived():m(4),n(5) {}
    virtual void f()//覆盖爷爷类的虚函数
    {
        std::cout << "derived::f()" << std::endl;
    }
    virtual void h()//覆盖父类1的虚函数
    {
        std::cout << "derived::h()" << std::endl;
    }
    virtual void j()//覆盖父类2的虚函数
    {
        std::cout << "derived::j()" << std::endl;
    }
    virtual void l()//derived类自己的虚函数
    {
        std::cout << "derived::l()" << std::endl;
    }
    virtual ~Derived() {}
    int m;
    int n;
};
int main()
{
    Derived *new_pderived = new Derived();
    new_pderived->pa = 11;
    delete new_pderived;

    Base1 *new_pbase1 = new Derived();
    new_pbase1->pa = 11;
    delete new_pbase1;

    Base2 *new_pbase2 = new Derived();
    new_pbase2->pa = 11;
    delete new_pbase2;
}

需要用到的反汇编代码如下:

    Derived *new_pderived = new Derived();
  401227:	e8 44 fe ff ff       	callq  401070 <operator new(unsigned long)@plt>
  40122c:	48 89 c1             	mov    %rax,%rcx
  40122f:	48 89 c2             	mov    %rax,%rdx
  401232:	48 89 c7             	mov    %rax,%rdi
  401235:	48 89 4d c8          	mov    %rcx,-0x38(%rbp)
  401239:	48 89 55 c0          	mov    %rdx,-0x40(%rbp)
  40123d:	e8 7e 01 00 00       	callq  4013c0 <Derived::Derived()>
  401242:	e9 00 00 00 00       	jmpq   401247 <main+0x37>
  401247:	48 8b 45 c0          	mov    -0x40(%rbp),%rax
  40124b:	48 89 45 f0          	mov    %rax,-0x10(%rbp)
    new_pderived->pa = 11;
  40124f:	48 8b 4d f0          	mov    -0x10(%rbp),%rcx
  401253:	48 8b 11             	mov    (%rcx),%rdx
  401256:	48 8b 52 e8          	mov    -0x18(%rdx),%rdx
  40125a:	c7 44 11 08 0b 00 00 	movl   $0xb,0x8(%rcx,%rdx,1)
  401261:	00 
    delete new_pderived;
  401262:	48 8b 4d f0          	mov    -0x10(%rbp),%rcx
  401266:	48 83 f9 00          	cmp    $0x0,%rcx
  40126a:	48 89 4d b8          	mov    %rcx,-0x48(%rbp)
  40126e:	0f 84 0d 00 00 00    	je     401281 <main+0x71>
  401274:	48 8b 45 b8          	mov    -0x48(%rbp),%rax
  401278:	48 8b 08             	mov    (%rax),%rcx
  40127b:	48 89 c7             	mov    %rax,%rdi
  40127e:	ff 51 18             	callq  *0x18(%rcx)
  401281:	bf 38 00 00 00       	mov    $0x38,%edi

    Base1 *new_pbase1 = new Derived();
  401286:	e8 e5 fd ff ff       	callq  401070 <operator new(unsigned long)@plt>
  40128b:	48 89 c1             	mov    %rax,%rcx
  40128e:	48 89 c2             	mov    %rax,%rdx
  401291:	48 89 c7             	mov    %rax,%rdi
  401294:	48 89 4d b0          	mov    %rcx,-0x50(%rbp)
  401298:	48 89 55 a8          	mov    %rdx,-0x58(%rbp)
  40129c:	e8 1f 01 00 00       	callq  4013c0 <Derived::Derived()>
  4012a1:	e9 00 00 00 00       	jmpq   4012a6 <main+0x96>
  4012a6:	48 8b 45 a8          	mov    -0x58(%rbp),%rax
  4012aa:	48 89 45 d8          	mov    %rax,-0x28(%rbp)
    new_pbase1->pa = 11;
  4012ae:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
  4012b2:	48 8b 08             	mov    (%rax),%rcx
  4012b5:	48 8b 49 e8          	mov    -0x18(%rcx),%rcx
  4012b9:	c7 44 08 08 0b 00 00 	movl   $0xb,0x8(%rax,%rcx,1)
  4012c0:	00 
    delete new_pbase1;
  4012c1:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
  4012c5:	48 83 f8 00          	cmp    $0x0,%rax
  4012c9:	48 89 45 a0          	mov    %rax,-0x60(%rbp)
  4012cd:	0f 84 0d 00 00 00    	je     4012e0 <main+0xd0>
  4012d3:	48 8b 45 a0          	mov    -0x60(%rbp),%rax
  4012d7:	48 8b 08             	mov    (%rax),%rcx
  4012da:	48 89 c7             	mov    %rax,%rdi
  4012dd:	ff 51 18             	callq  *0x18(%rcx)
  4012e0:	bf 38 00 00 00       	mov    $0x38,%edi

    Base2 *new_pbase2 = new Derived();
  4012e5:	e8 86 fd ff ff       	callq  401070 <operator new(unsigned long)@plt>
  4012ea:	48 89 c1             	mov    %rax,%rcx
  4012ed:	48 89 c2             	mov    %rax,%rdx
  4012f0:	48 89 c7             	mov    %rax,%rdi
  4012f3:	48 89 4d 98          	mov    %rcx,-0x68(%rbp)
  4012f7:	48 89 55 90          	mov    %rdx,-0x70(%rbp)
  4012fb:	e8 c0 00 00 00       	callq  4013c0 <Derived::Derived()>
  401300:	e9 00 00 00 00       	jmpq   401305 <main+0xf5>
  401305:	31 c0                	xor    %eax,%eax
  401307:	89 c1                	mov    %eax,%ecx
  401309:	48 8b 55 90          	mov    -0x70(%rbp),%rdx
  40130d:	48 83 fa 00          	cmp    $0x0,%rdx
  401311:	48 89 4d 88          	mov    %rcx,-0x78(%rbp)
  401315:	0f 84 0e 00 00 00    	je     401329 <main+0x119>
  40131b:	48 8b 45 90          	mov    -0x70(%rbp),%rax
  40131f:	48 05 10 00 00 00    	add    $0x10,%rax
  401325:	48 89 45 88          	mov    %rax,-0x78(%rbp)
  401329:	48 8b 45 88          	mov    -0x78(%rbp),%rax
  40132d:	48 89 45 d0          	mov    %rax,-0x30(%rbp)
    new_pbase2->pa = 11;
  401331:	48 8b 45 d0          	mov    -0x30(%rbp),%rax
  401335:	48 8b 08             	mov    (%rax),%rcx
  401338:	48 8b 49 e8          	mov    -0x18(%rcx),%rcx
  40133c:	c7 44 08 08 0b 00 00 	movl   $0xb,0x8(%rax,%rcx,1)
  401343:	00 
    delete new_pbase2;
  401344:	48 8b 45 d0          	mov    -0x30(%rbp),%rax
  401348:	48 83 f8 00          	cmp    $0x0,%rax
  40134c:	48 89 45 80          	mov    %rax,-0x80(%rbp)
  401350:	0f 84 0d 00 00 00    	je     401363 <main+0x153>
  401356:	48 8b 45 80          	mov    -0x80(%rbp),%rax
  40135a:	48 8b 08             	mov    (%rax),%rcx
  40135d:	48 89 c7             	mov    %rax,%rdi
  401360:	ff 51 18             	callq  *0x18(%rcx)
  401363:	8b 45 fc             	mov    -0x4(%rbp),%eax
  401366:	48 81 c4 80 00 00 00 	add    $0x80,%rsp
  40136d:	5d                   	pop    %rbp
  40136e:	c3                   	retq   
  40136f:	48 89 45 e8          	mov    %rax,-0x18(%rbp)
  401373:	89 55 e4             	mov    %edx,-0x1c(%rbp)
  401376:	48 8b 7d c8          	mov    -0x38(%rbp),%rdi

Derived类的vtbl内容如下(编译器没有完全打印出来,笔者将补充后的列在下面):

gef➤  info vtbl *new_pderived
vtable for 'Derived' @ 0x402020 (subobject @ 0x416eb0):
[0]: 0x401850 <Derived::h()>
[1]: 0x4015b0 <Base1::i()>
[2]: 0x401890 <Derived::~Derived()>
[3]: 0x4018d0 <Derived::~Derived()>
[4]: 0x401900 <Derived::f()>
[5]: 0x401940 <Derived::j()>
[6]: 0x401980 <Derived::l()>
vtable for 'Base2' @ 0x402070 (subobject @ 0x416ec0):
[0]: 0x4019c0 <non-virtual thunk to Derived::j()>
[1]: 0x401760 <Base2::k()>
[2]: 0x4019e0 <non-virtual thunk to Derived::~Derived()>//笔者补充的
[3]: 0x401a00 <non-virtual thunk to Derived::~Derived()>//笔者补充的
vtable for 'grandpa' @ 0x4020b8 (subobject @ 0x416ed8):
[0]: 0x401a20 <virtual thunk to Derived::f()>
[1]: 0x4016a0 <grandpa::g()>
[2]: 0x401a40 <virtual thunk to Derived::~Derived()>//笔者补充的
[3]: 0x401a60 <virtual thunk to Derived::~Derived()>//笔者补充的

至于为什么Base2类和grandpa类会多出两个thunk函数,上一小节已经分析过。这里补充一点,因为Derived类对象的数据布局最上面是Base1类对象,中间是Base2类对象,virtual grandpa对象永远是在最下面。这样就导致Base2类在支持多态时调用被重写的虚函数以及调用Derived类的virtual析构函数时,需要调整this指针。grandpa类道理一样。
咱们用GDB查看Derived类对象的内存布局,并用图画方式直观表达出来:
在这里插入图片描述
请添加图片描述
上图中,笔者将offset出现的地方明确标明出来了,下面咱们就来看下编译是如何取出这个offsets的!
请添加图片描述
反汇编代码0x401256 <main+70> mov rdx, QWORD PTR [rdx-0x18]还未执行时,发生的事情有:Derived类对象在0x416eb0堆内存中进行了构造。new_pderived指针的地址为rbp-0x10,它指向0x416eb0堆内存。编译器将Derived类vptr1指向的虚表地址存储到rdx中。
接下来将会发生的事情有:编译器将该虚表地址偏移-0x18字节并获取该地址中的内容(offset),将其值(0x28)存入rdx寄存器中。最后将十进制11存入this指针偏移后的地址中(rcx+rdx*1+0x80x416eb0+0x28*1+0x8 = 0x416ee0,即pa的地址)。
在这里插入图片描述
代码段new_pbase1->pa = 11;new_pbase2->pa = 11;同样会出现取offset进行偏移后,然后存取grandpa的成员变量pa的情况。new_pbase1->pa取到的offset值和new_pderived->pa一样是0x28。而new_pbase2->pa取到的值为0x18
在这里插入图片描述
如果是虚基类grandpa在支持多态时,且new_pgrandpa->pa进行存取操作,则不用偏移this指针,也就不需要取offset。该虚基类的虚表中也没有存offset
在这里插入图片描述
可以看到0x4020b8-0x18地址上的值是个牛马值 😃


  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值