0%

C++ 对象模型实战(六):多继承——多 vptr、this 调整与 adjustor thunk

这一篇解决什么问题

单继承里基类永远舒舒服服躺在偏移 0。多继承打破了这份平静:两个基类不可能同时在偏移 0。这一篇回答:

  1. 多继承对象的内部怎么摆?
  2. 指向第二个基类的指针,和对象地址还相同吗?
  3. 通过第二个基类指针调虚函数,this 是怎么”拨回”派生类的?

多继承的三个要点

要点 1:基类子对象按声明顺序排列,每个含虚函数的基类带一份 vptr。
class C : public A, public B 的布局是:A 子对象(含 A 的 vptr)→ B 子对象(含 B 的 vptr)→ C 自己的成员。C 的对象里有两个 vptr,分别服务两张虚表视角。

要点 2:指向第二个基类的指针 ≠ 对象地址。
A* pa = &c 地址不变(A 在偏移 0);B* pb = &c 则是 &c + sizeof(A)——指针被编译器悄悄加了偏移。这个调整在指针转换时静态完成,和虚表无关。

要点 3:经第二个基类调用被重写的虚函数,需要把 this 调回去。
pb->fb() 时,pb 指向 B 子对象,但 C::fb 期望的 this 是完整的 C 对象(首地址)。差值就是 B 子对象的偏移。MSVC 的解法:C 的”B 视角虚表”里,fb 那一项存的不是 C::fb 本身,而是一小段调整桩(adjustor thunk):先把 this 减去偏移,再跳进 C::fb。在反汇编里能看到名字带 adjustor{16} 字样的跳转目标。

验证工程

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
// 多继承: 两个 vptr, 基类指针的地址偏移, this 调整 thunk, 成员函数指针.
#include <cstdio>

class A {
public:
virtual void fa() { printf("A::fa\n"); }
int a = 1;
};

class B {
public:
virtual void fb() { printf("B::fb\n"); }
int b = 2;
};

class C : public A, public B {
public:
void fa() override { printf("C::fa this=%p\n", (void*)this); }
void fb() override { printf("C::fb this=%p\n", (void*)this); } // 对比 this 与 &obj/pb, 验证 this 调整.
int c = 3;
};

int main() {
printf("sizeof(A)=%zu sizeof(B)=%zu sizeof(C)=%zu\n", sizeof(A), sizeof(B), sizeof(C));

C obj;
A* pa = &obj;
B* pb = &obj;
printf("&obj=%p\n", (void*)&obj);
printf("pa =%p (same as &obj)\n", (void*)pa); // 第一个基类在偏移 0, 与 &obj 相同.
printf("pb =%p (shifted from &obj)\n", (void*)pb); // 第二个基类子对象, 从 &obj 后移.

pa->fa();
pb->fb(); // 断点看反汇编: 经 B 的虚表跳到 adjustor thunk, 把 this 调回 C 再进 C::fb.

// 成员函数指针在多继承下不只是个裸地址, 还要携带 this 调整信息.
printf("sizeof(&C::fa)=%zu sizeof(plain func ptr)=%zu\n",
sizeof(&C::fa), sizeof(void(*)()));
}

预测输出

动手跑之前先推一遍:

  • A、B 各 16(vptr 8 + int 4 + padding 4);C = A 16 + B 16 + int 4 + padding 4 = 40。
  • pa == &obj;pb - pa = 16:B 子对象从 A 之后开始。
  • 输出 C::fa、C::fb:多态正常,只是 pb 那一路多走了一个 adjustor thunk。
  • 成员函数指针预测为 16 字节:多继承下它除了函数地址还要携带 this 调整量,比普通函数指针(8)大。这个数字各编译器差异很大,以实测为准。

实测输出

VS2022 x64 Debug 下实际运行:

1
2
3
4
5
6
7
sizeof(A)=16  sizeof(B)=16  sizeof(C)=40
&obj=000000A1894FFAD8
pa =000000A1894FFAD8 (same as &obj)
pb =000000A1894FFAE8 (shifted from &obj)
C::fa this=000000A1894FFAD8
C::fb this=000000A1894FFAD8
sizeof(&C::fa)=16 sizeof(plain func ptr)=8

逐行比对

  • sizeof 16/16/40:命中。
  • pa == &obj == ...FAD8:命中,第一个基类在偏移 0。
  • pb = ...FAE8,比 &obj 大 0x10(16):命中,B 子对象从 A 之后开始,指向 B 的指针被编译器悄悄加了 sizeof(A) 的偏移。
  • sizeof(&C::fa)=16,普通函数指针 8:命中,多继承下成员函数指针携带 this 调整信息,体积翻倍。

this 调整的实测验证

最后两行是这篇文章最直接的证据。C::fb 是经由 pb(指向 B 子对象,…FAE8)调用的,调用约定里 rcx 装的也是 pb;但函数体里打印出来的 this 却是 ...FAD8,和 &obj 完全一致——中间有人把 this 减了 16,这就是 adjustor thunk 干的活。对照组 C::fa 经 pa(第一基类,偏移 0)调用,this 本来就等于 &obj,不需要调整。

值得记录的一次弯路:我先在反汇编里单步找 thunk,调用点(mov rcx,[pb]; call [rax])和单继承长得一模一样,F11 跟进增量链接跳转表(ILT)后直接落到了 C::fb 函数体,没看到预想中的 sub rcx, 16,一度怀疑 thunk 不存在。最后靠”让函数自己打印 this”这个更直接的办法确认了调整确实发生——thunk 是编译器生成的一小段无名代码,夹在跳转表和函数体之间,单步时容易一眼掠过。教训:验证 this 调整,打印 this 比读汇编可靠。

用 /d1 让编译器自证

/d1 reportAllClassLayout 对 class C 的报告(去掉了开头的 1> 编译器前缀):

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
class C    size(40):
+---
0 | +--- (base class A)
0 | | {vfptr}
8 | | a
| | <alignment member> (size=4)
| +---
16 | +--- (base class B)
16 | | {vfptr}
24 | | b
| | <alignment member> (size=4)
| +---
32 | c
| <alignment member> (size=4)
+---

C::$vftable@A@:
| &C_meta
| 0
0 | &C::fa

C::$vftable@B@:
| -16
0 | &C::fb

C::fa this adjustor: 0
C::fb this adjustor: 16

布局部分和运行输出逐项对上:A 子对象在偏移 0(含一份 vfptr),B 子对象在偏移 16(含第二份 vfptr),c 在 32,总共 40。

最有说服力的是最后两行:C::fa this adjustor: 0、C::fb this adjustor: 16——编译器白纸黑字写明了:经 B 视角调 fb,this 要调整 16 字节。这就是 adjustor thunk 存在的官方自证。

还有一个容易漏看的细节:C::$vftable@B@ 的表头是 -16(A 视角那张是 0)。表头这一项记录的是”这个子对象离完整对象开头的偏移”,RTTI 和 dynamic_cast 靠它从基类子对象找回完整对象——上一篇结尾说 dynamic_cast 需要虚函数支持,机制上就落在这里。

小结

1
2
3
4
多继承布局: [A 子对象(vptr1)][B 子对象(vptr2)][C 成员]
A* pa = &c: 地址不变; B* pb = &c: 地址 + sizeof(A)
pb->fb(): B 视角虚表的表项是 adjustor thunk, 先 sub this 再进 C::fb
成员函数指针: 多继承下不是裸地址, 含 this 调整信息