欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

c++ vtable 解析下篇 | 编程知识4

程序员文章站 2022-03-09 08:34:24
...

续接上篇,继续分析 vtable 的内容。由于篇幅过大,所以分成了上下两篇。

 

1、typeinfo

typeinfo for Child*:        .xword  _ZTVN10__cxxabiv119__pointer_type_infoE+16        .xword  typeinfo name for Child*        .word   0        .zero   4        .xword  typeinfo for Childtypeinfo name for Child*:        .string "P5Child"typeinfo for Child:        .xword  _ZTVN10__cxxabiv121__vmi_class_type_infoE+16        .xword  typeinfo name for Child        .word   0        .word   2        .xword  typeinfo for Mother        .xword  2        .xword  typeinfo for Father        .xword  2050typeinfo name for Child:        .string "5Child"typeinfo for Father:        .xword  _ZTVN10__cxxabiv117__class_type_infoE+16        .xword  typeinfo name for Fathertypeinfo name for Father:        .string "6Father"typeinfo for Mother:        .xword  _ZTVN10__cxxabiv117__class_type_infoE+16        .xword  typeinfo name for Mothertypeinfo name for Mother:        .string "6Mother"

这部分内容是紧接在虚表后面的。

 

可能大家会疑惑 1~8 行是干什么的?因为 Child 已经有 9~19 行的信息记录了。

Child *c = new Child();typeid(c).name();          // 其实是由于这一句导致的,如果去掉这句,1~8行就没了// 第8行的 P5Child 代表 Child*,即 p 代表 point

其实就是:如果运用运行时 rtti 的功能,编译器就需要生成这部分的辅助信息。

 

那么虚表与 typeinfo 的关系是怎样的呢?

c++ vtable 解析下篇 | 编程知识4

如上图所示是一个虚表,可以看到这里 typeinfo 是一个指针,指向 typeinfo 的信息。

 

0x400b48 -> 0x400b90

c++ vtable 解析下篇 | 编程知识4

从这张图也可以看出 typeinfo 的大致结构。

 

首先是 type_info 方法的辅助类,是 __cxxabiv1 里的某个类。

 

对于启用了 RTTI 的类来说,

 

    所有的基础类(没有父类的类)都继承于_class_type_info,

 

    所有的基础类指针都继承自__pointer_type_info

 

    所有的单一继承类都继承自__si_class_type_info

 

    所有的多继承类都继承自__vmi_class_type_info

 

然后是类型名字,如果有继承关系,则最后是指向父类的 typeinfo 的记录。

 

2、top_offset

 

Father 和 Mother 的虚表中,top_offset 都是 0,我们就看 Child 的虚表。

c++ vtable 解析下篇 | 编程知识4

Child 有两个虚指针,Mother 和 Child 合用一个,另一个是 Father 的。

 

为什么会需要有两个虚指针?Mother 和 Child 合用一个指针会有什么问题?

 

因为如果通过函数传递,只有 this 指针,无法知道传来的是什么对象。

void fun(void *h){}

void fun1(Mother *h){}

void fun2(Father *h){}

int main(){
  Child *c = new Child();
    fun(c);
  fun1(c);
    fun2(c);
  delete c;
}

在这个代码中,函数体都为空,可以看下进入函数体之前的操作:​​​​​​​

ldr     x0, [sp, 32]                 ;取出对象指针,下面的指令一样,不再重复  bl      fun(void*)                   ;fun  ldr     x0, [sp, 32]  bl      fun1(Mother*)                ;fun1  ldr     x0, [sp, 32]  cmp     x0, 0                        ;需要判断是否为空指针    beq     .L13                         ;如果是空指针则跳转    ldr     x0, [sp, 32]    add     x0, x0, 8                    ;偏移8个字节,拿的是第二个虚指针    b       .L14                         ;fun2.L13:    mov     x0, 0                        ;指针置空.L14:    bl      fun2(Father*)

从 arm 的汇编可以看出合用指针会出现的问题:​​​​​​​
 


Child c;
(void*)&c != (void*)static_cast<Father*>(&c)

void fun(void *c){
  static_cast<Father*>(h)->FatherFoo();       // 你会惊奇的发现这里调用的是 MotherFoo()
}

 

重新看回 top_offset,这玩意的用处是什么?​​​​​​​

vtable for Child:    .xword  0    .xword  typeinfo for Child    .xword  Child::MotherFoo()    .xword  Mother::MotherFoo2()    .xword  -8    .xword  typeinfo for Child    .xword  Father::FatherFoo()

其实就是用来告诉编译器,要把 this 指针偏移多少字节到所需的类型,这里到 Father 就是 8 字节,可以多继承一个类看看:​​​​​​​

vtable for Child:     .xword  0     .xword  typeinfo for Child     .xword  Mother::MotherFoo()     .xword  Mother::MotherFoo2()     .xword  Child::FatherFoo()     .xword  -8     .xword  typeinfo for Child     .xword  non-virtual thunk to Child::FatherFoo()     .xword  -16     .xword  typeinfo for Child     .xword  haha::hahaFoo()

