前言
想深入了解一下(或者说验证)对象底层是如何工作的
重点是虚函数的调用, 虚指针的生成, 虚表中的数据
以及这些数据在多重继承, 虚继承, 多重虚继承环境下的表现
虚指针(virtual point)
考虑以下代码
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
| class A{ public: virtual void func(){ printf("A %d\n", _i); } virtual void func2(){ printf("A2 %d\n", _i); }
int _i = 10; };
class B : public A{ public: virtual void func(){ printf("B %d\n", _i); }
int _i = 20; };
int main() { B b; A *pa = &b; pa->func(); pa->func2();
return 0; }
|
输出和预期一致, 其中 func 是被 B 覆盖过的虚函数, 而 func2 则未被覆盖
以下是生成的汇编代码:
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
| main: .LFB1199: pushq %rbp movq %rsp, %rbp subq $32, %rsp movq $_ZTV1B+16, -32(%rbp) // 又是这个东西, 现在我怀疑它是 lippman 说的 thunk // 后面知道了, **其实这就是虚指针, 它被放到了头部** // 编译器非常聪明, 在即使有 vptr, nontrivial 的情况下 // 也并未生成构造函数 movl $10, -24(%rbp) movl $20, -20(%rbp) leaq -32(%rbp), %rax movq %rax, -8(%rbp) movq -8(%rbp), %rax movq (%rax), %rax // rax = vptr 拿到虚指针地址 movq (%rax), %rax // rax = *vptr 获得虚指针中的地址(槽 0) movq -8(%rbp), %rdx // member data 是直接位移得到的 movq %rdx, %rdi call *%rax // 调用slot0 movq -8(%rbp), %rax movq (%rax), %rax addq $8, %rax // 虚指针地址 +8 位移(也就是下一个槽) movq (%rax), %rax // 获得 slot1 的地址 movq -8(%rbp), %rdx movq %rdx, %rdi call *%rax // 调用slot1 movl $0, %eax leave ret
|
所以调用方式和书中一致, 从 vptr 索引虚表, 获得相应的 slot
这些信息全部都是在编译的时候由编译器生成
有一个点我忽略了: 单一继承对象只有一个虚表
后续我加上 C 对象后( C 继承自 A ), 让 B 继承 A, 这样A中就有 B C
但是依旧只有一个虚表, 仅当我让A再继承一个对象时, 这时产生了 2 个虚表
可能会问, 那么C对象是如何通过 pc->A::func() 这样的形式来调用 A 作用域的函数的呢?
答案是, 这会是一个单纯的函数调用, 并不会通过虚表或虚指针, 也没有任何的偏移
(emmm… 也就是说普通成员函数对于类来说, 可能更像是个陌生人, 即使它是成员)
(这也是为什么大多数情况下基类需要 virtual destruct 的原因)
虚析构是如何被调用的
在写标题的时候大概猜到了, 其实虚析构就是一个在子类中占了一个不能被重写的虚表槽
应该就是通过简单的偏移来调用的, 来试一下
(重点在于虚析构是不可能被覆盖的, 因为子类中不可能存在同名的函数)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 5 class A{ 6 public: 7 virtual ~A() { 8 printf("%s\n", "A destruct"); 9 } 10 }; 11 12 class B : public A{ 13 public: 14 virtual ~B() { 15 printf("%s\n", "B destruct"); 16 } 17 }; 18 19 int main() { 20 B a; 21 22 return 0; 23 }
|
汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 161 main: 162 .LFB1204: 163 .cfi_startproc 164 pushq %rbp 165 .cfi_def_cfa_offset 16 166 .cfi_offset 6, -16 167 movq %rsp, %rbp 168 .cfi_def_cfa_register 6 169 pushq %rbx 170 subq $24, %rsp 171 .cfi_offset 3, -24 172 movq $_ZTV1B+16, -32(%rbp) 173 movl $0, %ebx 174 leaq -32(%rbp), %rax 175 movq %rax, %rdi 176 call _ZN1BD1Ev // 就是这里调用了析构函数 177 movl %ebx, %eax 178 addq $24, %rsp 179 popq %rbx 180 popq %rbp 181 .cfi_def_cfa 7, 8 182 ret 183 .cfi_endproc
|
B的析构:
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
| 89 _ZN1BD2Ev: // 如果足够细心, 那么注意到了这里是 _ZN1BD2Ev 而并非 _ZN1BD1Ev // 对此, 我发现编译器类似有个中间层一样的东西(或者说会符号替换?) // 然后其实会跳转到这里来, 汇编中将这两个东西相关联 90 .LFB1201: 91 .cfi_startproc 92 .cfi_personality 0x3,__gxx_personality_v0 93 .cfi_lsda 0x3,.LLSDA1201 94 pushq %rbp 95 .cfi_def_cfa_offset 16 96 .cfi_offset 6, -16 97 movq %rsp, %rbp 98 .cfi_def_cfa_register 6 99 subq $16, %rsp 100 movq %rdi, -8(%rbp) 101 movq -8(%rbp), %rax 102 movq $_ZTV1B+16, (%rax) 103 movl $.LC1, %edi 104 call puts // 编译器知道只是打印一个字符串, 所以调用的是 puts 105 movq -8(%rbp), %rax 106 movq %rax, %rdi 107 call _ZN1AD2Ev // 和我想的不一样, 又一样... 108 movl $0, %eax 109 testl %eax, %eax 110 je .L6 // 这里我也不太懂, 这个不是必跳么? 111 movq -8(%rbp), %rax 112 movq %rax, %rdi 113 call _ZdlPv // 这个东西没有找到
|
和预料中不同的是, A 的析构函数是直接调用的, 而并非偏移
想了想这在意料之中, 因为编译器将它优化成了普通函数
至于 _ZdlPv, 没有在汇编中找到这个符号 …
(我试了一下, 没有什么好的方式让它像被虚函数一样调用, 很可惜…)
虚继承的析构函数调用
感觉非常奇怪, 所以本来是打算看看就好的, 因为之前花功夫去看了看虚继承的内存布局
不过实在太奇怪了, 所以打算好好分析一下, 顺便我在之前分析的时候好像忘了看虚继承的虚函数调用了
因为那时候好像已经晕了…
它会怎么被调用呢? 最无趣的情况就是像上面那样硬编码
不过在虚继承这种比较复杂的环境下, 编译器可能会做出其他反应也说不定?
源码就单纯的E被CD虚继承, B继承CD, 这里直接汇编:
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
| main: .LFB1212: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 pushq %rbx // 这里, 与前面的 pushq 无关 subq $56, %rsp .cfi_offset 3, -24 leaq -64(%rbp), %rax // 前面有 pushq, 所以这里是 -64 movq %rax, %rdi call _ZN1BC1Ev // 构造 movl $0, %ebx leaq -64(%rbp), %rax movq %rax, %rdi call _ZN1BD1Ev // 析构 movl %ebx, %eax addq $56, %rsp popq %rbx popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc
|
B的析构函数:
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
| 457 _ZN1BD1Ev: 458 .LFB1210: 459 .cfi_startproc 460 .cfi_personality 0x3,__gxx_personality_v0 461 .cfi_lsda 0x3,.LLSDA1210 462 pushq %rbp 463 .cfi_def_cfa_offset 16 464 .cfi_offset 6, -16 465 movq %rsp, %rbp 466 .cfi_def_cfa_register 6 467 subq $16, %rsp 468 movq %rdi, -8(%rbp) 469 movl $_ZTV1B+24, %edx 470 movq -8(%rbp), %rax 471 movq %rdx, (%rax) // 栈顶加入了虚指针 +24 472 movl $32, %edx 473 movq -8(%rbp), %rax 474 addq %rax, %rdx 475 movl $_ZTV1B+104, %eax 476 movq %rax, (%rdx) // 栈顶 -32 处加入了虚指针 +104 477 movl $_ZTV1B+64, %edx 478 movq -8(%rbp), %rax 479 movq %rdx, 16(%rax) // 栈顶 -16 处加入了虚指针 +64 480 movq -8(%rbp), %rax 481 movl 28(%rax), %eax 482 movl %eax, %esi 483 movl $.LC3, %edi // .string "B destruct: %d\n" 484 movl $0, %eax 485 call printf 486 movl $_ZTT1B+24, %eax 487 movq -8(%rbp), %rdx 488 addq $16, %rdx 489 movq %rax, %rsi 490 movq %rdx, %rdi 491 call _ZN1CD2Ev // 硬编码... 492 movl $_ZTT1B+8, %edx 493 movq -8(%rbp), %rax 494 movq %rdx, %rsi 495 movq %rax, %rdi 496 call _ZN1AD2Ev 497 movl $2, %eax 498 testl %eax, %eax 499 je .L23 500 movq -8(%rbp), %rax 501 addq $32, %rax 502 movq %rax, %rdi 503 call _ZN1ED2Ev 504 nop
|
虚函数是通过硬编码来调用的
不过有个非常不错的发现, 我在 _ZTT1B 中发现了 LTHUNK 的影子
在 _ZTT1B 中有 LTHUNK 相关的代码, 不过很可惜, 这些猜测无从证明…
来看看虚函数的调用吧:
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| 911 main: 912 .LFB1216: 913 .cfi_startproc 914 .cfi_personality 0x3,__gxx_personality_v0 915 .cfi_lsda 0x3,.LLSDA1216 916 pushq %rbp 917 .cfi_def_cfa_offset 16 918 .cfi_offset 6, -16 919 movq %rsp, %rbp 920 .cfi_def_cfa_register 6 921 pushq %rbx 922 subq $88, %rsp 923 .cfi_offset 3, -24 924 leaq -96(%rbp), %rax 925 movq %rax, %rdi 926 call _ZN1BC1Ev 927 leaq -96(%rbp), %rax 928 movq %rax, -24(%rbp) 929 movq -24(%rbp), %rax 930 movq (%rax), %rax // 解引用 931 addq $16, %rax // +16 932 movq (%rax), %rax // 取这个槽中的地址 933 movq -24(%rbp), %rdx 934 movq %rdx, %rdi 935 .LEHB0: 936 call *%rax 937 leaq -96(%rbp), %rax 938 movq %rax, -32(%rbp) 939 movq -32(%rbp), %rax 940 movq (%rax), %rax 941 addq $16, %rax // +16 942 movq (%rax), %rax 943 movq -32(%rbp), %rdx 944 movq %rdx, %rdi 945 call *%rax // A, B使用了一样的虚表和槽, 仅仅是this指针不同 946 leaq -96(%rbp), %rax 947 addq $16, %rax // +16 948 movq %rax, -40(%rbp) 949 movq -40(%rbp), %rax 950 movq (%rax), %rax 951 addq $16, %rax // +16 952 movq (%rax), %rax 953 movq -40(%rbp), %rdx 954 movq %rdx, %rdi 955 call *%rax // C 先是 +16 取到自己的虚表, 然后同样使用槽 1 956 .LEHE0: 957 movl $0, %ebx 958 leaq -96(%rbp), %rax 959 movq %rax, %rdi 960 call _ZN1BD1Ev 961 movl %ebx, %eax 962 jmp .L39 963 .L38: 964 movq %rax, %rbx 965 leaq -96(%rbp), %rax 966 movq %rax, %rdi 967 call _ZN1BD1Ev 968 movq %rbx, %rax 969 movq %rax, %rdi 970 .LEHB1: 971 call _Unwind_Resume 972 .LEHE1: 973 .L39: 974 addq $88, %rsp 975 popq %rbx 976 popq %rbp 977 .cfi_def_cfa 7, 8 978 ret 979 .cfi_endproc
|
普通虚函数的调用就是找到自己的虚表, 调用对应的槽
这个虚继承来的虚函数调用和普通继承的虚函数调用方式基本一致
多态是如何实现的呢?
每个构造函数, 都会覆写它的所有虚指针, 使他指向不同的地址(但是这些地址都不相同!!!)
如你所见, 当C指针去调用时, 发生了偏移, 虽然取到了同样的 slot, 但却不是同一张虚表
那么我能想到的就是有多张相同的虚表
(我只能如此猜测, 我想不到为什么这样的情况下还能调用同一个函数)
(但是为什么要有相同的呢? 这又是一个问题)
我尝试了看看这些虚表中是什么数据, 可惜失败了, 或者说看不懂
但是同一个类的虚表是完全相同的, 这可以肯定
不同的情况仅在于A类中C类和单独的C类, 这两个C类的虚表是不一样的
summary
虚函数机制和书中所说基本一致
- 找到对应的虚表
- 调用槽中的函数
review
我在 objdump 的输出中发现, 诸如 _ZTI1C 这样的东西在链接的时候会转化成一个数字常量
有些可能会是基于某个位置的偏移(比如 %rip)
编译器会利用它进行直接寻址, 有些会利用它进行间接寻址