C ++ vtables。 第1部分(基础知识+多重继承)

大家好! 本文的翻译是专门为“ C ++开发人员”课程的学生准备的。 朝这个方向发展是否有趣? 12月13日在莫斯科时间20:00上线。 “使用Google测试框架的实践”大师班!



在本文中,我们将研究clang如何实现vtable(虚拟方法表)和RTTI(运行时类型标识)。 在第一部分中,我们从基类开始,然后研究多重和虚拟继承。


请注意,在本文中,我们必须深入研究使用gdb为代码的各个部分生成的二进制表示形式。 这是一个很低的水平,但是我会为您做所有的努力工作。 我认为将来的大多数职位都不会描述如此低水平的细节。


免责声明 :此处编写的所有内容均取决于实现,在将来的任何版本中都可能会更改,因此您不应依赖它。 我们仅出于教育目的考虑。


太好了,那就开始吧。


第1部分-vtables-基础


让我们看下面的代码:


#include <iostream> using namespace std; class NonVirtualClass { public: void foo() {} }; class VirtualClass { public: virtual void foo() {} }; int main() { cout << "Size of NonVirtualClass: " << sizeof(NonVirtualClass) << endl; cout << "Size of VirtualClass: " << sizeof(VirtualClass) << endl; } 

 $ #    main.cpp $ clang++ main.cpp && ./a.out Size of NonVirtualClass: 1 Size of VirtualClass: 8 

NonVirtualClass的大小为1个字节,因为在C ++中,类的大小不能为零。 但是,这现在并不重要。


在64位计算机上, VirtualClass是8个字节。 怎么了 因为里面有一个指向vtable的隐藏指针。 vtable是为每个虚拟类创建的静态转换表。 本文讨论了它们的内容以及如何使用它们。


为了更深入地了解vtable的外观,让我们用gdb查看以下代码,以了解如何分配内存:


 #include <iostream> class Parent { public: virtual void Foo() {} virtual void FooNotOverridden() {} }; class Derived : public Parent { public: void Foo() override {} }; int main() { Parent p1, p2; Derived d1, d2; std::cout << "done" << std::endl; } 

 $ #         ,  gdb $ clang++ -std=c++14 -stdlib=libc++ -g main.cpp && gdb ./a.out ... (gdb) #  gdb  -  C++ (gdb) set print asm-demangle on (gdb) set print demangle on (gdb) #     main (gdb) b main Breakpoint 1 at 0x4009ac: file main.cpp, line 15. (gdb) run Starting program: /home/shmike/cpp/a.out Breakpoint 1, main () at main.cpp:15 15 Parent p1, p2; (gdb) #     (gdb) n 16 Derived d1, d2; (gdb) #     (gdb) n 18 std::cout << "done" << std::endl; (gdb) #  p1, p2, d1, d2 -     ,    (gdb) p p1 $1 = {_vptr$Parent = 0x400bb8 <vtable for Parent+16>} (gdb) p p2 $2 = {_vptr$Parent = 0x400bb8 <vtable for Parent+16>} (gdb) p d1 $3 = {<Parent> = {_vptr$Parent = 0x400b50 <vtable for Derived+16>}, <No data fields>} (gdb) p d2 $4 = {<Parent> = {_vptr$Parent = 0x400b50 <vtable for Derived+16>}, <No data fields>} 

这是我们从上面中学到的内容:
-尽管类没有数据成员,但存在指向vtable的隐藏指针;
-p1和p2的vtable相同。 vtable是每种类型的静态数据;
-d1和d2继承了Parent的vtable-pointer,该指针指向vtable Derived;
-所有vtable指示该vtable中有16(0x10)个字节的偏移量。 我们稍后还将讨论。


让我们继续我们的gdb会话以查看vtables的内容。 我将使用x命令,该命令在屏幕上显示内存。 我们将从0x400b40开始以十六进制输出300个字节。 为什么要这个地址呢? 因为我们在上面看到vtable指针指向0x400b50,并且该地址的符号vtable for Derived+16 (16 == 0x10)vtable for Derived+16 (16 == 0x10)


 (gdb) x/300xb 0x400b40 0x400b40 <vtable for Derived>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b48 <vtable for Derived+8>: 0x90 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400b50 <vtable for Derived+16>: 0x80 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400b58 <vtable for Derived+24>: 0x90 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400b60 <typeinfo name for Derived>: 0x37 0x44 0x65 0x72 0x69 0x76 0x65 0x64 0x400b68 <typeinfo name for Derived+8>: 0x00 0x36 0x50 0x61 0x72 0x65 0x6e 0x74 0x400b70 <typeinfo name for Parent+7>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b78 <typeinfo for Parent>: 0x90 0x20 0x60 0x00 0x00 0x00 0x00 0x00 0x400b80 <typeinfo for Parent+8>: 0x69 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400b88: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400b90 <typeinfo for Derived>: 0x10 0x22 0x60 0x00 0x00 0x00 0x00 0x00 0x400b98 <typeinfo for Derived+8>: 0x60 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400ba0 <typeinfo for Derived+16>: 0x78 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400ba8 <vtable for Parent>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x400bb0 <vtable for Parent+8>: 0x78 0x0b 0x40 0x00 0x00 0x00 0x00 0x00 0x400bb8 <vtable for Parent+16>: 0xa0 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 0x400bc0 <vtable for Parent+24>: 0x90 0x0a 0x40 0x00 0x00 0x00 0x00 0x00 ... 