可以看到 haha 需要偏移 16 字节。

 

上面出现了 non-virtual thunk to Child::FatherFoo() ,这个又是什么呢?

 

其实是我在子类重写了 FatherFoo 方法之后才出现的,可以看到 Child::FatherFoo() 被写到了第一个虚指针的区域。

 

但是如果是 Father* 类型,即用了第二个虚指针,要调用 FatherFoo 方法怎么办?

 

根据前面说到的函数偏移,会调到 non-virtual thunk to Child::FatherFoo() 这个函数,来看下这个函数结构。​​​​​​​

non-virtual thunk to Child::FatherFoo():     sub     x0, x0, #8     b       .LTHUNK0                        ;调用 thunk 方法

第二个虚指针的 top_offset,这里是 -8,负数的效果出现了,即 this 指针需要 -8 然后去调用 FatherFoo 方法。

 

再看看如果把 hahaFoo 也重写的效果:​​​​​​​

non-virtual thunk to Child::hahaFoo():     sub     x0, x0, #16     b       .LTHUNK1

如果父类有成员变量,还 top_offset 加上这部分的偏移,这说明虚指针之间还夹着父类的成员变量。

 

为什么不直接在虚表中覆盖,而是通过 thunk 函数的方式?​​​​​​​

                     __ZThn8_N5Child9FatherFooEv:        // non-virtual thunk to Child::FatherFoo()0000000100003ee0         push       rbp0000000100003ee1         mov        rbp, rsp0000000100003ee4         mov        qword [rbp+var_8], rdi0000000100003ee8         mov        rax, qword [rbp+var_8]0000000100003eec         add        rax, 0xfffffffffffffff8                     ; 即 -80000000100003ef0         mov        rdi, rax0000000100003ef3         pop        rbp0000000100003ef4         jmp        __ZN5Child9FatherFooEv                      ; Child::FatherFoo()

this 指针被减 8,也就是 Father 类型被强行转成了 Child 类型。

 

如果直接覆盖的话,this 指针还是 Father 部分的,一些 Child 使用到的成员变量(比如 Mother 里的)就无法用到。

 

而 Child::FatherFoo() 里的代码指令是写死的,即对于成员变量的偏移都固定了,如果不强转 this 会出问题。

 

3、虚继承​​​​​​​

class ios ...class istream : virtual public ios ...class ostream : virtual public ios ...class iostream : public istream, public ostream

虚继承主要是为了解决菱形继承问题,如果这里没有虚继承的话,则 iostream 会存在两份 ios 的实例,很容易出问题且同步困难。

 

看下虚继承会有什么不同:假设 parent1 和 parent2 都虚继承自 grand,而 child 多继承自 parent1 和 parent2。

c++ vtable 解析下篇 | 编程知识4

其他结构都一样,但可以看到 top_offset 上面都多出了 virtual-base offset。

 

虚继承是如何控制只有一份 grand 实例?

 

    在虚继承时,Child::Child() 会先构造 grand,然后才是 parent1、parent2。

 

    如果不是虚继承,则 Child::Child() 直接调用 parent1::parent1()、parent2::parent2(),然后间接构造 grand。

 

但还有问题,虚继承时,轮到构造 parent1 时,它怎么知道去哪里找已经构造好的 grand 的数据?

 

在虚表和 typeinfo 表之间还有一些如 construction vtable for Parent1-in-Child 的信息。

c++ vtable 解析下篇 | 编程知识4

至于 grand 的数据,这就用到了上面的 virtual-base offset,告诉 this 指针偏移多少字节去拿。

 

这里表中第一个 virtual-base offset 是 32 字节,代表构造 Mother 时的 this 指针需要偏移 32 字节到之前构造 grand 的地方。

 

还有一个 VTT 的信息,这个又是什么呢?

 

VTT 代表 virtual-table table,即记录虚表的表,看下它的主要结构:

c++ vtable 解析下篇 | 编程知识4

用于帮助编译器指令找到它想要的表。

 

接下来看看这部分的整体逻辑,以 Child 和其中的 Mother(parent1) 为例:

 

下面代码部分较长,截取重要讲解部分,需要从 Child::Child() 方法的逻辑看起。​​​​​​​


VTT for Child:
        .xword  vtable for Child+24
        .xword  construction vtable for Mother-in-Child+24
        .xword  construction vtable for Mother-in-Child+64
        .xword  construction vtable for Father-in-Child+24
        .xword  construction vtable for Father-in-Child+56
        .xword  vtable for Child+104
        .xword  vtable for Child+72
construction vtable for Mother-in-Child:
        .xword  24
        .xword  0
        .xword  typeinfo for Mother
        .xword  Mother::MotherFoo()
        .xword  Mother::MotherFoo2()
        .xword  0
        .xword  -24
        .xword  typeinfo for Mother
        .xword  grand::Foo()

