C++ 虚函数表指针在多重继承下的内存偏移修正在汇编层的体现
理解核心问题
在 C++ 单一继承中,派生类对象的内存布局通常很简单:基类子对象放在起始位置,派生类新增的成员变量紧接其后。虚函数表指针(vptr)通常是对象的前 8 个字节(64 位平台)。多重继承打破了这个整齐的结构。
当你通过不同类型的指针调用同一个虚函数时,编译器在幕后做了 this 指针偏移修正。修正的目的是让函数体内通过 this 访问的成员变量指向正确的子对象起始地址。这个修正过程在汇编层清晰可见:一条 add 或 sub 指令调整 rcx(x86-64 下 this 存放在 rcx)的值,然后才跳转到真正的虚函数入口。
本文通过一个具体例子,逐层展示从 C++ 代码到汇编指令的转换,并解释偏移修正的原理。
1. 构建示例代码
先写一个简单的多重继承结构:两个纯虚基类 IB 和 IC,一个派生类 Derived 同时继承它们,并实现两个虚函数。重点关注 this 在不同指针类型下的差异。
// 文件: multiple_virtual.cpp
#include <cstdio>
class IB {
public:
virtual void foo() = 0;
virtual ~IB() = default;
int ib_data = 10;
};
class IC {
public:
virtual void bar() = 0;
virtual ~IC() = default;
int ic_data = 20;
};
class Derived : public IB, public IC {
public:
void foo() override {
printf("Derived::foo(), ib_data=%d\n", ib_data);
}
void bar() override {
printf("Derived::bar(), ic_data=%d\n", ic_data);
}
int derived_data = 30;
};
int main() {
Derived d;
IB* pb = &d;
IC* pc = &d;
pb->foo(); // 通过 IB 指针调用
pc->bar(); // 通过 IC 指针调用
return 0;
}
编译命令(无优化,保留符号):
g++ -g -O0 -fno-inline -S multiple_virtual.cpp -o multiple_virtual.s
得到汇编文件 multiple_virtual.s。本文使用 x86-64 AT&T 语法进行分析(-masm=att 默认)。如果你用 Intel 语法,调整指令顺序即可,逻辑相同。
2. 分析对象内存布局
先确定 Derived 对象在内存中的排列顺序。默认 GCC 布局:
- 基类 IB 子对象:包含虚表指针(指向
Derived的IB部分虚表)和成员ib_data(偏移 8 字节处)。 - 基类 IC 子对象:紧接其后,包含虚表指针(指向
Derived的IC部分虚表)和成员ic_data(偏移 8 字节处)。 - 派生类成员
derived_data:放在最后。
用表格表示(64 位平台,指针 8 字节,int 4 字节,但 GCC 会考虑对齐):
| 相对偏移 | 大小 | 内容 |
|---|---|---|
| 0 | 8 | IB 部分的 vptr |
| 8 | 4 | ib_data (值 10) |
| 12 | 4 | 填充(对齐到 8 字节) |
| 16 | 8 | IC 部分的 vptr |
| 24 | 4 | ic_data (值 20) |
| 28 | 4 | 填充 |
| 32 | 4 | derived_data (值 30) |
| 36 | 4 | 填充至 40 字节 |
因此,IB* 指针指向偏移 0,IC* 指针指向偏移 16。当你把 &d 赋值给 IC* 时,编译器自动添加了 16 字节偏移。
汇编层的关键点:在调用 pc->bar() 时,this 指针(rcx)指向的是 IC 子对象的起始地址(即 &d + 16)。函数 Derived::bar() 内部要访问 ic_data,但 ic_data 相对于当前 this(IC 子对象起始)的偏移是 8(因为 vptr 占据前 8 字节),所以编译后的代码直接用 [this+8] 即可。这没问题。
但如果通过 pc 调用了 Derived::foo()(虽然本例未发生),编译器就必须将 this 从 IC 子对象回退到 IB 子对象(即减去 16)。这就是 偏移修正 的核心场景。
3. 查看虚表结构
在 x86-64 GCC 中,每个虚表是一个函数指针数组,但多重继承时每个基类都有独立的虚表。GCC 使用 调整器(adjustor thunk) 技术来实现指针修正。
什么是 thunk? 当派生类重写了一个继承自非第一个基类的虚函数时,编译器会生成一个跳板函数(thunk)。thunk 负责调整 this 指针,然后跳转到真正的函数实现。这个 thunk 地址被填入对应基类的虚表槽位。
在我们的例子中,Derived 没有显式覆盖 IB::foo() 吗?它覆盖了 foo(),但 foo 来自 IB,而 IB 是第一个基类,所以不需要 thunk。但是对于 IC 的 bar(),Derived 也覆盖了它,且 IC 不是第一个基类,因此 IC 虚表中的 bar 槽位指向一个 thunk,而 thunk 再跳转到 Derived::bar()。
反过来如果通过 IB 指针调用 foo(),无需调整;通过 IC 指针调用 bar(),也无需调整,因为 bar() 属于 IC 子对象,this 已经“正确”。但如果通过 IC 指针调用 foo()(假如 IB 和 IC 都有同名虚函数,或者你用一个 IC* 调用了从另一条路径继承的虚函数),则必须修正。
我们修改代码让 IB 和 IC 都声明一个相同签名的虚函数,但名称不同不重要,关键是看 thunk 的生成。为了更直观,我们可以让 IC 也有一个 foo()(但名称冲突,只能用不同名字),更好的方式是通过 IC 指针调用一个本属于 IB 的函数?实际上不存在这种调用,因为 IC 指针只知道 IC 的接口。多继承的 thunk 通常发生在如下场景:派生类重写了一个虚函数,该函数同时来自两个基类(菱形继承)或来自非首基类。我们例子中 bar() 是 IC 特有的,但 Derived 重写了它,编译器仍然可以为 IC 的虚表槽生成 thunk?通常不需要,因为 Derived::bar() 可以直接放入 IC 虚表而不需要调整(this 已指向 IC 子对象)。但有些编译器(如 MSVC)采用不同的策略:它们会在每个虚表中放置指向同一份函数体的指针,但函数体内部通过偏移修正访问成员。GCC 则倾向于使用 thunk 来统一。
实际情况更复杂,我们以 GCC 生成的汇编来验证。
4. 汇编代码逐行分析(使用 GCC 输出)
打开 multiple_virtual.s,找到 main 函数的关键部分。以下摘录并添加注释:
main:
pushq %rbp
movq %rsp, %rbp
pushq %rbx
subq $56, %rsp # 局部变量空间
# 构造 Derived 对象 d(在栈上偏移 -48 处)
leaq -48(%rbp), %rax # rax = &d
movq %rax, %rdi
call _ZN7DerivedC1Ev # Derived::Derived()
# pb = &d (IB*)
leaq -48(%rbp), %rax
movq %rax, -24(%rbp) # 局部变量 pb 存放 &d(偏移 0)
# pc = &d 然后偏移到 IC 子对象
leaq -48(%rbp), %rax # rax = &d
addq $16, %rax # 调整到 IC 子对象起始(偏移 16)
movq %rax, -32(%rbp) # 局部变量 pc 存放 &d+16
# pb->foo()
movq -24(%rbp), %rax # rax = pb
movq (%rax), %rdx # rdx = vptr (虚表地址)
movq (%rdx), %rdx # rdx = 虚表第一个槽 (foo 地址)
movq -24(%rbp), %rdi # rdi = this (pb)
call *%rdx # 调用 foo
# pc->bar()
movq -32(%rbp), %rax # rax = pc
movq (%rax), %rdx # rdx = vptr (IC 虚表)
movq (%rdx), %rdx # rdx = 虚表第一个槽 (bar 地址)
movq -32(%rbp), %rdi # rdi = this (pc)
call *%rdx # 调用 bar
注意:GCC 在 call 之前把 this 通过 movq 传入 %rdi(System V ABI 的第一个参数寄存器)。这里 pb->foo() 和 pc->bar() 都直接把对应的指针原样传递,没有额外的调整。这意味着 GCC 生成的 foo 和 bar 函数体内部不需要 this 修正,因为:
foo函数期望 this 指向 IB 子对象(偏移 0),我们传入了pb,正确。bar函数期望 this 指向 IC 子对象(偏移 16),我们传入了pc,也正确。
但如果我们通过 IB* 调用一个被 Derived 重写的、原本属于 IC 的虚函数呢?这个例子中没有这样的函数。为了演示 thunk,我们强制让 IC 派生的某个函数被 IB 指针调用——这不可能,因为 IB* 只知道 IB 的接口。真正需要 thunk 的场景是:派生类重写了同时存在于多个基类中的虚函数(菱形继承),或者为了优化虚函数调用,编译器统一使用 thunk 将不同 this 调整为同一个函数入口。在我们的代码中,Derived::foo() 属于 IB 部分,Derived::bar() 属于 IC 部分,因此每种调用都可以直接使用对应子对象的 this。
那么 thunk 在哪里?让我们查看 Derived::bar() 的汇编:
_ZN7Derived3barEv: # Derived::bar()
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movq %rdi, -8(%rbp) # 将 this 保存到局部变量
movq -8(%rbp), %rax
movl 8(%rax), %eax # 访问 this->ic_data (偏移 8)
movl %eax, %edx
leaq .LC1(%rip), %rdi
xorl %eax, %eax
call printf
nop
leave
ret
```
函数体内访问 `ic_data` 使用的是 `[this+8]`,因为 this 是 IC 子对象的起始地址,而 `ic_data` 在 IC 子对象内的偏移是 8(vptr 占 8 字节)。所以 this 没有额外调整。
现在,如果我们试图通过 `IC*` 调用 `Derived::foo()`?IC 的接口没有 `foo`,所以不可能。为了展示 thunk,需要修改设计:
让 `IB` 和 `IC` 都包含一个同名虚函数,比如 `void g()`, 然后 `Derived` 重写 `g()`。此时两个基类的虚表中都需要有 `g` 的入口。对于 `IB` 部分,this 是对象起始;对于 `IC` 部分,this 需要偏移 16。为了共享同一份 `Derived::g()` 的实现,编译器会为 `IC` 的虚表槽生成一个 thunk,thunk 先 `subq $16, %rdi`,然后跳转到 `_ZN7Derived1gEv`。
我们快速修改代码验证:
```cpp
class IB {
public:
virtual void g() = 0;
// ...
};
class IC {
public:
virtual void g() = 0;
// ...
};
class Derived : public IB, public IC {
public:
void g() override {
printf("Derived::g()\n");
}
};
然后编译,查看汇编中是否有类似 _ZThn16_N7Derived1gEv 的符号(GCC thunk 命名规则:_ZThn<偏移>_<函数mangled名>)。
在生成的汇编文件中你会发现:
_ZThn16_N7Derived1gEv: # thunk for Derived::g(), this 调整 +16?
# 实际上这里的偏移是相对于对象起始的调整?
# 注意:thunk 名称中的 "Thn16" 表示 this 需要减去 16 才能指向对象起始?
# 不同编译器符号不同。GCC 的 thunk 通常是调整当前 this 到正确的子对象。
# 详细分析:
# 当我们通过 IC* 调用 g() 时,this 指向 IC 子对象(偏移 16),
# 而 Derived::g() 期望 this 指向完整对象起始(偏移 0),
# 所以 thunk 要减去 16。
leaq -16(%rdi), %rdi # this = rdi - 16
jmp _ZN7Derived1gEv # 跳转到实际函数
(注释:实际汇编因版本差异,但逻辑一致。)
这就是 内存偏移修正 在汇编层的直接体现:一条算术指令改变 rdi(或 rcx),然后跳转到真正的函数体。thunk 本身不分配栈帧,仅作指针修正和跳转。
5. 总结 thunk 的触发条件
- 非首基类的虚函数重写:派生类重写了一个虚函数,该虚函数在第二个及以后基类中定义。此时通过该基类指针调用时,需要 thunk 将 this 从子对象偏移调整回对象起始,或者反之(取决于编译器约定)。
- 菱形继承(虚拟继承):虚拟继承的虚函数表布局更复杂,可能需要多次跳转,但 thunk 原理相似。
- 调整器(adjustor)与转换 thunk:MSVC 使用
this指针直接在虚函数表槽位存储修正前的地址,但函数体内部通过ecx寄存器修正。GCC 倾向使用 thunk 外部修正。
6. 如何自己观察汇编
步骤:
- 编写触发 thunk 的代码:让两个基类具有同名虚函数,派生类重写它。
- 编译并生成汇编:
g++ -O0 -fno-inline -S test.cpp -o test.s - 搜索符号
ZThn:在汇编文件中查找_ZThn开头的标签,它们就是 thunk。 - 查看 thunk 指令:通常只有
subq/addq和jmp。 - 确认调用路径:找到
main中通过不同指针调用g()的汇编,查看调用目标是否跳转到 thunk。
7. 关键结论
- 多重继承的内存布局:每个基类子对象在派生类对象中按声明顺序排列,且每个子对象都有自己的
vptr。 - 指针调整发生在赋值和函数调用两个层面:将
&d赋值给IC*时会自动添加偏移;通过该指针调用虚函数时,虚表槽中的地址可能指向一个 thunk,thunk 修正this后跳转。 - 汇编层的直接证据:thunk 中的
leaq -16(%rdi), %rdi或addq $16, %rdi指令,清晰展示了偏移修正。 - 优化与 thunk 的关系:
-O2可能内联 thunk 或合并函数,但概念不变。开启优化后,thunk 仍然存在,只是可能被内联到调用点。
通过上述分解,你现在可以亲手编译代码,打开汇编文件,找到 thunk 并理解每一条指令的意义。这是深入 C++ 对象模型和 ABI 底层的一把钥匙。

暂无评论,快来抢沙发吧!