注意:我们看一下经过修饰的字符。 如果您真的很感兴趣,则_ZTV是vtable的前缀,_ZTS是类型字符串(名称)的前缀,而_ZTI是typeinfo的前缀。



这是vtable Parent结构:


地址价值目录内容
0x400ba80x0top_offset(稍后会详细介绍)
0x400bb00x400b78指向Parent的typeinfo的指针(也是上述内存转储的一部分)
0x400bb80x400aa0指向父级的指针:: Foo() (1) 。 _vptr父项指向此处。
0x400bc00x400a90指向父级的指针:: FooNotOverridden() (2)

这是vtable Derived结构:


地址价值目录内容
0x400b400x0top_offset(稍后会详细介绍)
0x400b480x400b90指向派生的typeinfo的指针(也是上述内存转储的一部分)
0x400b500x400a80指向派生指针:: Foo() (3) 。,_ Vptr派生指向此处。
0x400b580x400a90指向父级的指针:: FooNotOverridden()(与父级相同)

1:


 (gdb) # ,        0x400aa0 (gdb) info symbol 0x400aa0 Parent::Foo() in section .text of a.out 

2:


 (gdb) info symbol 0x400a90 Parent::FooNotOverridden() in section .text of a.out 

3:


 (gdb) info symbol 0x400a80 Derived::Foo() in section .text of a.out 

还记得Derived中的vtable指针指向vtable中+16字节的偏移吗? 第三个指针是第一种方法的指针的地址。 想要第三种方法? 没问题-向vtable指针添加2 sizeof(void )。 需要typeinfo记录吗? 转到它前面的指针。


继续-typeinfo记录结构如何?


Parent


地址价值目录内容
0x400b780x602090type_info (1)方法的帮助程序类
0x400b800x400b69代表类型名称的字符串(2)
0x400b880x00表示没有父类型信息条目

这是typeinfo Derived条目:


地址价值目录内容
0x400b900x602210type_info (3)方法的帮助程序类
0x400b980x400b60代表类型名称的字符串(4)
0x400ba00x400b78指向typeinfo父项的指针

1:


 (gdb) info symbol 0x602090 vtable for __cxxabiv1::__class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out 

2:


 (gdb) x/s 0x400b69 0x400b69 <typeinfo name for Parent>: "6Parent" 

3:


 (gdb) info symbol 0x602210 vtable for __cxxabiv1::__si_class_type_info@@CXXABI_1.3 + 16 in section .bss of a.out 

4:


 (gdb) x/s 0x400b60 0x400b60 <typeinfo name for Derived>: "7Derived" 

如果您想了解有关__si_class_type_info的更多信息,可以在此处此处找到一些信息。


这用尽了我在gdb上的技能,并且还完成了这一部分。 我建议有些人认为这太低了,或者根本就没有实用价值。 如果是这样,我建议跳过第2部分和第3 部分 ,直接进入第4部分


第2部分-多重继承


单一继承层次结构的世界对于编译器来说更容易。 正如我们在第一部分中看到的,每个子类通过为每个新的虚拟方法添加条目来扩展父vtable。


让我们看一下多重继承,它使情况变得复杂,即使仅从接口实现继承也是如此。


让我们看下面的代码片段:


 class Mother { public: virtual void MotherMethod() {} int mother_data; }; class Father { public: virtual void FatherMethod() {} int father_data; }; class Child : public Mother, public Father { public: virtual void ChildMethod() {} int child_data; }; 

子结构
_vptr $母亲
mother_data(+填充)
_vptr $父亲
父亲数据
child_data (1)

请注意,有2个vtable指针。 凭直觉,我期望1或3个指针(母亲,父亲和孩子)。 实际上,不可能有一个指针(稍后会详细介绍),并且编译器足够聪明,可以将子级vtable Child的条目组合为vtable Mother的扩展,从而节省了1个指针。


为什么孩子不能为所有三种类型使用一个vtable指针? 请记住,可以将Child指针传递给接受母亲或父亲指针的函数,并且两者都希望this指针在正确的偏移量处包含正确的数据。 这些功能不需要了解Child,因此您绝对不应假定Child确实是它们所依据的“母亲/父亲”指针下的内容。


(1)与本主题无关,但是,有趣的是,将child_data实际上放置在父亲的填充物中。 这称为尾巴填充,可能是将来发布的主题。


这是vtable结构:


地址价值目录内容
0x4008b80top_offset(稍后会详细介绍)
0x4008c00x400930指向Child的typeinfo的指针
0x4008c80x400800母亲:: MotherMethod()。 _vptr $妈妈点在这里。
0x4008d00x400810子:: ChildMethod()
0x4008d8-16top_offset(稍后会详细介绍)
0x4008e00x400930指向Child的typeinfo的指针
0x4008e80x400820父亲::父亲方法()。 _vptr $父亲在这里指向。

