casyup.me@outlook.com

0%

other/virtualInherit

前言

想要了解一下虚继承的内部数据结构

(语言: c++, 编译器: g++(4.8.5))

内存布局

考虑以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A{
public:
int _i = 1;
};

class B : virtual public A{
public:
int _i = 2;
};

class C : virtual public A{
public:
int _i = 3;
};

class D : public B, public C{
public:
int _i = 4;
};

单纯的虚拟继承而已, 我使用了这种方式打印它们的内部数据

1
2
3
4
5
6
7
8
D a;
void *p = &a;
int *pi = (int *)p;

for (int i = 0; i < sizeof(a) / 4; ++i)
printf("%d\n", pi[i]);

printf("val:%d\n", a._i);

然后它们A, B, C, D内部数据情况如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
A: 	1
B: 4196632
0
2
1
C: 4196632
0
3
1
D: 4196760
0
2
0
4196784
0
3
4
1
0

emmm…

OK, 我可以把前两个 4 字节认为是虚指针(A没有)

那么A, B, C的内存分布就不用看了, 唯一比较特殊的是虚基类在内存的高地址

然后D的内部分布… 这… emmm… 仔细一想, 还好, 重要的是(Q1: 为什么最后补了一个0 ??)

存取方式

我试着访问了一下数据

1
2
3
4
5
6
7
8
9
D a;
A *pa = &a;
printf("%d\n", pa->_i);
B *pb = &a;
printf("%d\n", pb->_i);
C *pc = &a;
printf("%d\n", pc->_i);
D *pd = &a;
printf("%d\n", pd->_i);

