10.28
2025-10-28
周二
C++ 类的内存布局
本日记并非从零基础开始的教学,只是记录自己的思考,写成教学的体量将是本日记的数倍,什么时候有空了或许会改写…
初步探索
突然对发现自己还没学过 C++ 类的内存布局,于是试图学习
使用 clang 进行测试:
-
可以用 clang 的
-Xclang -fdump-record-layouts编译选项查看类的内存布局,为了查看类A及基类的的内存布局,需要代码中有sizeof(A) -
可以用 clang 的
-Xclang -fdump-vtable-layouts编译选项查看虚表,为了查看类A涉及的虚表,需要代码中有A类型的对象
知道了这两个方法,就可以动手实践了!1当然,也可以不自己动手,因为可以找到一篇文章替我们测试了简单的继承情形: C++:虚函数内存布局解析(以 clang 编译器为例)
跑题时间: 这是此人 技术博客 里的文章,从技术博客可以看到,他读过 John Hopcroft 写的《自动机理论、语言和计算导论》,这非常有趣,于是翻了翻他的 生活博客,可以从 工作两年,再次思考如何度过一生和 怀念纯真的前 GPT 时代 中推理出,是 2023 年 6 月本科毕业,立刻在 web3 就业。然而,2024 年的技术博客居然还有光学和抽象代数2,web3 工作真有这么闲?
当然,显然不能满足于只了解简单的菱形继承的情形,而是要能够分析普遍的情形,一个方法是查看 clang 使用的 ABI 的规范
C++ 的主流 ABI 是 Itanium ABI 和 MSVC ABI,GCC 和 Clang(默认) 都使用前者,所以自然更关心 Itanium ABI 下的内存布局,可见规范: Itanium C++ ABI。阅读后可以发现,这里的内存布局部分已经过时,和 clang 对虚继承的处理已经完全不同,规范中写道:
Case (2b) above is now considered to be an error in the design.
而 clang 已经修正了这一设计错误,从几个例子中可以看出,现在不再把直接虚基类或间接虚基类作为主基类(primary base class),现在主基类只能是非虚动态基类。后面的流程需要做的相应改动是自然的
小巧思
struct A {
int a;
virtual void fa() {
cout << "A" << endl;
}
};
struct B : A {
int b;
virtual void fa() {
cout << "B" << endl;
}
};
struct C : A, B {
int c;
};
编译会报 warning:
warning: direct base 'A' is inaccessible due to ambiguity:
struct C -> A
struct C -> B -> A [-Winaccessible-base]
当然,编译还是能通过,可是似乎没有办法调用A::fa ,因为以下四段代码都是不合法的,会报错 基类 “A” 不明确:
C c;
c.A::fa();
C c;
A* pa = &c;
pa->fa();
C c;
A* pa = static_cast<A*>(&c);
pa->fa();
C c;
A* pa = dynamic_cast<A*>(&c);
pa->fa();
那么,如何让 A::fa 被调用,输出 A ?“direct base ‘A’ is inaccessible” 是真的吗?
观察一下内存布局和虚表
*** Dumping AST Record Layout
0 | struct C
0 | struct A (primary base)
0 | (A vtable pointer)
8 | int a
16 | struct B (base)
16 | struct A (primary base)
16 | (A vtable pointer)
24 | int a
28 | int b
32 | int c
| [sizeof=40, dsize=36, align=8,
| nvsize=36, nvalign=8]
Vtable for 'C' (6 entries).
0 | offset_to_top (0)
1 | C RTTI
-- (A, 0) vtable address --
-- (C, 0) vtable address --
2 | void A::fa()
3 | offset_to_top (-16)
4 | C RTTI
-- (A, 16) vtable address --
-- (B, 16) vtable address --
5 | void B::fa()
那么实际上可以这样:
C c;
A* pa = reinterpret_cast<A*>(&c);
pa->fa();
如果调换 A 和 B 作为基类的顺序,改成下面这样:
struct C : B, A {
int c;
};
那么想调用 A::fa 就稍微麻烦一些:
A* pa = reinterpret_cast<A*>((char*)(&c)+16);
这个故事告诉我们,一个基类可以在继承图中出现不止一次(所有虚继承算做一次),尽管这样产生的子类不能被正常使用
进一步测试
多看几个例子,总是好的
case 1
struct A {
int a;
virtual void fa() {}
};
struct B : virtual A {
int b;
};
struct C : A, B {
int c;
};
*** Dumping AST Record Layout
0 | struct C
0 | struct A (primary base)
0 | (A vtable pointer)
8 | int a
16 | struct B (base)
16 | (B vtable pointer)
24 | int b
28 | int c
32 | struct A (primary virtual base)
32 | (A vtable pointer)
40 | int a
| [sizeof=48, dsize=44, align=8,
| nvsize=32, nvalign=8]
Vtable for 'C' (11 entries).
0 | vbase_offset (32)
1 | offset_to_top (0)
2 | C RTTI
-- (A, 0) vtable address --
-- (C, 0) vtable address --
3 | void A::fa()
4 | vbase_offset (16)
5 | offset_to_top (-16)
6 | C RTTI
-- (B, 16) vtable address --
7 | vcall_offset (0)
8 | offset_to_top (-32)
9 | C RTTI
-- (A, 32) vtable address --
10 | void A::fa()
case 2
struct A {
int a;
virtual void fa() {}
};
struct B : virtual A {
int b;
virtual void fa() override {}
};
struct C : A, B {
int c;
};
*** Dumping AST Record Layout
0 | struct C
0 | struct A (primary base)
0 | (A vtable pointer)
8 | int a
16 | struct B (base)
16 | (B vtable pointer)
24 | int b
28 | int c
32 | struct A (primary virtual base)
32 | (A vtable pointer)
40 | int a
| [sizeof=48, dsize=44, align=8,
| nvsize=32, nvalign=8]
Vtable for 'C' (12 entries).
0 | vbase_offset (32)
1 | offset_to_top (0)
2 | C RTTI
-- (A, 0) vtable address --
-- (C, 0) vtable address --
3 | void A::fa()
4 | vbase_offset (16)
5 | offset_to_top (-16)
6 | C RTTI
-- (B, 16) vtable address --
7 | void B::fa()
8 | vcall_offset (-16)
9 | offset_to_top (-32)
10 | C RTTI
-- (A, 32) vtable address --
11 | void B::fa()
[this adjustment: 0 non-virtual, -24 vcall offset offset]
case 3
struct A {
int a;
virtual void fa_1() {}
virtual void fa_2() {}
virtual void fa_3() {}
};
struct B : virtual A {
int b;
virtual void fa_2() override {}
virtual void fb_1() {}
};
struct B2 : virtual A {
int b2;
};
struct C {
int c;
virtual void fc_1() {}
virtual void fc_2() {}
virtual void fc_3() {}
};
struct C2 {
int c2;
virtual void fc_3() override {}
};
struct D : virtual C2 {
int d;
virtual void fc_2() override {}
};
struct E : B, B2, D {
int e;
virtual void fe() {}
virtual void fa_3() override {}
virtual void fc_2() override {}
};
*** Dumping AST Record Layout
0 | struct E
0 | struct B (primary base)
0 | (B vtable pointer)
8 | int b
16 | struct B2 (base)
16 | (B2 vtable pointer)
24 | int b2
32 | struct D (base)
32 | (D vtable pointer)
40 | int d
44 | int e
48 | struct A (virtual base)
48 | (A vtable pointer)
56 | int a
80 | struct C (virtual base)
80 | (C vtable pointer)
88 | int c
64 | struct C2 (virtual base)
64 | (C2 vtable pointer)
72 | int c2
| [sizeof=96, dsize=92, align=8,
| nvsize=48, nvalign=8]
Vtable for 'E' (39 entries).
0 | vbase_offset (80)
1 | vbase_offset (64)
2 | vbase_offset (48)
3 | offset_to_top (0)
4 | E RTTI
-- (B, 0) vtable address --
-- (E, 0) vtable address --
5 | void B::fa_2()
6 | void B::fb_1()
7 | void E::fe()
8 | void E::fa_3()
9 | void E::fc_2()
10 | vbase_offset (32)
11 | offset_to_top (-16)
12 | E RTTI
-- (B2, 16) vtable address --
13 | vbase_offset (48)
14 | vbase_offset (32)
15 | offset_to_top (-32)
16 | E RTTI
-- (D, 32) vtable address --
17 | void E::fc_2()
[this adjustment: -32 non-virtual]
18 | vcall_offset (-48)
19 | vcall_offset (-48)
20 | vcall_offset (0)
21 | offset_to_top (-48)
22 | E RTTI
-- (A, 48) vtable address --
23 | void A::fa_1()
24 | void B::fa_2()
[this adjustment: 0 non-virtual, -32 vcall offset offset]
25 | void E::fa_3()
[this adjustment: 0 non-virtual, -40 vcall offset offset]
26 | vcall_offset (0)
27 | vbase_offset (16)
28 | offset_to_top (-64)
29 | E RTTI
-- (C2, 64) vtable address --
30 | void C2::fc_3()
31 | vcall_offset (-16)
32 | vcall_offset (-80)
33 | vcall_offset (0)
34 | offset_to_top (-80)
35 | E RTTI
-- (C, 80) vtable address --
36 | void C::fc_1()
37 | void E::fc_2()
[this adjustment: 0 non-virtual, -32 vcall offset offset]
38 | void C2::fc_3()
[this adjustment: 0 non-virtual, -40 vcall offset offset]
Itanium ABI 和 MSVC ABI 的一个区别
在 case 3 里,可以看到 [this adjustment: -32 non-virtual] 和 [this adjustment: 0 non-virtual, -32 vcall offset offset] 两种 this 指针的偏移方式,前者是固定偏移,后者是动态获取偏移量
可以通过反汇编来看一下 this 指针偏移的技术细节:
-
对于不需要偏移 this 指针的函数调用,在虚表的对应槽位放的就是函数的地址
-
对于固定偏移量的情形,在虚表的对应槽位放的是一个调用桩的地址,调用桩的逻辑是:先将 this 指针偏移某个定值(non-virtual),然后调用真正需要的函数
-
对于动态获取偏移量的情形,在虚表的对应槽位放的也是一个调用桩的地址,调用桩的逻辑是:先在虚表的某个由定量偏移(vcall offset offset)确定的槽位中读取真正需要的 this 指针偏移,然后根据它偏移 this 指针,最后调用真正需要的函数
但是,实际上这种区分并非必要,因为两种偏移本质没有区别。不难发现,虽然是动态获取偏移量,但其实获取的值不会改变,而且是编译期确定的值。那么,为什么不直接全部写死在调用桩里,而是要多此一举呢?
实际上,MSVC ABI 就没有多此一举,它的所有 this 指针偏移都在调用桩写死!
ChatGPT 告诉我,Itanium ABI 这样做有一些好处:
-
对动态链接友好:偏移量作为数据可由重定位决定,而不用在代码段里埋立即数。
-
构造/析构时的 construction vtable 也能使用同一套桩,只要提供合适的 vcall offset 填充值。
我对动态链接不是很熟悉,看起来第二点和这里关系比较大,它涉及到对象构造过程中的虚表。这并非 GPT 异想天开的 corner case,construction vtable 在 Itanium ABI 规范里确实有讨论: 2.6 Virtual tables During Object Construction
当然,我对所谓的 construction vtable 兴趣不是很大。能够搞懂正常的虚表,已经很满意了!
评论区
最新评论
--