先别急着翻 ABI 文档背那张 vtable 布局表,背了也记不牢。我们直接写一段最朴素的两个基类的多继承,把地址打到屏幕上,让代码自己把那个反常的现象摔到你面前。这个反常一路拆下去,会拆到 ABI 层、拆到 .text 里一段你平时根本不会去看的跳板代码——拆完你手里会多一条判断:多继承里 this 的回拨,既不在指针转换那一瞬、也不在虚调用那一瞬,它被编译器推迟到了 callee 侧一个叫 thunk 的跳板里。
1. 一个藏不住的反常:指针转换出来的不是同一个值
最素的两个带虚函数的基类,派生类各覆盖一个 who:
#include<cstdio>
struct Base1 { int b1=1; virtual void who(); virtual ~Base1()=default; };
struct Base2 { int b2=2; virtual void who(); virtual ~Base2()=default; };
struct Derived : Base1, Base2 {
int d=3;
__attribute__((noinline)) void who() override{
printf("Derived::who this=%p d=%d\n",(void*)this,d);
}
};
void Base1::who(){ printf("Base1::who this=%p\n",(void*)this); }
void Base2::who(){ printf("Base2::who this=%p\n",(void*)this); }
int main(){
Derived obj; auto* d=&obj; auto* b2=static_cast<Base2*>(d);
printf("Derived*=%p Base2*=%p off=%ld\n",(void*)d,(void*)b2,(long)((char*)b2-(char*)d));
b2->who();
}
我把 Derived::who 标了 noinline,目的是别让优化器在后面把事情搅浑,这一点后面第 4 节会回头讲。先看结果,这是我在一台 arm64 的 Mac 上交叉编到 x86-64 跑出来的真实输出(地址每次运行会变,数量关系不变):
Derived*=0x16bcee7d0 Base2*=0x16bcee7e0 off=16
Derived::who this=0x16bcee7d0 d=3
现象就两条,条条反常。第一条,Derived* 和 Base2* 不是同一个值,后者比前者大了 16 字节——一次 static_cast 居然改了指针的数值。第二条更怪:b2 明明指向 0x16bcee7e0,可进了 Derived::who 之后,this 又变回了 0x16bcee7d0,也就是完整 Derived 对象的起点。谁偷偷把它调回来的?调用点那一行 b2->who() 看上去平淡无奇,它根本不知道 obj 的动态类型是 Derived,它凭什么能动 this?这就是我们要拆的谜题。
先用 clang 的 -Xclang -fdump-record-layouts 把对象布局打出来,这是真实输出,我只裁了跟我们的类相关的几行:
0 | struct Base1
0 | (Base1 vtable pointer)
8 | int b1
| [sizeof=16, dsize=12, align=8]
0 | struct Base2
0 | (Base2 vtable pointer)
8 | int b2
| [sizeof=16, dsize=12, align=8]
0 | struct Derived
0 | struct Base1 (primary base)
0 | (Base1 vtable pointer)
8 | int b1
16 | struct Base2 (base)
16 | (Base2 vtable pointer)
24 | int b2
28 | int d
| [sizeof=32, dsize=32]
Base1 是主基类(primary base),它跟完整 Derived 共享起点,所以 Base1* 和 Derived* 数值相同。Base2 是非主基类,被排在偏移 16 处。这里有个细节值得一嘴:d 落在偏移 28,恰好复用了 Base2 子对象的尾部填充——单独看 Base2 时那 4 字节是 padding,塞进 Derived 后被 d 吃掉了。所以 sizeof(Derived) 是 32 而不是 36,C++ 标准允许基类子对象在派生类里被这样“压缩尾填充”,这是空基类优化之外的另一处省内存的地方。
把布局画成图,一目了然:
Derived 对象 (sizeof=32)
偏移 0 8 12-15 16 24 28-31
┌───────────┬──────┬───────┬───────────┬──────┬───────┐
│ Base1 vptr │ b1 │ pad │ Base2 vptr │ b2 │ d │
├───────────┴──────┴───────┴───────────┴──────┴───────┤
│<─────── Base1 子对象 ───────>│<── Base2 子对象 ──>│ │
│ │ │ │
↑ d = Derived* ↑ b2 = Base2* (off +16)
static_cast<Base2*>(d) 干的事,就是把指针从偏移 0 挪到偏移 16,好让 b2 正确指向 Base2 子对象——只有这样,b2->b2(访问成员)和 Base2 的虚表查找才对得上号。这一步加 16,是编译期就定的死偏移。
2. 转换点加的是常量,调用点却装作看不见
到此第一层矛盾已经摆出来了:转换点加了一个编译期已知的 +16,但虚调用经过 Base2 时,真正被调的 Derived::who 需要的是完整 Derived 对象的起点指针,不是 Base2 子对象指针。Derived::who 里访问 d 是按 28(%rdi) 取的——这里的 28 是相对完整对象起点的偏移。如果传进去的 this 是 Base2 子对象地址(完整对象 +16),那 28(%rdi) 就会取到 完整对象+16+28 = +44,直接越界读。所以 Derived::who 必须拿到一个回拨过 16 的 this。
现在问题来了:这个回拨能在哪做?候选只有三个位置。
第一个,转换点。static_cast<Base2*> 那里能不能顺便记一笔“将来虚调用要减回去”?不行。转换点只产生一个 Base2 指针,这个指针之后会被传来传去、存进容器、跨函数边界,转换点根本控制不了下游怎么用它。
第二个,调用点。b2->who() 这一行能不能在这里减?也不行。调用点的静态类型是 Base2*,编译器在这里不知道、按规则也不该知道这个指针真正指向的是 Derived 还是别的什么派生类。它连该减多少都不知道——不同派生类里 Base2 的偏移可能不同。在调用点做死偏移调整,等于把动态类型信息硬塞进静态类型,这正是虚函数要消灭的东西。
第三个,callee 侧。真正知道“我是 Derived、Base2 在我体内偏移 16”的,是 Derived 自己。那就让 Derived 来调。但 Derived::who 的函数体是用户写的,不能让编译器去改用户的代码。于是编译器在用户函数外面包一层:这层先把 this 减 16 调到完整对象,再跳进真正的 Derived::who。这层包装,就是 thunk。
一句话总结到这里:转换点做的是静态加偏移(让指针对上子对象),回拨做的是 callee 侧的 thunk 减偏移(让 this 对上完整对象)。两者方向相反、位置不同、一个编译期一个跨函数边界。调用点夹在中间,什么都不做,也不该做。
3. thunk 在哪生成:二级虚表的槽位里藏着一个跳板
那这个 thunk 到底在哪?我交叉编到 x86-64,用 -S 出汇编,grep 一下符号,它就现形了。先看 Derived 相关的几个全局符号(macOS 的 Mach-O 会给每个符号前面多加一个下划线,所以下面 __Z 其实是 ABI 名 _Z 加一个平台前缀,看的时候心里去掉一个 _ 即可):
.globl __ZN7Derived3whoEv # Derived::who 真身
.globl __ZThn16_N7Derived3whoEv # ← thunk
.globl __ZThn16_N7DerivedD1Ev # ← 析构 thunk(complete-object dtor)
.globl __ZThn16_N7DerivedD0Ev # ← 析构 thunk(deleting dtor)
_ZThn16_N7Derived3whoEv 就是我们要找的 thunk。拆一下这个名字:_Z 是 Itanium 改名的统一前缀;Th 标识这是个 thunk(special name);n16 是一个带符号数,n 是负号,所以是 −16;后面 _N7Derived3whoEv 是被包装的目标函数 Derived::who。整句翻译:这是一个非虚 thunk,把 this 加上 −16(即减 16),然后进 Derived::who。
再看 .rodata 里那张虚表,我把 Derived 相关的连续几个 .quad 贴出来:
.quad __ZN7Derived3whoEv # primary 虚表(经 Base1) 的 who 槽 → 真身
.quad __ZThn16_N7Derived3whoEv # secondary 虚表(经 Base2) 的 who 槽 → THUNK
.quad __ZThn16_N7DerivedD1Ev
.quad __ZThn16_N7DerivedD0Ev
这就是答案。Derived 有两张虚表:主虚表对应 Base1 子对象(和完整对象共享起点),非主虚表对应 Base2 子对象。主虚表的 who 槽里放的是 Derived::who 的真身地址;非主虚表的 who 槽里放的不是真身,而是 thunk 的地址。经 Base2 走的虚调用,取的就是 thunk。
thunk 的函数体长什么样?这段汇编是上面那段代码在 -O2、noinline 下真实编出来的,骨架就两行:
__ZThn16_N7Derived3whoEv:
pushq %rbp
movq %rsp, %rbp
addq $-16, %rdi # this -= 16 :Base2 子对象 → 完整 Derived
popq %rbp
jmp __ZN7Derived3whoEv # 尾跳进真函数(TAILCALL)
addq $-16, %rdi——把第一个参数寄存器 rdi(x86-64 的 this 寄存器)减 16,然后 jmp 尾跳进真函数。注意是 jmp 不是 call,thunk 不另开栈帧、不留返回地址,像根透明的转接头把 this 扭一下就钻进真函数,调用者完全感知不到中间过过一道手。
回到标题里那个“在哪生成”的问题。thunk 是编译器在发射 Derived 的非主虚表时一并生成的代码,和虚表本体在同一个翻译单元、走同样的 vague linkage(COMDAT,由 key function 触发弱符号合并)。它落在 .text,名字带 Th/Tv 前缀,nm 或 objdump 都看得到。所以“thunk 在哪生成”的精确回答是:不在调用点、不在转换点,在 Derived 的非主虚表被实例化的那个翻译单元里,编译器顺手生成的。析构函数也一样吃 thunk——上面那两个 _ZThn16_N7DerivedD1Ev/D0Ev 就是给 delete b2 用的,因为通过 Base2 指针析构同样得先把 this 回拨到完整对象,否则 operator delete 拿到的尺寸和起始地址都是错的。
这里值得配一个反面假设来坐实 thunk 不是优化、而是正确性必需。假设编译器不生成thunk,非主虚表的 who 槽直接放 Derived::who 真身地址,会发生什么?b2->who() 取出真身地址、把 this=b2(完整对象 +16)原样传进去。Derived::who 按 28(%rdi) 取 d,实际取到 b2+28 = 完整对象+44,越过 sizeof=32 的对象读到了隔壁栈变量的字节。d 打印出来是个随机值,更糟的成员访问直接段错误。也就是说,没有 thunk,多继承的虚函数覆盖在通过非主基类调用时语义直接崩。thunk 是 ABI 为多继承虚覆盖兜底的硬通货,不是可有可无的性能装饰。
4. 调用点对 thunk 一无所知:这才是设计的漂亮之处
把镜头切到调用点,看 b2->who() 那行编成了什么。这是 main 里真实的几行(rbx 装的就是 b2):
movq -24(%rbp), %rax # 取出函数指针(放谁由虚表槽决定)
movq %rbx, %rdi # this = b2 = Base2 子对象地址,没调!
callq *(%rax) # 间接调用
请把这两行看仔细了。movq %rbx, %rdi——传给函数的 this,就是那个没有回拨过的 Base2 子对象地址。调用点根本没做任何调整,它甚至不知道这个 this 该不该调、该调多少。它只是规规矩矩地:从对象头取 vptr、从 vptr 指向的虚表槽取一个函数指针、把这个对象指针原样塞进 rdi、call。至于取出来的函数指针是 Derived::who 真身还是 thunk,调用点一概不关心——那是由对象里 vptr 指向哪张子表决定的。
这就是这套 ABI 最漂亮的一点,值得单独拎出来说。经 Base1 走的虚调用,对象 vptr 指向主虚表,who 槽是真身地址,调用点 call 进去,this 本来就是完整对象,皆大欢喜。经 Base2 走的虚调用,对象 vptr(Base2 子对象头那个 vptr)指向非主虚表,who 槽是 thunk 地址,调用点 call 进 thunk,thunk 减 16 再跳真身。两条路径的调用点汇编一模一样,差异全藏在“vptr 指向哪张虚表、那张虚表的槽里放了谁”这两件事里。
所以标题里那个“虚调用经过 Base2 时 this 如何被 back-adjust”,精确答案是这样的:back-adjust 不是调用点做的,是调用点 call 出去之后、在 callee 侧的 thunk 里做的,发生在 vcall 取出函数指针之后、进入真 Derived::who 之前的那一步。从调用点看,它取到的“函数指针”压根就是 thunk——它没有“先取到真函数再回头调 this”这个动作,那个顺序是错的。thunk 和真函数在虚表槽这一层是同一个角色,调用点无差别地 call 它们。
讲到这里,我得对前面埋的一个扣做个交代,否则你会被我误导。第 1 节那段代码我特意给 Derived::who 加了 noinline,原因是:在 -O2 下,如果编译器能内联 Derived::who,它会干脆把 thunk 内联成一个特化版本,把偏移折进成员访问里。我在没加 noinline 时的 -O2 汇编里,thunk 的身影是这样的:
__ZThn16_N7Derived3whoEv:
...
movl 12(%rdi), %edx # 直接从 b2+12 取 d(= 完整对象 28 − 16)
leaq -16(%rdi), %rsi # 只为打印 this 现算一次完整对象地址
...
看明白了吗?优化器发现 thunk 只是减 16 再进函数,干脆把“减 16”这件事消解掉了——d 在完整对象里是 28(%rdi),而在 Base2 子对象视角下它就是 12(%rdi)(28−16),直接从这里取,连 sub 都省了,独立的 thunk 函数在这个 TU 里根本不生成。这是优化器的合法把戏,也恰恰说明 thunk 的本质就是“把 this 换个视角”,优化器有权在能内联时把它摊平。
但别高兴太早,这有一个硬边界:内联只发生在调用点静态类型已知、且函数体在同一 TU 可见时。一旦你是经一个真正的 Base2*(指向堆上某派生对象、跨翻译单元、虚函数 out-of-line,也就是推理引擎里算子调度那种真虚调用场景),调用点不知道动态类型、函数体也看不到,优化器没法内联——b2->who() 这一步会老老实实 call 进那张独立的 thunk 函数,一个 jmp 都省不掉。所以“-O2 下 thunk 会被消掉”这句话,必须带“仅当调用点可内联”这个前提,否则就是误导。这是这套机制最容易被讲飞的一点,专家面试里也容易在这里被追问。
5. 换成虚继承:静态偏移不够了,thunk 得去虚表里读一个运行时数字
到这里,非虚继承的 this 调整已经讲透了:thunk 烧一个 −16 的死偏移,addq $-16, %rdi 一了百了。可一旦 Base2 是虚基类(virtual base),这套死偏移立刻失灵。原因很硬:同一个虚基,在不同派生类里的位置不一样,而且这个位置要等运行时拿到对象、按 vptr 查虚表才知道,编译期定不下来。换一个派生类,thunk 里那个 −16 就该是别的数了,但你同一个 thunk 符号不能变。
所以 Itanium ABI 给虚基换了一套虚 thunk(Tv)。我写了个最素的菱形虚继承来看它:
struct VB { virtual void hi(); virtual ~VB()=default; int x=7; };
struct Left : virtual VB { int l=1; };
struct Right : virtual VB { int r=2; };
struct Diamond : Left, Right {
int dm=9;
__attribute__((noinline)) void hi() override{
printf("Diamond::hi this=%p x=%d\n",(void*)this,x);
}
};
int main(){ Diamond obj; VB* vb=&obj; printf("Diamond*=%p VB*=%p\n",(void*)&obj,(void*)vb); vb->hi(); }
真实汇编里的虚 thunk 长这样:
__ZTv0_n24_N7Diamond2hiEv: # 虚 thunk
pushq %rbp
movq %rsp, %rbp
movq (%rdi), %rax # rax = *this = vptr(虚基子对象的虚表地址点)
addq -24(%rax), %rdi # this += 虚表[-3] ← 运行时从虚表读 vcall offset
popq %rbp
jmp __ZN7Diamond2hiEv # 尾跳真函数
名字 _ZTv0_n24_N7Diamond2hiEv:Tv 标识虚 thunk;0 是 vcall offset 的索引;n24 是这个 vcall offset 槽相对虚表地址点的字节偏移(−24,即地址点往前数第三个槽)。和 Thn 对照着看就明白了:
非虚 thunk Thn16 : addq $-16, %rdi # 偏移烧进指令,编译期常量
虚 thunk Tv… : movq (%rdi),%rax
addq -24(%rax), %rdi # 偏移放进虚表一个槽,运行时现读
非虚 thunk 把偏移烧进指令,虚 thunk 把偏移放进虚表的一个槽(vcall offset),运行时去读。这一句是两种继承在 ABI 层最本质的差别。代价也直接:虚继承的虚调用比非虚继承多一次内存读(那条 movq (%rdi), %rax),而且这次读还可能 cache miss。虚基子对象的虚表里,地址点之前那几个负索引槽存的就是这套动态偏移信息——vbase offset 用来从派生对象找到虚基子对象,vcall offset 用来在虚覆盖时把 this 从虚基子对象拨到完整派生对象。
运行结果印证它确实在运行时算对了:
Diamond*=0x16f1167c0 VB*=0x16f1167e0
Diamond::hi this=0x16f1167c0 x=7
VB* 比 Diamond* 大 32(这次偏移更大,因为虚基排在两个非虚基子对象之后还要对齐),进了 Diamond::hi 之后 this 又被拨回了 0x16f1167c0——这次不是烧死的 −16,是虚 thunk 从虚表里现读出来的数。
虚继承还有个更深的层,这里只点一句、不当主线展开:对象构造期间用的虚表跟构造完成后用的不是同一张。构造函数执行时,虚基还没完全就位,vptr 指向的是一套“构造虚表”(construction vtable,由 VTT 驱动),里头的偏移语义和成品虚表不一样。这套机制保证基类构造函数里调虚函数时看到的是基类自己的版本,而不是派生类覆盖后的——这是 C++“构造期虚函数不下降到派生类”语义在 ABI 层的落点。这一层见过即可,真要用到再回头查 Itanium ABI 文档的 construction vtable 一节,不必现在死记。
6. 这套 thunk 的代价,以及今天还该不该让多继承虚函数上桌
讲透了机制,该给这套东西算成本了。我前面通篇在讲 thunk 怎么工作,但你别误以为我在推荐“多继承加虚函数覆盖”这种写法。恰恰相反,thunk 这套东西本质上是你用了多继承虚覆盖之后,ABI 被迫给你的对象和调用打的一串补丁,每一个补丁都有代价。
第一笔代价是间接分支。真虚调用经非主虚表走 thunk,意味着一次虚调用变成了两次跳转:call 进 thunk、thunk 再 jmp 进真函数。这条额外的 jmp 在流水线上是间接分支预测的压力,BTB 命不中就是一次气泡。虚继承更狠,多一次 movq (%rdi) 的虚表内存读,cache miss 一次代价就上去了。第二笔代价是空间:每个非主基类都派生一张 secondary 虚表,.rodata 里多占几个 .quad,符号表里多一堆 _ZThn*/_ZTv*,调试和二进制体积都跟着膨胀——前面那段 grep 已经能看到一屏的 thunk 符号了。
但这里有个最容易被讲飞的点,我必须给自己打个脸式地讲清楚:“多继承有 thunk 开销”这句话不能笼统说。开销只发生在经非主基类指针的真虚调用上。如果你拿 Derived* 直接调 d->who(),走的是主虚表,who 槽就是真身地址,不经 thunk;调用点静态类型足够明确时,编译器甚至能 devirtualize 掉整个虚调用,连 call 都变直接 call。thunk 的开销,只咬那些“把对象塞进 Base2* 容器、之后从容器取出来通过 Base2 指针派发”的真虚调用路径。笼统说“多继承慢”,是把有条件的事讲成了无条件,专家评审里这种话会被一句“你 devirtualize 了吗”问回去。
这就引出一个我真正想留下的工程判断。推理引擎里那种算子调度,常见有人把一个算子写成 class FooOp : public OpBase, public IObservable 两个都带虚函数的多继承,然后把 FooOp* 塞进 vector<IObservable*> 之后通过 IObservable 指针派发 on_event。我咬过这个坑:profile 里这条派发路径吃了一记 thunk + 间接预测失败的组合拳,排在火焰图上一个不起眼但稳定的台阶。我的修法不是去调编译器开关,而是把 IObservable 这个基类的虚函数改成纯非虚接口(抽象基类不占主对象布局的另一种写法是把那组回调设计成 CRTP 或者干脆一组 std::function),去掉一个非主虚表、一个 thunk 家族,派发路径回到单 vtable。这是个老实话:thunk 的代价在 hot path 上是真金白银,多继承虚覆盖上不上桌,取决于那条经非主基类指针派发的路径是不是真的 hot。不是 hot path,留着无妨;是 hot path,能少一个带虚函数的基类就少一个。
再交代一句时代性,免得你以为这是上古 ABI 还在影响今天。C++20/23 没有动 Itanium 这套 vtable + thunk 的布局——ABI 是稳定承诺,动了就所有旧二进制得重编,没人敢动。C++23 的 deducing this 也不触及“this 跨基类回拨”这一层,它解决的是显式接收 this 的写法,不是多继承布局。所以这篇讲的所有机制,今天、可预见未来都成立,不存在“标准已经给了更好替代”的情况。真要说“今天还该不该这么写”,答案不在标准演进里,在上面那句 hot path 判断里。
小结
把全篇收成几条能背的:
- 指针转换
Derived* → Base2* 在转换点加一个编译期常量偏移,让指针对上非主子对象;这是 ABI 静态能算的事。
- 虚调用经过 Base2 时的 back-adjust 不在转换点、不在调用点,在 callee 侧的 thunk 里——非主虚表槽放的是 thunk 地址,调用点取到的“函数指针”本来就是 thunk。
- 非虚 thunk(
Thn)把偏移烧进指令(addq $-16, %rdi; jmp);虚继承的虚 thunk(Tv)把偏移放进虚表一个槽(vcall offset),运行时 movq (%rdi),%rax; addq -24(%rax),%rdi 现读——这是两种继承在 ABI 层的本质差别。
- 调用点汇编对主虚表 / 非主虚表完全一致,差异全在 vptr 指向哪张子表、那张表的槽里放了真身还是 thunk。这套设计把多态做到了 base-agnostic。
- thunk 的代价咬的是经非主基类指针的真虚调用路径;调用点静态类型明确时可被 devirtualize 或内联摊平,但跨 TU 的真虚调用躲不掉一个
jmp。
多继承的 this 回拨,本质不是“在某处调一个偏移”,而是“编译器在你写的函数外面套了一层对齐器,把静态视角的子对象指针翻译成动态视角的完整对象指针”。这层对齐器叫 thunk,它住在非主虚表被实例化的那个翻译单元里。想看清它,就别盯着 static_cast 那一行,去 nm 里找带 Th 和 Tv 的符号,去 .text 里看那两行 addq 和 jmp——它们才是多继承在你二进制里留下的真指纹。反复在 Compiler Explorer 上把同一个继承结构从 -O0 拨到 -O2、把 noinline 加上再去掉,看 thunk 怎么生成、怎么被摊平,这是把这套机制吃进骨头里的唯一办法。
声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。
想继续深挖 C++ 底层细节?欢迎来云栈社区和更多开发者一起拆解这些硬货。