来看一看汇编

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
pushq   %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $80, %rsp // 80 栈帧
leaq -80(%rbp), %rax
movq %rax, %rdi
call _ZN1DC1Ev // 这里是构造函数, 稍后可能需要看看它的构造
leaq -80(%rbp), %rax
addq $32, %rax // +32, OK, 向上 32 字节的确是 1
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
movl (%rax), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf // 1 第一次printf
leaq -80(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax // B 类型指针并没有经过偏移, 这和预料的一样
movl 8(%rax), %eax // 这里+8, 跳过了虚指针
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf // 2 第二次printf
leaq -80(%rbp), %rax
addq $16, %rax // +16 C类型指针跳过了 B的内存, 这也没什么不对
movq %rax, -24(%rbp)
movq -24(%rbp), %rax
movl 8(%rax), %eax // 同理
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf // 3 第三次printf
leaq -80(%rbp), %rax
movq %rax, -32(%rbp)
movq -32(%rbp), %rax
movl 28(%rax), %eax // +28 OK~
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf // 4 第四次printf
movl $0, %eax
leave

数据的访问在编译时就已经定好的, 不存在额外效率影响

A指针 偏移了 +32 字节 直接访问数据

B指针 未偏移 访问数据时跳过了虚指针(8字节)

C指针 偏移 +16 字节 访问数据时跳过了虚指针(8字节)

D指针 未偏移 访问数据时跳过了虚指针 + B, C类数据(共28字节)

或许看一下访问虚基类的数据会有所了解?

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
pushq   %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $80, %rsp
leaq -80(%rbp), %rax
movq %rax, %rdi
call _ZN1DC1Ev
leaq -80(%rbp), %rax
addq $32, %rax
movq %rax, -8(%rbp)
movq -8(%rbp), %rax
movl (%rax), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf // 基类访问方式和上面的汇编并没有不同
leaq -80(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movq (%rax), %rax // 取指针指向的值, 也就是D的地址
subq $24, %rax // D地址减去24字节 也就是说, 它前面还有东西
movq (%rax), %rax // 获得那个地址中的值
movq %rax, %rdx
movq -16(%rbp), %rax
addq %rdx, %rax // 增加了 rdx 大小,
movl (%rax), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
leaq -80(%rbp), %rax
addq $16, %rax
movq %rax, -24(%rbp)
movq -24(%rbp), %rax
movq (%rax), %rax
subq $24, %rax
movq (%rax), %rax
movq %rax, %rdx
movq -24(%rbp), %rax
addq %rdx, %rax
movl (%rax), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
leaq -80(%rbp), %rax
movq %rax, -32(%rbp)
movq -32(%rbp), %rax
movq (%rax), %rax
subq $24, %rax
movq (%rax), %rax
movq %rax, %rdx
movq -32(%rbp), %rax
addq %rdx, %rax
movl (%rax), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %eax
leave

emmm… 有一个重大的发现就是, 基类对象前面有数据

这些数据应该是偏移, 这些偏移配合 + 指针本身的地址能够访问到正确的虚基类数据

也就是说, 这是 深入探索对象 中, 对于虚基类的两种实现方式中的第一种

即: 在每个基类中添加虚基类的偏移, 但是又不全对…

越来越糊涂了 (눈_눈), 深吸一口气, 想想自己要干嘛… emmmm….

这样吧, 完整地看一遍构造的过程, 这样应该就明白了

(如果还是晕, 那么就希望下次遇到这个问题的时候能够更从容一些)

(我已经再这个问题上花太多时间, 再这样下去反而不好, 这也不是一个很重要/常用的知识)

完整的构造过程

首先是 main 区块

1
2
3
4
5
6
7
8
9
10
11
main:
.LFB1196:
pushq %rbp
movq %rsp, %rbp
subq $48, %rsp // 48 字节栈帧, 为什么是 48 呢?
leaq -48(%rbp), %rax
movq %rax, %rdi
call _ZN1DC1Ev // 调用构造函数
movl $0, %eax
leave
ret

然后是D的构造函数:

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
_ZN1DC1Ev:
.LFB1208:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp // 它再次开辟了 16 字节的栈帧
movq %rdi, -8(%rbp) // 把原栈顶指针放入了栈中
movq -8(%rbp), %rax
addq $32, %rax // 原栈顶指针 +32 偏移, 距栈底还有 16 字节
movq %rax, %rdi
call _ZN1AC2Ev // 这里留了 16 字节用于 A 的构造
movl $_ZTT1D+8, %edx // 我很好奇地查了 $_ZTT1D 这东西是多少, 然而事实让我绝望...
// 它有非常多的耦合, 短时间内我根本无法计算出这东西是多少!!!
// 而且里面的代码是以.开头的, 这意味着是伪指令... (;´༎ຶД༎ຶ`)
movq -8(%rbp), %rax
movq %rdx, %rsi // rdx 从未被赋值过, 我很好奇它将什么值给了 rsi
// 对了, 在上面一句用到了 edx, 这和 rdx 有关
// 暂且把它认为是一个, 编译器施加的魔法: magic
movq %rax, %rdi // 原栈顶指针
call _ZN1BC2Ev // 调用 B 构造函数
movl $_ZTT1D+16, %eax
movq -8(%rbp), %rdx // 拿出原栈顶指针
addq $16, %rdx // +16 偏移
movq %rax, %rsi
movq %rdx, %rdi
call _ZN1CC2Ev // 调用 C 构造函数
movl $_ZTV1D+24, %edxl
movq -8(%rbp), %rax
movq %rdx, (%rax) // 这里与调用 B 时的步骤重复了 :), 覆写
movl $_ZTV1D+48, %edx
movq -8(%rbp), %rax
movq %rdx, 16(%rax) // 这里与调用 C 时的步骤重复了 :), 覆写
movq -8(%rbp), %rax
movl $444, 28(%rax) // 写入值
leave
ret

A的构造函数:

1
2
3
4
5
6
7
8
9
10
11
_ZN1AC2Ev:
.LFB1199:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl $111, (%rax) // 将 $111 放入了参数 rdi 指向的内存中 movl: 4字节
popq %rbp
ret // 嗯, 没了, 所以编译器仅仅对这 4 字节赋值
// 剩下的 12 字节呢?
// 其中8字节没用过, 或者不在 D 的内存中, 因为 sizeof d 是40, 而非48

B的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_ZN1BC2Ev:
.LFB1202:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp) // 原栈顶指针
movq %rsi, -16(%rbp) // 未知的原 rdx(magic)
movq -16(%rbp), %rax
movq (%rax), %rdx // magic的地址 -16 中的值放入 rdx
movq -8(%rbp), %rax
movq %rdx, (%rax) // 将 rdx 中的值放入了栈顶, 这里是q, 占了 8 字节
movq -8(%rbp), %rax
movl $222, 8(%rax) // 栈顶 +8 偏移中, 放入了 $222
popq %rbp
ret // 共写入了 12 字节

C的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_ZN1CC2Ev:
.LFB1205:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq -16(%rbp), %rax
movq (%rax), %rdx
movq -8(%rbp), %rax
movq %rdx, (%rax)
movq -8(%rbp), %rax
movl $333, 8(%rax)
popq %rbp // 步骤与 B 基本相同
ret

也就是说, 在D的构造中, ABC的构造都被调用了

要注意的一点是, BC都未调用A的构造, A的构造仅在D中调用了一次

同时要注意的是, BC会用那个编译器给的数字往自身的内存布局中写值

但是如果其上有D, D会将那个值覆写一次, 假设其值恒定不变, 位移分别为:

8 16 24 48

这是一组有规律的数字, 以 8 为开始, 每个数字是前面数字之和(这个就是偏移)

剩下来最重要的是, D覆写了什么?

再次来看看是如何访问虚基类数据的

1
2
3
D a;
C *pd = &a;
printf("%d\n", pd->A::_i);

汇编:

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
.LFB1196:
pushq %rbp
movq %rsp, %rbp
subq $48, %rsp
leaq -48(%rbp), %rax
movq %rax, %rdi
call _ZN1DC1Ev
leaq -48(%rbp), %rax
addq $16, %rax // 因为是 C 类型指针, 所以做了 +16 偏移
movq %rax, -8(%rbp) // C *pd = &a;
movq -8(%rbp), %rax
movq (%rax), %rax // 获得了指针中的值
subq $24, %rax // 该值减去 24
movq (%rax), %rax // 再以该值间接寻址
movq %rax, %rdx
movq -8(%rbp), %rax
addq %rdx, %rax // rdx 本身的值 + 指针的值
movl (%rax), %eax // 间接寻址得到的值就是虚基类的值
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %eax
leave
ret

这次要清晰得多了, 我们以上面D的内存布局为例:

1
2
3
4
5
6
7
8
9
10
11
12
4196760	// 2. 它减去 24 到了这里 
// 3. 用这里的值寻址得到了某个数 x
0
2
0
4196784 // 1. 这是一开始 C 指针指向的内存
// 4. 用这个指针 + x 得到了虚基类数据的地址
0
3
4
1
0

那么在代码中试一下, 如果没有错误的话, 那个 x 应该是 4

(这个地址明显非常低, 它甚至没达到 8 字节, 这应该是编译的时候准备好的数据)

1
2
3
4
5
6
D a;
long long *pl = (long long *)&a;
long long *pl2 = (long long *)&pl;
*pl2 = *pl;

printf("%ld\n", *pl); // 16

取出来了 16, emmm… 嗯, 没错, 4 个 4字节 4 x 4 = 16 !!!!!!!

PS: 有没有思考过为什么通过双间接才能去到虚基类呢?

这里明显的一个问题是, 虚基类的数据是通过 C 的某个地址索引到 D 的某个地址

然后再用 C 的地址索引到虚基类数据, 有两次的跳转

我再次试了一下有继承三个虚基类的情况(就是D再继承了一个类似 BC的类)

发现它的访问方式是一样的, 也就是说, 每一个自身都会往前寻找固定的字节(编译后固定)

然后用那个地址的数据 + 指针本身的地址(我不知道我又没有表达清楚, 不过我感觉我没有 :) )

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
class E : virtual public A{
public:
int _i = 555;
};

