10.28

2025-10-28

周二

C++ 类的内存布局

本日记并非从零基础开始的教学,只是记录自己的思考,写成教学的体量将是本日记的数倍,什么时候有空了或许会改写…

初步探索

突然对发现自己还没学过 C++ 类的内存布局,于是试图学习

使用 clang 进行测试:

知道了这两个方法,就可以动手实践了!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();

如果调换 AB 作为基类的顺序,改成下面这样:

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 指针偏移的技术细节:

  1. 对于不需要偏移 this 指针的函数调用,在虚表的对应槽位放的就是函数的地址

  2. 对于固定偏移量的情形,在虚表的对应槽位放的是一个调用桩的地址,调用桩的逻辑是:先将 this 指针偏移某个定值(non-virtual),然后调用真正需要的函数

  3. 对于动态获取偏移量的情形,在虚表的对应槽位放的也是一个调用桩的地址,调用桩的逻辑是:先在虚表的某个由定量偏移(vcall offset offset)确定的槽位中读取真正需要的 this 指针偏移,然后根据它偏移 this 指针,最后调用真正需要的函数

但是,实际上这种区分并非必要,因为两种偏移本质没有区别。不难发现,虽然是动态获取偏移量,但其实获取的值不会改变,而且是编译期确定的值。那么,为什么不直接全部写死在调用桩里,而是要多此一举呢?

实际上,MSVC ABI 就没有多此一举,它的所有 this 指针偏移都在调用桩写死!

ChatGPT 告诉我,Itanium ABI 这样做有一些好处:

我对动态链接不是很熟悉,看起来第二点和这里关系比较大,它涉及到对象构造过程中的虚表。这并非 GPT 异想天开的 corner case,construction vtable 在 Itanium ABI 规范里确实有讨论: 2.6 Virtual tables During Object Construction

当然,我对所谓的 construction vtable 兴趣不是很大。能够搞懂正常的虚表,已经很满意了!

Footnotes

  1. 在学会这个方法前,我用 sizeof 和 offset 这种原始人方法折腾了很久,非常坐牢

  2. 虽然只是开了个头,没有坚持下去

最新评论

--