在此示例中,Child实例在转换为Mother指针时将具有相同的指针。 但是,当转换为父亲指针时,编译器将计算this指针的偏移量,以指向Child的_vptr $父亲部分(Child结构中的第3个字段,请参见上表)。


换句话说,对于给定的子代c; ::(void )&c!=(Void )static_cast <父亲*>(&c)。 某些人并不期望这样做,也许有一天这些信息将为您节省一些调试时间。


我发现此功能不止一次。 但是,等等,还不是全部。


如果孩子决定替代父亲的方法之一怎么办? 考虑以下代码:


 class Mother { public: virtual void MotherFoo() {} }; class Father { public: virtual void FatherFoo() {} }; class Child : public Mother, public Father { public: void FatherFoo() override {} }; 

情况越来越难了。 该函数可以接受参数Father *,并为其调用FatherFoo()。 但是,如果传递Child实例,则应该使用正确的this指针调用重写的Child方法。 但是,呼叫者不知道他确实包含Child。 它具有一个指向“子代数”偏移量的指针,即父代的位置。 有人必须偏移此指针,但是该怎么做呢? 编译器执行此魔术有什么作用?


在我们回答这个问题之前,请注意,覆盖此Mother方法之一并不是很棘手,因为this指针是相同的。 Child知道vtable Mother之后要阅读的内容,并希望Child方法紧随其后。


解决方法是:编译器创建一个thunk方法来纠正this指针,然后调用“ real”方法。 适配器方法的地址将在vtable父目录下,而“真实”方法的地址将在vtable子目录下。


这是vtable Child


 0x4008e8 <vtable for Child>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x4008f0 <vtable for Child+8>: 0x60 0x09 0x40 0x00 0x00 0x00 0x00 0x00 0x4008f8 <vtable for Child+16>: 0x00 0x08 0x40 0x00 0x00 0x00 0x00 0x00 0x400900 <vtable for Child+24>: 0x10 0x08 0x40 0x00 0x00 0x00 0x00 0x00 0x400908 <vtable for Child+32>: 0xf8 0xff 0xff 0xff 0xff 0xff 0xff 0xff 0x400910 <vtable for Child+40>: 0x60 0x09 0x40 0x00 0x00 0x00 0x00 0x00 0x400918 <vtable for Child+48>: 0x20 0x08 0x40 0x00 0x00 0x00 0x00 0x00 

这是什么意思:


地址价值目录内容
0x4008e80top_offset(即将推出!)
0x4008f00x400960儿童的typeinfo
0x4008f80x400800母亲:: MotherFoo()
0x4009000x400810孩子::父亲之父()
0x400908-8top_offset
0x4009100x400960儿童的typeinfo
0x4009180x400820不是虚拟适配器的子级::: FatherFoo()

说明:如我们先前所见,Child有2个vtable-一个用于母亲和孩子,另一个用于父亲。 在vtable父亲中,FatherFoo()指向“适配器”,而在vtable中,Child直接指向Child :: FatherFoo()。


您问这个“适配器”是什么?


 (gdb) disas /m 0x400820, 0x400850 Dump of assembler code from 0x400820 to 0x400850: 15 void FatherFoo() override {} 0x0000000000400820 <non-virtual thunk to Child::FatherFoo()+0>: push %rbp 0x0000000000400821 <non-virtual thunk to Child::FatherFoo()+1>: mov %rsp,%rbp 0x0000000000400824 <non-virtual thunk to Child::FatherFoo()+4>: sub $0x10,%rsp 0x0000000000400828 <non-virtual thunk to Child::FatherFoo()+8>: mov %rdi,-0x8(%rbp) 0x000000000040082c <non-virtual thunk to Child::FatherFoo()+12>: mov -0x8(%rbp),%rdi 0x0000000000400830 <non-virtual thunk to Child::FatherFoo()+16>: add $0xfffffffffffffff8,%rdi 0x0000000000400837 <non-virtual thunk to Child::FatherFoo()+23>: callq 0x400810 <Child::FatherFoo()> 0x000000000040083c <non-virtual thunk to Child::FatherFoo()+28>: add $0x10,%rsp 0x0000000000400840 <non-virtual thunk to Child::FatherFoo()+32>: pop %rbp 0x0000000000400841 <non-virtual thunk to Child::FatherFoo()+33>: retq 0x0000000000400842: nopw %cs:0x0(%rax,%rax,1) 0x000000000040084c: nopl 0x0(%rax) 

正如我们已经讨论的那样,这是偏移量,并且调用了FatherFoo()。 我们应该改变多少才能让孩子得到? top_offset!


请注意,我个人认为非虚拟thunk名称非常令人困惑,因为它是虚拟函数的虚拟表条目。 我不确定它不是虚拟的,但这只是我的看法。




目前仅此而已,在不久的将来,我们将翻译3和4部分。 关注新闻!

Source: https://habr.com/ru/post/zh-CN479802/


All Articles