class D : public B, public C, public E{
public:
int _i = 444;
};

int main() {
D a;
long long *pl = (long long *)&a;
long long *pl2 = (long long *)&pl;
*pl2 = *pl;
printf("%ld\n", *pl); // 32

pl = (long long *)&a + 2;
pl2 = (long long *)&pl;
*pl2 = *pl;
printf("%ld\n", *pl); // 16

pl = (long long *)&a + 4;
pl2 = (long long *)&pl;
*pl2 = *pl;
printf("%ld\n", *pl); // 0

内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
4196856	(32)// 2. 通过这个地址拿到偏移
0
222
0
4196880 (16)// 1. C对象, 当我要找虚基类对象时, 我先往上移动固定字节
// 这些移动的大小都是在编译时就写死了的常量(编译器自动计算)
// 往上移动的字节会随着数据大小而改变, 但是数据中的地址是不变的
// 比如我这里是4196880, 那么无论往上移动多少
// 上一个地址中存的值恒定为 当前值 - 24 (4196856)
// 3. 根据拿到的偏移 + 地址获取虚基类数据
0
333
0
4196904 (0)// E对象, 它如果想拿到虚基类数据, 执行和 C 对象一样的操作就好
0
555
444
111
0

这样的内存布局在其他方面可能还会有用

那么为什么会有 0 呢? 我估计是因为要实现这种内存布局, 或者说什么其他原因

summary

我很怀疑下次我看笔记时, 我自己看不看得懂我在说什么…