casyup.me@outlook.com

0%

other/virtualFunction

前言

想深入了解一下(或者说验证)对象底层是如何工作的

重点是虚函数的调用, 虚指针的生成, 虚表中的数据

以及这些数据在多重继承, 虚继承, 多重虚继承环境下的表现

虚指针(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(); // B 20
pa->func2(); // A2 10

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

虚函数机制和书中所说基本一致

  1. 找到对应的虚表
  2. 调用槽中的函数

review

我在 objdump 的输出中发现, 诸如 _ZTI1C 这样的东西在链接的时候会转化成一个数字常量

有些可能会是基于某个位置的偏移(比如 %rip)

编译器会利用它进行直接寻址, 有些会利用它进行间接寻址