grand::grand() [base object constructor]:
        sub     sp, sp, #16
        str     x0, [sp, 8]
        adrp    x0, vtable for grand+16                    ;初始化 grand 的虚表指针
        add     x1, x0, :lo12:vtable for grand+16
        ldr     x0, [sp, 8]
        str     x1, [x0]                                   ;存入 x0 内存
        nop
        add     sp, sp, 16
        ret
Mother::Mother() [base object constructor]:
        sub     sp, sp, #16
        str     x0, [sp, 8]                                ;[sp, 8] 放的是 new 的内存空间首地址
        str     x1, [sp]                                   ;[sp] 放的是 VTT+8 指针
        ldr     x0, [sp]
        ldr     x1, [x0]                                   ;现在 x1 是 construction vtable表+24的位置了
        ldr     x0, [sp, 8]                                ;恢复 x0
        str     x1, [x0]                             ;把表+24 存入 x0 内存,表+24即第一个方法指针,类似虚指针
        ldr     x0, [sp, 8]                                ;恢复 x0
        ldr     x0, [x0]                                   ;解指针,即现在到了表+24的位置
        sub     x0, x0, #24                                ;-24,即到了构造表的 virtual-base offset 的位置
        ldr     x0, [x0]                                   ;取 offset 的值
        mov     x1, x0                                     ;给 x1
        ldr     x0, [sp, 8]                          ;恢复 x0,是指向表+24位置的指针,放在 new 的内存空间首地址
        add     x0, x0, x1                           ;加上 offset 的偏移,拿到了内存中 grand 构造的位置 
        ldr     x1, [sp]                                   ;恢复 x1,即 VTT+8 的指针
        ldr     x1, [x1, 8]                                ;解指针+8,现在取到构造表+64的指针
        str     x1, [x0]                                   ;存入 x0 内存,也是类似虚指针的方式
        nop                                          ;这里构造函数都是空的,所以没什么操作
        add     sp, sp, 16                           ;但通过上述操作,知道了编译器如何拿到它想要的东西了
        ret
Child::Child() [complete object constructor]:
        stp     x29, x30, [sp, -32]!
        mov     x29, sp
        str     x0, [sp, 24]                                ;当前 x0 是刚 new 出来的内存空间
        ldr     x0, [sp, 24]
        add     x0, x0, 24                                  ;偏移 24 个字节,这里用于初始化 grand
        bl      grand::grand() [base object constructor]    ;调用完后,x0+24 放着 grand 的虚指针
        ldr     x2, [sp, 24]
        adrp    x0, VTT for Child+8                         ;使用 VTT 辅助查表
        add     x0, x0, :lo12:VTT for Child+8               
                                             ;VTT+8 是 construction vtable for Mother-in-Child+24 的指针

        mov     x1, x0                                       ;这个指针放到了 x1
        mov     x0, x2                                       ;x0 还是 new 的内存空间首位置
        bl      Mother::Mother() [base object constructor]
        ldr     x0, [sp, 24]
        add     x2, x0, 8
        adrp    x0, VTT for Child+24
        add     x0, x0, :lo12:VTT for Child+24
        mov     x1, x0
        mov     x0, x2
        bl      Father::Father() [base object constructor]   ;Father 构造用内存+8的地方,所以 offset 肯定为 8
        adrp    x0, vtable for Child+24
        add     x1, x0, :lo12:vtable for Child+24
        ldr     x0, [sp, 24]
        str     x1, [x0]
        ldr     x0, [sp, 24]
        add     x0, x0, 24
        adrp    x1, vtable for Child+104
        add     x1, x1, :lo12:vtable for Child+104
        str     x1, [x0]
        adrp    x0, vtable for Child+72
        add     x1, x0, :lo12:vtable for Child+72
        ldr     x0, [sp, 24]
        str     x1, [x0, 8]
        nop
        ldp     x29, x30, [sp], 32
        ret

​​​​​​​

如果代码里有 Mother *m = new Mother() 实际上还会有一个 Mother::Mother() 方法:

 

一个给 Child 类型的初始化用。

 

另一个给 Mother 类型的初始化用(这个里面会有调用 grand::grand() 初始化的操作)。

 

Father(parent2) 如果有的话同理。

 

4、Note

 

  • 拥有虚函数的类会有一个虚表,而且这个虚表存放在类定义模块的数据段中。模块的数据段通常存放定义在该模块的全局数据和静态数据,这样我们可以把虚表看作是模块的全局数据或者静态数据。

  • 类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表,从这个意义上说,我们可以把虚表简单理解为类的静态数据成员。值得注意的是,虽然虚表是共享的,但是虚表指针并不是,类的每一个对象有一个属于它自己的虚表指针。

  • 虚表中存放的是虚函数的地址。

 

 

5、参考&推荐阅读

 

C++ vtables - Part 1 - Basics:https://shaharmike.com/cpp/vtable-part1/

 

C++ vtables - Part 2 - Multiple Inheritance:https://shaharmike.com/cpp/vtable-part2/

 

C++ vtables - Part 3 - Virtual Inheritance:https://shaharmike.com/cpp/vtable-part3/

 

C++ vtables - Part 4 - Compiler-Generated Code:https://shaharmike.com/cpp/vtable-part4/