Itanium C++ ABI中虚表的工作原理
摘要
一篇详细的博客文章,解释了Itanium C++ ABI中虚表(vtable)的实现方式,涵盖虚表结构、修饰名称和虚函数调度。
<p><a href="https://lobste.rs/s/kzslib/how_virtual_tables_work_itanium_c_abi">评论</a></p>
查看缓存全文
缓存时间: 2026/05/26 13:20
# Itanium C++ ABI 中虚函数表的工作原理
来源:https://peter0x44.github.io/posts/vtables-itanium-abi/
虚函数是 C++ 的核心特性之一,实现了运行时多态。大多数 C++ 程序员经常使用它,却很少有人真正了解它在底层是如何工作的。当你将函数声明为 `virtual` 时,编译器实际生成了什么?程序如何在运行时确定调用哪个实现?虚函数表数据又存储在何处?这篇博客将聚焦于解答这些问题。
C++ 标准规定了行为而非实现。本文描述的是 **Itanium C++ ABI**(https://itanium-cxx-abi.github.io/cxx-abi/abi.html),该 ABI 被大多数平台采用(值得注意的例外是 Microsoft MSVC)。
## 一个简单的示例
考虑以下带有虚函数的类:
```cpp
struct Base {
virtual void foo() { __builtin_printf("Base::foo\n"); }
virtual void bar() { __builtin_printf("Base::bar\n"); }
virtual ~Base() {}
};
struct Derived : Base {
void foo() override { __builtin_printf("Derived::foo\n"); }
};
void call_foo(Base* b) {
b->foo(); // 实际调用哪个 foo()?
}
int main() {
Base base;
Derived derived;
call_foo(&base); // 应调用 Base::foo
call_foo(&derived); // 应调用 Derived::foo
}
```
编译器在编译时并不知道 `call_foo()` 中 `b` 指向的具体类型。该函数需要根据对象的实际运行时类型,将调用分发到正确的 `foo()` 实现。这正是虚函数表(vtable)所做的事情。
## 虚函数表结构
我们来看看虚表的真实面貌。GCC 提供了一个有用的选项 `-fdump-lang-class`,可以输出类及其虚表的布局:
```bash
g++ -fdump-lang-class example.cpp
```
GCC 会生成一个名为 `a-example.cpp.001l.class` 的文件,其中包含类和虚表的布局信息。Clang 也能输出类似信息,不过接口不同:
```bash
clang++ -Xclang -fdump-record-layouts -Xclang -fdump-vtable-layouts example.cpp
```
(我是从《使用 Clang 转储 C++ 对象的内存布局》这篇文章中学到这个技巧的:https://eli.thegreenplace.net/2012/12/17/dumping-a-c-objects-memory-layout-with-clang)
我个人觉得 GCC 的输出格式更友好一些,因此下面引用的输出均来自 `gcc -fdump-lang-class`。
对于我们的 `Base` 类,GCC 输出如下:
```
Vtable for Base
Base::_ZTV4Base: 6 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI4Base)
16 (int (*)(...))Base::foo
24 (int (*)(...))Base::bar
32 (int (*)(...))Base::~Base
40 (int (*)(...))Base::~Base
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x23116c0) 0
vptr=((& Base::_ZTV4Base) + 16)
```
让我们逐一分析这些内容。
### 名称修饰(Mangled Names)
在查看条目之前,先注意符号名 `_ZTV4Base`。这是虚函数表的 **修饰名称**。
- `_Z` 前缀表示 Itanium 名称修饰
- `T` 是一个特殊名称前缀(用于虚表、类型信息、thunk 等编译器生成的符号)
- `V` 表示虚表(`I` 表示类型信息)
- `4` 是后续名称的长度
- `Base` 是类名
类似地:
- `_ZTV7Derived` = Derived 的虚表
- `_ZTI4Base` = Base 的类型信息
### 虚表布局
名为 `_ZTV4Base` 的虚表共有 6 个条目,每个条目在 64 位系统上间隔 8 字节。结构如下:
```
偏移 0: offset-to-top = 0
偏移 8: typeinfo 指针 = &_ZTI4Base
偏移 16: foo() = &Base::foo
偏移 24: bar() = &Base::bar
偏移 32: ~Base() = &Base::~Base (D1)(完整对象析构函数)
偏移 40: ~Base() = &Base::~Base (D0)(删除析构函数)
```
### 虚函数指针
类中声明的每个虚函数在虚表中都有一个条目,顺序与声明顺序一致。`Base` 中声明的函数有 `foo()`、`bar()` 和析构函数。
在 Itanium ABI 中,析构函数在虚表中占据两个条目:
- **完整对象析构函数(D1)**(偏移 32):销毁对象但不释放内存。用于栈对象、成员变量和数组元素。
- **删除析构函数(D0)**(偏移 40):先调用完整析构函数(D1),然后调用 `operator delete` 释放内存。用于 `delete ptr` 语句。
还有第三种变体 **基类对象析构函数(D2)**,它不出现在虚表中,仅由派生类的析构函数直接调用。在讨论虚继承时我们会看到它为何存在。
### 偏移到顶部(Offset-to-Top)
该字段记录了虚表指针(vptr)距离完整对象起始位置的偏移量。在单继承中,其值为零,因此看起来没什么意思。但在多重继承中它就变得重要了。
### RTTI 类型信息指针
类型信息指针用于运行时类型识别(RTTI)。它指向该类的 `std::type_info` 对象,供 `dynamic_cast`、`typeid` 和异常处理使用。如果使用了 `-fno-rtti`,该字段为 null。
## 虚表存放在哪里?
虚表是静态数据结构,位于二进制文件的 `.rodata` 段中。但具体由哪个翻译单元生成它呢?Itanium ABI 采用了 **关键函数(key function)规则**:虚表由第一个非内联虚函数所在的翻译单元生成。
```cpp
// base.h
struct Base {
virtual void foo() { /* ... */ }; // 内联,不是关键函数
virtual void bar(); // 非内联 - 这是关键函数
virtual ~Base();
};
// base.cpp
void Base::bar() { /* ... */ } // 关键函数 - 虚表将在此翻译单元中
// Base 的虚表在 base.cpp 中生成
```
这条规则是常见链接错误的根源:
```
undefined reference to `vtable for Base'
```
当关键函数被声明但未定义时就会发生此错误。如果所有虚函数都是内联的,则不存在关键函数。此时虚表将以弱链接(weak linkage)的形式出现在每个使用该类的翻译单元中。
## 对象布局:虚表指针(VPtr)
每个拥有虚函数的对象都包含一个隐藏成员,称为 **虚表指针**(vptr)。在这个简单的单继承例子中,它位于对象偏移 0 处。虚函数调用通过读取 `*(void**)this` 来找到虚表。在多重继承中会有多个虚表指针(每个基类一个),后续会介绍。
```
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x22606c0) 0
nearly-empty vptr=((& Base::_ZTV4Base) + 16)
```
注意,虚表指针并不指向虚表起始位置,而是指向虚表的 **地址点(address point)**。在此例中,它指向虚表中的第一个函数条目(偏移 +16)。类型信息指针和 offset-to-top 位于地址点的负偏移处。可以这样理解,编译器将以下代码:
```cpp
struct Base {
virtual void foo();
int x;
};
```
转换成了类似这样的结构:
```cpp
struct Base {
void** __vptr; // 指向虚表
int x;
};
```
## 继承:虚表扩展
当派生类重写虚函数时,它会基于基类布局获得自己的虚表:
```cpp
struct Derived : Base {
void foo() override;
virtual void baz();
};
```
```
vtable for Derived:
[offset-to-top] = 0
[typeinfo] = &typeinfo for Derived
[foo()] = &Derived::foo (已重写)
[bar()] = &Base::bar (继承)
[~Derived()] = &Derived::~Derived (完整对象析构函数)
[~Derived()] = &Derived::~Derived (删除析构函数)
[baz()] = &Derived::baz (新虚函数)
```
继承来的槽位保持相同的位置。偏移 0 处的函数仍然是 `foo()`,只是指向了不同的实现。因此 `foo()` 被替换为 `Derived::foo`,`bar()` 仍指向基类实现,`baz()` 则追加在末尾。
## 虚函数调用:分发机制
现在我们来看看调用虚函数时实际发生了什么:
```cpp
void call_foo(Base* b) {
b->foo();
}
```
编译器大致会将其转换为:
```cpp
void call_foo(Base* b) {
// 从对象中加载 vptr
void** vtable = *(void***)b;
// 从虚表中加载函数指针
// foo() 在索引 0 处
void (*func)(Base*) = (void(*)(Base*))vtable[0];
// 调用函数
func(b);
}
```
以下是 GCC 在 `-Os` 优化下为 `call_foo` 生成的代码:
```asm
call_foo(Base*):
movq (%rdi), %rax # 加载 vptr
jmp *(%rax) # 调用虚表中第一个函数
```
如果 `foo` 是虚表中的第二个函数,调用会变成:
```asm
jmp *8(%rax) # 调用虚表中第二个函数
```
因为 `call_foo` 在虚函数返回后不需要做任何事,GCC 选择将其转换为尾调用(jmp 而非 call),非常巧妙。
## 多重继承:一个对象,多个虚表指针
到目前为止,模型都很简单:对象起始位置有一个 vptr,对应一个虚表。多重继承在此基础上增加了复杂度。
考虑以下情况:
```cpp
struct Left {
virtual void left_func() {}
int left_data;
};
struct Right {
virtual void right_func() {}
int right_data;
};
struct MultiDerived : Left, Right {
void left_func() override {}
[[gnu::noinline]] void right_func() override {
right_data = 42;
}
};
int main() {
MultiDerived md;
MultiDerived* md_ptr = &md;
Right* r_ptr = md_ptr; // 转换时加 16 字节
__builtin_printf("MultiDerived* : %p\n", (void*)md_ptr);
__builtin_printf("Right* : %p\n", (void*)r_ptr);
}
```
`MultiDerived` 对象必须既能当作 `Left*` 使用,也能当作 `Right*` 使用。这意味着它包含一个 `Left` 部分和一个 `Right` 部分。这些内嵌的基类部分在 ABI 中称为 **基类子对象(base subobjects)**。
实际布局大致如下:
```cpp
struct MultiDerived {
// Left 基类子对象
void** __vptr_Left;
int left_data;
// Right 基类子对象
void** __vptr_Right;
int right_data;
};
```
如果编译器拥有 `MultiDerived*`,它在编译时就知道这种布局,因此像 `md_ptr->left_data` 和 `md_ptr->right_data` 这样的访问只是相对于完整对象起始位置的固定偏移。
棘手之处在于让基类指针也能正常工作:
- `Left*` 应指向对象的 `Left` 部分
- `Right*` 应指向对象的 `Right` 部分
在此例中,`Left` 部分位于偏移 0 处,因此 `MultiDerived*` 和 `Left*` 具有相同的地址。`Right` 部分在 16 字节之后,因此将 `MultiDerived*` 转换为 `Right*` 会给指针加上 16 字节。这就是上述 printf 展示的地址差异。
由于两个基类子对象都是多态的,因此 `Left*` 必须能从 `Left` 部分的起始位置找到与 `Left` 兼容的虚表,同样 `Right*` 必须能从 `Right` 部分的起始位置找到与 `Right` 兼容的虚表。这就是对象拥有两个 vptr 的原因。
让我们使用 GCC 的 `-fdump-lang-class` 来检查布局,看看它是如何实现的:
```
Vtable for MultiDerived
MultiDerived::_ZTV12MultiDerived: 7 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI12MultiDerived)
16 (int (*)(...))MultiDerived::left_func
24 (int (*)(...))MultiDerived::right_func
32 (int (*)(...))-16
40 (int (*)(...))(& _ZTI12MultiDerived)
48 (int (*)(...))MultiDerived::_ZThn16_N12MultiDerived10right_funcEv
Class MultiDerived
size=32 align=8
base size=28 base align=8
MultiDerived (0x0x23aa000) 0
vptr=((& MultiDerived::_ZTV12MultiDerived) + 16)
Left (0x0x2311960) 0
primary-for MultiDerived (0x0x23aa000)
Right (0x0x23119c0) 16
vptr=((& MultiDerived::_ZTV12MultiDerived) + 48)
```
类的转储显示存在 **两个 vptr**,一个在偏移 0 处(用于 `Left` 部分),另一个在偏移 16 处(用于 `Right` 部分)。两个 vptr 都指向同一个 `_ZTV12MultiDerived` 符号的不同偏移位置。该符号是一个 **虚函数表组(virtual table group)**:一个主虚表后面跟一个或多个 **次级虚表(secondary virtual tables)**,每个非主基类对应一个。
**主虚表**(用于 `Left`,条目在偏移 0-24):
```
偏移 0: offset-to-top = 0
偏移 8: typeinfo 指针
偏移 16: left_func = &MultiDerived::left_func
偏移 24: right_func = &MultiDerived::right_func
```
**次级虚表**(用于 `Right`,条目在偏移 32-48):
```
偏移 32: offset-to-top = -16
偏移 40: typeinfo 指针
偏移 48: right_func = &MultiDerived::_ZThn16_N12MultiDerived10right_funcEv (thunk)
```
`Right` 子对象位于 `MultiDerived` 内部偏移 16 处。当将 `MultiDerived*` 转换为 `Right*` 时,编译器给指针加上 16 字节,使得成员访问(例如 `r->right_data`,它们是相对于 `this` 的固定偏移)能落在正确的字段上。此时指针指向 `Right` 的 vptr,后者指向对应的次级虚表。
次级虚表特别有趣。通过 `Right*` 调用时,起始指针指向对象的 `Right` 部分,因此必须使用 `Right` 的 vptr 和 `Right` 视角的虚表。次级虚表中的 `right_func` 槽位并不直接指向 `MultiDerived::right_func`,而是指向一个 **非虚 thunk(non-virtual thunk)**:一个微小的包装函数,将 `this` 从 `Right` 子对象调整回完整对象,然后调用真正的实现。
由于这种调整对于该类布局是固定的,因此可以硬编码在 thunk 中,所以称为“非虚”。其修饰名称分解如下:
- `_Z` - Itanium 修饰前缀
- `T` - 特殊名称开始(与虚表中的 `TV` 含义相同)
- `h` - 非虚调用偏移 thunk(虚 thunk 用 `v`)
- `n16` - 调整量:`n` 前缀表示负数,因此是 -16
- `_` - 调用偏移结束
- `N12MultiDerived10right_funcEv` - 目标函数(`MultiDerived::right_func()`)
GCC 生成如下代码:
```asm
.set .LTHUNK0,_ZN12MultiDerived10right_funcEv
_ZThn16_N12MultiDerived10right_funcEv:
subq $16, %rdi
jmp .LTHUNK0
```
在 C++ 中,这大致相当于:
```cpp
void non_virtual_thunk_to_MultiDerived_right_func(Right* this) {
MultiDerived* complete = (MultiDerived*)((char*)this - 16);
MultiDerived::right_func(complete);
}
```
`.set` 指令为 `MultiDerived::right_func` 创建了一个本地别名。thunk 调用该别名而非直接调用外部符号,从而避免了在 Linux 上经过 PLT。
**offset-to-top** 字段让运行时代码能够从基类子对象指针找到完整对象的起始位置。主要使用者是 `dynamic_cast`:`dynamic_cast<T>(r)` 从次级虚表中读取 offset-to-top,将其加到 `this` 上,从而找到最派生对象。
总结一下,多重继承的合理心智模型是:
- 一个对象可以包含多个多态基类部分
- 转换为基类指针时地址可能会改变
- 每个多态基类部分拥有自己的 vptr 和虚表视图
- 通过非主基类调用虚函数可能需要 thunk 来修复 `this`,使用硬编码的调整量
- offset-to-top 让运行时代码能从任意基类子对象指针恢复完整对象的起始位置
## 虚继承
虚继承是 C++ 解决 **菱形继承问题**(https://en.wikipedia.org/wiki/Multiple_inheritance#The_diamond_problem)的方案。考虑以下情况:
```cpp
struct Base {
virtual void f();
int x;
};
struct Left : virtual Base {
virtual void g();
};
struct Right : virtual Base {
virtual void h();
};
struct Derived : Left, Right {
void f() override;
};
```
`Left : virtual Base` 和 `Right : virtual Base` 中的 `virtual` 关键字告诉编译器 `Base` 是一个 **虚基类**:继承图中的所有路径共享同一个 `Base` 子对象。如果没有 `virtual`,`Derived` 将包含两个独立的 `Base` 副本(一个通过 `Left`,一个通过 `Right`),这样通过 `Derived` 访问 `Base::x` 就会产生二义性。这就是菱形继承问题。
共享基类正是我们想要的,但这也打破了前一节中 thunk 的假定。在普通多重继承中,每个基类子对象相对于完整对象都有固定偏移,因此非虚 thunk 可以硬编码调整量。而在虚继承中,虚基类的偏移取决于最终的派生类型。`Left` 并不知道当它嵌入 `Derived` 时 `Base` 会出现在哪里——只有在布局完整对象时才能确定。
这带来了几个后果:
- 通过虚基类进行虚函数调用需要运行时确定的 `this` 调整
- 构造函数不能假设每个基类的独立虚表布局
- 析构函数必须避免重复销毁虚基类
(原文 continues with more details on virtual inheritance, but the provided text cuts off at "destruction has to avoid destroying the". We'll translate the portion given.
Let's continue translating the remaining part of the article. The original ends abruptly at "destruction has to avoid destroying the". We'll translate up to that point.
Now, produce the final translated markdown.# Itanium C++ ABI 中虚函数表的工作原理
来源:https://peter0x44.github.io/posts/vtables-itanium-abi/
虚函数是 C++ 的核心特性之一,实现了运行时多态。大多数 C++ 程序员经常使用它,却很少有人真正了解它在底层是如何工作的。当你将函数声明为 `virtual` 时,编译器实际生成了什么?程序如何在运行时确定调用哪个实现?虚函数表数据又存储在何处?这篇博客将聚焦于解答这些问题。
C++ 标准规定了行为而非实现。本文描述的是 **Itanium C++ ABI**(https://itanium-cxx-abi.github.io/cxx-abi/abi.html),该 ABI 被大多数平台采用(值得注意的例外是 Microsoft MSVC)。
## 一个简单的示例
考虑以下带有虚函数的类:
```cpp
struct Base {
virtual void foo() { __builtin_printf("Base::foo\n"); }
virtual void bar() { __builtin_printf("Base::bar\n"); }
virtual ~Base() {}
};
struct Derived : Base {
void foo() override { __builtin_printf("Derived::foo\n"); }
};
void call_foo(Base* b) {
b->foo(); // 实际调用哪个 foo()?
}
int main() {
Base base;
Derived derived;
call_foo(&base); // 应调用 Base::foo
call_foo(&derived); // 应调用 Derived::foo
}
```
编译器在编译时并不知道 `call_foo()` 中 `b` 指向的具体类型。该函数需要根据对象的实际运行时类型,将调用分发到正确的 `foo()` 实现。这正是虚函数表(vtable)所做的事情。
## 虚函数表结构
我们来看看虚表(virtual table)的真实面貌。GCC 提供了一个有用的选项 `-fdump-lang-class`,可以输出类及其虚表的布局:
```bash
g++ -fdump-lang-class example.cpp
```
GCC 会生成一个名为 `a-example.cpp.001l.class` 的文件,其中包含类和虚表的布局信息。Clang 也能输出类似信息,不过接口不同:
```bash
clang++ -Xclang -fdump-record-layouts -Xclang -fdump-vtable-layouts example.cpp
```
(我是从《使用 Clang 转储 C++ 对象的内存布局》这篇文章中学到这个技巧的:https://eli.thegreenplace.net/2012/12/17/dumping-a-c-objects-memory-layout-with-clang)
我个人觉得 GCC 的输出格式更友好一些,因此下面引用的输出均来自 `gcc -fdump-lang-class`。
对于我们的 `Base` 类,GCC 输出如下:
```
Vtable for Base
Base::_ZTV4Base: 6 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI4Base)
16 (int (*)(...))Base::foo
24 (int (*)(...))Base::bar
32 (int (*)(...))Base::~Base
40 (int (*)(...))Base::~Base
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x23116c0) 0
vptr=((& Base::_ZTV4Base) + 16)
```
让我们逐一分析这些内容。
### 名称修饰(Mangled Names)
在查看条目之前,先注意符号名 `_ZTV4Base`。这是虚函数表的 **修饰名称**。
- `_Z` 前缀表示 Itanium 名称修饰
- `T` 是一个特殊名称前缀(用于虚表、类型信息、thunk 等编译器生成的符号)
- `V` 表示虚表(`I` 表示类型信息)
- `4` 是后续名称的长度
- `Base` 是类名
类似地:
- `_ZTV7Derived` = Derived 的虚表
- `_ZTI4Base` = Base 的类型信息
### 虚表布局
名为 `_ZTV4Base` 的虚表共有 6 个条目,每个条目在 64 位系统上间隔 8 字节。结构如下:
```
偏移 0: offset-to-top = 0
偏移 8: typeinfo 指针 = &_ZTI4Base
偏移 16: foo() = &Base::foo
偏移 24: bar() = &Base::bar
偏移 32: ~Base() = &Base::~Base (D1)(完整对象析构函数)
偏移 40: ~Base() = &Base::~Base (D0)(删除析构函数)
```
### 虚函数指针
类中声明的每个虚函数在虚表中都有一个条目,顺序与声明顺序一致。`Base` 中声明的函数有 `foo()`、`bar()` 和析构函数。
在 Itanium ABI 中,析构函数在虚表中占据两个条目:
- **完整对象析构函数(D1)**(偏移 32):销毁对象但不释放内存。用于栈对象、成员变量和数组元素。
- **删除析构函数(D0)**(偏移 40):先调用完整析构函数(D1),然后调用 `operator delete` 释放内存。用于 `delete ptr` 语句。
还有第三种变体 **基类对象析构函数(D2)**,它不出现在虚表中,仅由派生类的析构函数直接调用。在讨论虚继承时我们会看到它为何存在。
### 偏移到顶部(Offset-to-Top)
该字段记录了虚表指针(vptr)距离完整对象起始位置的偏移量。在单继承中,其值为零,因此看起来没什么意思。但在多重继承中它就变得重要了。
### RTTI 类型信息指针
类型信息指针用于运行时类型识别(RTTI)。它指向该类的 `std::type_info` 对象,供 `dynamic_cast`、`typeid` 和异常处理使用。如果使用了 `-fno-rtti`,该字段为 null。
## 虚表存放在哪里?
虚表是静态数据结构,位于二进制文件的 `.rodata` 段中。但具体由哪个翻译单元生成它呢?Itanium ABI 采用了 **关键函数(key function)规则**:虚表由第一个非内联虚函数所在的翻译单元生成。
```cpp
// base.h
struct Base {
virtual void foo() { /* ... */ }; // 内联,不是关键函数
virtual void bar(); // 非内联 - 这是关键函数
virtual ~Base();
};
// base.cpp
void Base::bar() { /* ... */ } // 关键函数 - 虚表将在此翻译单元中
// Base 的虚表在 base.cpp 中生成
```
这条规则是常见链接错误的根源:
```
undefined reference to `vtable for Base'
```
当关键函数被声明但未定义时就会发生此错误。如果所有虚函数都是内联的,则不存在关键函数。此时虚表将以弱链接(weak linkage)的形式出现在每个使用该类的翻译单元中。
## 对象布局:虚表指针(VPtr)
每个拥有虚函数的对象都包含一个隐藏成员,称为 **虚表指针**(vptr)。在这个简单的单继承例子中,它位于对象偏移 0 处。虚函数调用通过读取 `*(void**)this` 来找到虚表。在多重继承中会有多个虚表指针(每个基类一个),后续会介绍。
```
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x22606c0) 0
nearly-empty vptr=((& Base::_ZTV4Base) + 16)
```
注意,虚表指针并不指向虚表起始位置,而是指向虚表的 **地址点(address point)**。在此例中,它指向虚表中的第一个函数条目(偏移 +16)。类型信息指针和 offset-to-top 位于地址点的负偏移处。可以这样理解,编译器将以下代码:
```cpp
struct Base {
virtual void foo();
int x;
};
```
转换成了类似这样的结构:
```cpp
struct Base {
void** __vptr; // 指向虚表
int x;
};
```
## 继承:虚表扩展
当派生类重写虚函数时,它会基于基类布局获得自己的虚表:
```cpp
struct Derived : Base {
void foo() override;
virtual void baz();
};
```
```
vtable for Derived:
[offset-to-top] = 0
[typeinfo] = &typeinfo for Derived
[foo()] = &Derived::foo (已重写)
[bar()] = &Base::bar (继承)
[~Derived()] = &Derived::~Derived (完整对象析构函数)
[~Derived()] = &Derived::~Derived (删除析构函数)
[baz()] = &Derived::baz (新虚函数)
```
继承来的槽位保持相同的位置。偏移 0 处的函数仍然是 `foo()`,只是指向了不同的实现。因此 `foo()` 被替换为 `Derived::foo`,`bar()` 仍指向基类实现,`baz()` 则追加在末尾。
## 虚函数调用:分发机制
现在我们来看看调用虚函数时实际发生了什么:
```cpp
void call_foo(Base* b) {
b->foo();
}
```
编译器大致会将其转换为:
```cpp
void call_foo(Base* b) {
// 从对象中加载 vptr
void** vtable = *(void***)b;
// 从虚表中加载函数指针
// foo() 在索引 0 处
void (*func)(Base*) = (void(*)(Base*))vtable[0];
// 调用函数
func(b);
}
```
以下是 GCC 在 `-Os` 优化下为 `call_foo` 生成的代码:
```asm
call_foo(Base*):
movq (%rdi), %rax # 加载 vptr
jmp *(%rax) # 调用虚表中第一个函数
```
如果 `foo` 是虚表中的第二个函数,调用会变成:
```asm
jmp *8(%rax) # 调用虚表中第二个函数
```
因为 `call_foo` 在虚函数返回后不需要做任何事,GCC 选择将其转换为尾调用(jmp 而非 call),非常巧妙。
## 多重继承:一个对象,多个虚表指针
到目前为止,模型都很简单:对象起始位置有一个 vptr,对应一个虚表。多重继承在此基础上增加了复杂度。
考虑以下情况:
```cpp
struct Left {
virtual void left_func() {}
int left_data;
};
struct Right {
virtual void right_func() {}
int right_data;
};
struct MultiDerived : Left, Right {
void left_func() override {}
[[gnu::noinline]] void right_func() override {
right_data = 42;
}
};
int main() {
MultiDerived md;
MultiDerived* md_ptr = &md;
Right* r_ptr = md_ptr; // 转换时加 16 字节
__builtin_printf("MultiDerived* : %p\n", (void*)md_ptr);
__builtin_printf("Right* : %p\n", (void*)r_ptr);
}
```
`MultiDerived` 对象必须既能当作 `Left*` 使用,也能当作 `Right*` 使用。这意味着它包含一个 `Left` 部分和一个 `Right` 部分。这些内嵌的基类部分在 ABI 中称为 **基类子对象(base subobjects)**。
实际布局大致如下:
```cpp
struct MultiDerived {
// Left 基类子对象
void** __vptr_Left;
int left_data;
// Right 基类子对象
void** __vptr_Right;
int right_data;
};
```
如果编译器拥有 `MultiDerived*`,它在编译时就知道这种布局,因此像 `md_ptr->left_data` 和 `md_ptr->right_data` 这样的访问只是相对于完整对象起始位置的固定偏移。
棘手之处在于让基类指针也能正常工作:
- `Left*` 应指向对象的 `Left` 部分
- `Right*` 应指向对象的 `Right` 部分
在此例中,`Left` 部分位于偏移 0 处,
相似文章
C++ 编译器何时可以反虚拟化调用?
探讨 C++ 编译器何时可以对虚函数调用进行去虚拟化,涵盖已知动态类型和 final 关键字等情况,并在 GCC、Clang、MSVC 和 ICC 之间进行比较。
字节码虚拟机在意外场景中的应用 (2024)
本文探讨了字节码虚拟机的出人意料的应用,特别是Linux内核中的eBPF以及编译后二进制文件中用于调试信息的DWARF表达式。
SBCL: 终极汇编代码面包板 (2014)
一篇技术博客文章,探讨如何使用SBCL作为汇编代码的面包板,重点介绍基于堆栈的虚拟机技术,如旋转堆栈和高效的原语操作分发,并引用了F18处理器和x87堆栈。
80386 微码反汇编
一篇博客文章,详细介绍了成功反汇编和分析 Intel 80386 微码的过程,揭示了215条指令入口点以及其复杂的内部架构。
矩阵转置的实现要点
一篇深入的技术博客文章,解释如何使用现代x86_64 CPU上的SIMD指令高效地转置矩阵,重点介绍类似_mm256_shuffle_epi8的AVX2内联函数。