文章目录
- [逐层拆解:C++ 虚函数从对象内存到手工调用的完整过程](#逐层拆解:C++ 虚函数从对象内存到手工调用的完整过程)
-
- 一、实验边界:先区分"标准规定"和"编译器实现"
- 二、运行时多态究竟"动态"在哪里?
-
- [1. 静态类型](#1. 静态类型)
- [2. 动态类型](#2. 动态类型)
- 三、示例类:让对象布局更容易观察
- 四、不要混淆三个不同的东西
-
- [1. 对象地址](#1. 对象地址)
- [2. vptr 所在的位置](#2. vptr 所在的位置)
- [3. vptr 中保存的值](#3. vptr 中保存的值)
- [五、为什么原代码会把 `&a` 转成 `void**`?](#五、为什么原代码会把
&a转成void**?) - [六、使用 `memcpy` 读取对象开头的 vptr](#六、使用
memcpy读取对象开头的 vptr) - [七、vptr 指向的未必是"虚表物理开头"](#七、vptr 指向的未必是“虚表物理开头”)
- [八、如何从 vptr 读取第一个虚函数槽位?](#八、如何从 vptr 读取第一个虚函数槽位?)
- [九、`vtable0` 到底做了什么?](#九、
vtable[0]到底做了什么?) - [十、成员函数在 ABI 层面如何接收 `this`?](#十、成员函数在 ABI 层面如何接收
this?) - 十一、为什么可以写成普通函数指针形式?
- 十二、完整实验代码
- 十三、把完整过程压缩成一次指针链
-
- 第一步:取得对象地址
- 第二步:读取对象开头
- [第三步:读取虚表第 0 个槽位](#第三步:读取虚表第 0 个槽位)
- 第四步:准备参数
- 第五步:跳转执行
- 十四、正常虚函数调用对应的汇编逻辑
- [十五、对象构造时,vptr 是如何变成 `A` 的虚表地址的?](#十五、对象构造时,vptr 是如何变成
A的虚表地址的?) - 十六、为什么不能简单断言"虚表按声明顺序存函数"?
- 十七、多继承时,为什么情况会复杂得多?
- 十八、原始理解中需要修正的几个关键点
-
- [误区一:C++ 标准规定使用 vptr 和 vtable](#误区一:C++ 标准规定使用 vptr 和 vtable)
- [误区二:`&a` 天然就是二级指针](#误区二:
&a天然就是二级指针) - [误区三:对象首地址永远等于 vptr 地址](#误区三:对象首地址永远等于 vptr 地址)
- [误区四:vptr 一定指向整个虚表的物理起点](#误区四:vptr 一定指向整个虚表的物理起点)
- 误区五:虚表中保存的一定是函数本体地址
- [误区六:x86-64 第二个参数放在整个 `rsi` 中](#误区六:x86-64 第二个参数放在整个
rsi中) - [误区七:Windows MSVC 一定因为 `__thiscall` 崩溃](#误区七:Windows MSVC 一定因为
__thiscall崩溃)
- 十九、这段代码属于哪一种"未定义"或"不保证"?
-
- [1. 假设对象开头是 vptr](#1. 假设对象开头是 vptr)
- [2. 假设 vptr 的大小等于 `std::uintptr_t`](#2. 假设 vptr 的大小等于
std::uintptr_t) - [3. 读取编译器生成的虚表内存](#3. 读取编译器生成的虚表内存)
- [4. 假设第 0 个槽位对应 `Test`](#4. 假设第 0 个槽位对应
Test) - [5. 将整数地址转换为函数指针](#5. 将整数地址转换为函数指针)
- [6. 使用普通函数指针调用成员函数入口](#6. 使用普通函数指针调用成员函数入口)
- [7. 假设槽位中不是特殊 thunk](#7. 假设槽位中不是特殊 thunk)
- 二十、最终知识闭环
逐层拆解:C++ 虚函数从对象内存到手工调用的完整过程
适合读者:已经会使用
virtual,但希望理解运行时多态究竟如何完成间接调用的 C++ 开发者。
cpp
#include <iostream>
// 基类(含有虚函数)
class Base {
public:
virtual void Test(int x) {
std::cout << "Base::Test(" << x << ")" << std::endl;
}
virtual ~Base() = default; // 方便继承
};
// 派生类 A
class A : public Base {
public:
void Test(int x) override {
std::cout << "A::Test(" << x << ")" << std::endl;
}
};
// 派生类 B
class B : public Base {
public:
void Test(int x) override {
std::cout << "B::Test(" << x << ")" << std::endl;
}
};
// 通过基类引用调用虚函数
void TestBase(Base& b) {
b.Test(99); // 虚调用,运行时决定调用哪个派生类的版本
}
int main() {
A a;
B b;
// 多态调用
TestBase(a); // 输出:A::Test(99)
TestBase(b); // 输出:B::Test(99)
// 查看对象内存布局(仅供调试/学习)
// 取对象首地址,即虚表指针(vptr)的地址
auto vptr_a = reinterpret_cast<void**>(&a);
auto vtable_a = reinterpret_cast<void**>(*vptr_a); // 虚表地址
std::cout << "vptr of a: " << vptr_a << std::endl;
std::cout << "vtable of a: " << vtable_a << std::endl;
auto vptr_b = reinterpret_cast<void**>(&b);
auto vtable_b = reinterpret_cast<void**>(*vptr_b);
std::cout << "vptr of b: " << vptr_b << std::endl;
std::cout << "vtable of b: " << vtable_b << std::endl;
//得到A对象虚表第一个表格的内容
auto func_pointer = vtable_a[0];
//需要将这个转换为函数指针
using FUNC = void* ( *)(void*,int);
auto first_func_ptr = reinterpret_cast<FUNC>(func_pointer);
//使用函数指针调用函数
first_func_ptr(&a,8);
return 0;
}
&a 是对象a的起始地址,也是存放着虚函数表指针的起始地址,也就是说是一个指向地址的地址,可以转换为二级指针
对二级指针解引用,得到一级指针,这个一级指针就是虚函数表指针本身,虚函数表指针指向了虚函数表
虚函数表就相当于是一个存放着 函数地址的数组 。 所以这个虚函数表就相当于是存放着虚函数指针的数组,数组的每个元素都是一个函数指针(或者说是函数地址吧)(后面会补充一个知识点,虚表内不一定都是虚函数地址)
所以现在 虚函数表指针的角色就是 指向 一个数组的指针,这个数组存放的是地址,也就是说现在 虚函数表指针又称为指向地址的地址了
所以可以进行转换为二级指针。
二级指针使用中括号,表示取出指针指向的位置的某个元素, 二级指针[0]表示取出 二级指针指向的数组第一个元素,而这个元素是函数指针,所以可以转换为函数指针类型
函数指针的类型是成员函数,成员函数的函数指针表示方法是第一个参数是this 指针,可以使用void*表示,其余的都是其余指针
本文只研究一条链路:
text
对象地址
↓
读取对象中的 vptr
↓
定位虚表地址点
↓
读取虚函数槽位
↓
按目标 ABI 模拟调用
最终要解释清楚三个问题:
-
对象中究竟存放了什么?
-
为什么解引用对象开头可以得到虚表地址?
-
为什么虚表中的某个地址可以被当作函数入口调用?
一、实验边界:先区分"标准规定"和"编译器实现"
在继续之前,必须明确一个重要事实:
C++ 标准只规定虚函数调用应表现出运行时多态行为,并没有规定必须使用
vptr和vtable。
vptr、vtable、虚表槽位顺序、析构函数布局、调整函数等,都属于编译器 ABI 的实现细节。
本文假设以下环境:
-
x86-64 Linux
-
GCC 或 Clang
-
Itanium C++ ABI
-
单继承
-
没有虚继承
-
Base是A的主基类 -
仅用于观察底层实现,不用于生产代码
在这些条件下,编译器通常会采用如下实现:
-
多态对象中保存一个隐藏的虚表指针,即
vptr -
vptr指向该动态类型对应的虚表地址点 -
虚表槽位中保存虚函数入口地址,或者用于调整
this的 thunk 地址
后文所有"对象开头是 vptr""第 0 个槽位是 Test"之类的描述,都必须放在上述 ABI 条件下理解,而不能当作 C++ 标准保证。
二、运行时多态究竟"动态"在哪里?
先看普通的多态调用:
cpp
Base* p = new A();
p->Test(10);
从源代码看,p 的静态类型是:
cpp
Base*
但它实际指向的对象类型是:
cpp
A
这里需要区分两个概念。
1. 静态类型
编译器在编译源代码时能够直接看到的类型:
cpp
Base*
2. 动态类型
程序运行时,指针当前实际指向的对象类型:
cpp
A
当编译器无法在编译期确定动态类型时,类似:
cpp
p->Test(10);
的调用通常会被编译为一条间接调用链:
text
读取 p 指向对象中的 vptr
↓
根据 Test 对应的槽位偏移读取函数地址
↓
将 p 作为隐藏的 this 参数
↓
跳转到该函数地址
其伪代码可以理解为:
cpp
vptr = *reinterpret_cast<void***>(p);
function = vptr[TestSlot];
function(p, 10);
这不是合法、可移植的标准 C++ 写法,只是对 ABI 行为的概念化表达。
另外,"虚函数一定只能在运行时查表"也不完全准确。
当编译器能够证明对象的动态类型时,优化器可能执行去虚拟化,直接调用:
cpp
A::Test
而不再真的读取虚表。
三、示例类:让对象布局更容易观察
使用下面三个类:
cpp
#include <iostream>
class Base {
public:
virtual void Test(int x) {
std::cout << "Base::Test(" << x << ")\n";
}
virtual ~Base() = default;
};
class A : public Base {
public:
int value = 123;
void Test(int x) override {
std::cout << "A::Test(" << x
<< "), value=" << value << '\n';
}
};
class B : public Base {
public:
void Test(int x) override {
std::cout << "B::Test(" << x << ")\n";
}
};
这里有两个重要条件:
-
Base含有虚函数,因此是多态类。 -
A继承Base,并重写了Test。
创建对象:
cpp
A a;
在本文假设的 ABI 下,a 的内存布局通常类似:
text
A 对象地址:0x7ffc0010
低地址
┌──────────────────────────────────────┐
│ 0x7ffc0010 ~ 0x7ffc0017 │
│ vptr = 0x0000000000403d50 │
│ 指向 A 对应虚表的地址点 │
├──────────────────────────────────────┤
│ 0x7ffc0018 ~ 0x7ffc001b │
│ value = 123 │
├──────────────────────────────────────┤
│ 0x7ffc001c ~ 0x7ffc001f │
│ 对齐填充 │
└──────────────────────────────────────┘
高地址
在这个具体条件下:
cpp
&a
与对象中第一个机器字的地址相同。
但更准确的说法不是:
&a就是 vptr。
而是:
&a是整个A对象的地址;在当前 ABI 和继承结构下,对象起始位置恰好存放着 vptr。
对象地址和 vptr 的值是两个完全不同的地址。
假设:
text
对象地址:0x7ffc0010
vptr 值: 0x403d50
它们的含义分别是:
text
0x7ffc0010:对象位于哪里
0x403d50: 虚表地址点位于哪里
四、不要混淆三个不同的东西
理解虚表时,最容易混淆的是下面三个概念。
1. 对象地址
cpp
&a
类型是:
cpp
A*
假设数值为:
text
0x7ffc0010
它指向整个 A 对象。
2. vptr 所在的位置
在当前布局下,vptr 位于对象起始位置,因此 vptr 字段所在地址也是:
text
0x7ffc0010
3. vptr 中保存的值
从对象开头读取一个指针大小的数据,得到:
text
0x403d50
这才是虚表地址点。
可以把它类比为普通成员:
cpp
struct Example {
void* pointer;
};
假设:
cpp
Example object;
那么:
text
&object 对象地址
&object.pointer pointer 字段地址
object.pointer pointer 字段中保存的地址值
在 pointer 恰好是第一个成员时,前两个地址可能相等,但它们在语义上仍然不是同一个概念。
五、为什么原代码会把 &a 转成 void**?
原始写法类似:
cpp
auto vptr_location = reinterpret_cast<void**>(&a);
这行代码经常被解释成:
因为对象开头放的是一个指针,所以对象地址天然是二级指针。
这个说法不够准确。
&a 的真实类型始终是:
cpp
A*
它并不会因为对象开头保存了一个指针,就"天然变成二级指针"。
reinterpret_cast<void**>(&a) 真正表达的是:
暂时忽略
A的类型,把对象起始地址解释为一个能够读取void*数据的位置。
也就是说:
cpp
auto vptr_location = reinterpret_cast<void**>(&a);
执行后:
text
vptr_location 中保存的数值:0x7ffc0010
vptr_location 指向的位置:对象开头
该位置中的数据: 0x403d50
因此变量名最好不要叫:
cpp
vptr_a
因为它当前还不是 vptr 的值,而是"存放 vptr 的位置"。
更准确的命名是:
cpp
vptr_location
或者:
cpp
object_first_word
随后解引用:
cpp
void* vptr_value = *vptr_location;
才会从对象开头读取一个指针大小的数据:
text
*vptr_location == 0x403d50
这个值才是 vptr。
不过,这种通过不相关指针类型直接读取对象存储的方式不属于可移植的标准 C++ 用法,还可能涉及严格别名规则。
在实验代码中,可以使用 std::memcpy 读取对象表示,从而避免直接通过错误类型解引用对象。
六、使用 memcpy 读取对象开头的 vptr
可以这样读取对象的第一个指针大小数据:
cpp
#include <cstdint>
#include <cstring>
std::uintptr_t vptr_value = 0;
std::memcpy(
&vptr_value,
&a,
sizeof(vptr_value)
);
假设对象内存如下:
text
地址 0x7ffc0010 中的 8 字节:
50 3d 40 00 00 00 00 00
x86-64 使用小端字节序,因此解释为整数后得到:
text
0x0000000000403d50
于是:
cpp
vptr_value == 0x403d50
这里每个变量的含义非常明确:
text
&a = 对象地址
vptr_value = 从对象开头读取出的地址值
std::uintptr_t 是一种能够容纳对象指针数值的无符号整数类型。
需要注意:
std::uintptr_t本身也是可选类型,不过主流 32 位和 64 位平台一般都会提供。
七、vptr 指向的未必是"虚表物理开头"
很多文章会直接说:
vptr 指向虚表的第一个元素。
这在 Itanium C++ ABI 下仍然不够准确。
更准确的概念叫:
text
virtual table address point
即虚表地址点。
在 Itanium ABI 中,vptr 通常指向可以直接进行虚函数槽位索引的位置。在该地址之前,还可能存在:
text
vptr[-2]:offset-to-top
vptr[-1]:RTTI / typeinfo 指针
vptr[0]:第一个虚函数槽位
vptr[1]:第二个虚函数槽位
...
示意结构如下:
text
较低地址
┌──────────────────────────────┐
│ offset-to-top │ address_point[-2]
├──────────────────────────────┤
│ typeinfo for A │ address_point[-1]
├──────────────────────────────┤
│ A::Test 或相关 thunk │ address_point[0]
├──────────────────────────────┤
│ A::~A 的析构入口之一 │ address_point[1]
├──────────────────────────────┤
│ A::~A 的另一个析构入口 │ address_point[2]
└──────────────────────────────┘
较高地址
对象中的 vptr 通常指向:
text
address_point[0]
而不是指向整个虚表数据结构的最低地址。
因此代码中更准确的变量名是:
cpp
vtable_address_point
而不是简单地叫:
cpp
vtable_begin
八、如何从 vptr 读取第一个虚函数槽位?
已经得到:
cpp
std::uintptr_t vptr_value;
它保存虚表地址点,例如:
text
0x403d50
将这个地址解释为一段内存:
cpp
const void* vtable_address_point =
reinterpret_cast<const void*>(vptr_value);
然后读取该地址处的一个指针大小数据:
cpp
std::uintptr_t first_slot = 0;
std::memcpy(
&first_slot,
vtable_address_point,
sizeof(first_slot)
);
假设:
text
地址 0x403d50 中保存的 8 字节:
00 12 40 00 00 00 00 00
读取后:
text
first_slot == 0x401200
这个值可能是:
text
A::Test 的机器码入口地址
也可能是:
text
先调整 this,再跳转到 A::Test 的 thunk 地址
在当前简单单继承示例中,它通常就是 A::Test 的非调整入口。
九、vtable[0] 到底做了什么?
假设使用原始写法:
cpp
auto vtable = reinterpret_cast<void**>(vptr_value);
auto raw_function = vtable[0];
表达式:
cpp
vtable[0]
等价于:
cpp
*(vtable + 0)
由于 vtable 的类型是:
cpp
void**
所以:
cpp
vtable + 1
会向后移动一个 void* 的大小。
在 64 位平台上,一般是 8 字节:
text
vtable[0] 读取地址:vptr_value + 0
vtable[1] 读取地址:vptr_value + 8
vtable[2] 读取地址:vptr_value + 16
因此:
cpp
vtable[0]
表达的本质是:
从虚表地址点开始,读取第 0 个指针大小的槽位。
它不是在"调用函数",只是读取了一个地址。
读取结果可能是:
text
0x401200
这个数字只有在按照目标 ABI 解释时,才具有"函数入口地址"的含义。
十、成员函数在 ABI 层面如何接收 this?
源代码中的成员函数:
cpp
void A::Test(int x);
从语言层面看,只有一个显式参数:
cpp
int x
但普通非静态成员函数还需要知道自己正在操作哪个对象。
因此调用:
cpp
a.Test(8);
在 ABI 层面可以近似理解为:
cpp
A_Test(&a, 8);
其中:
text
&a:隐藏的 this 参数
8: 显式参数 x
在 x86-64 System V ABI 中,整数和指针参数通常依次放入:
text
第一个参数:RDI
第二个参数:RSI
第三个参数:RDX
第四个参数:RCX
第五个参数:R8
第六个参数:R9
所以类似:
cpp
a.Test(8);
的调用在机器层面通常会使:
text
RDI = &a
ESI = 8
这里第二个参数是 int,实际只需要使用 RSI 的低 32 位,即 ESI。
随后执行类似:
asm
call rax
跳转到虚表槽位中的函数地址。
十一、为什么可以写成普通函数指针形式?
为了模拟这个 ABI 调用,可以定义:
cpp
using AbiFunction = void (*)(A*, int);
然后把读取出的槽位地址解释成该函数指针:
cpp
auto function =
reinterpret_cast<AbiFunction>(first_slot);
最后调用:
cpp
function(&a, 8);
从目标 ABI 的寄存器传参角度看,这相当于:
text
RDI = &a
ESI = 8
CALL first_slot
于是 A::Test 中访问:
cpp
value
时,会通过 this 指针定位成员:
text
this + value 的成员偏移
例如 value 位于对象偏移 8 字节的位置,那么函数内部可以近似理解为:
cpp
int current_value =
*reinterpret_cast<int*>(
reinterpret_cast<char*>(this) + 8
);
实际偏移由编译器根据对象布局决定。
必须强调:
将虚表槽位中的原始地址转换成普通函数指针并调用,不属于 C++ 标准保证的行为。
这种写法依赖:
-
编译器 ABI
-
函数指针表示
-
调用约定
-
隐藏参数传递方式
-
虚表槽位顺序
-
目标平台的代码地址表示
它只是一个 ABI 实验。
十二、完整实验代码
下面给出一份相对清晰的实验程序。
它使用 std::memcpy 读取对象和虚表中的地址值,减少直接通过不相关指针类型读取内存的问题。
cpp
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <type_traits>
class Base {
public:
virtual void Test(int x) {
std::cout << "Base::Test(" << x << ")\n";
}
virtual ~Base() = default;
};
class A : public Base {
public:
int value = 123;
void Test(int x) override {
std::cout << "A::Test(" << x
<< "), value=" << value << '\n';
}
};
class B : public Base {
public:
void Test(int x) override {
std::cout << "B::Test(" << x << ")\n";
}
};
void TestBase(Base& object) {
object.Test(99);
}
template <typename T>
void ManualCallFirstVirtualSlot(T& object, int argument) {
static_assert(std::is_polymorphic_v<T>);
// 第一步:取得对象地址。
const auto object_address =
reinterpret_cast<std::uintptr_t>(&object);
// 第二步:读取对象开头的一个指针大小数据。
// 在本文假设的 ABI 和继承结构下,该值是 vptr。
std::uintptr_t vptr_value = 0;
std::memcpy(
&vptr_value,
&object,
sizeof(vptr_value)
);
// 第三步:从虚表地址点读取第 0 个槽位。
std::uintptr_t first_slot = 0;
const auto* vtable_address_point =
reinterpret_cast<const void*>(vptr_value);
std::memcpy(
&first_slot,
vtable_address_point,
sizeof(first_slot)
);
std::cout << std::hex << std::showbase;
std::cout << "对象地址 : "
<< object_address << '\n';
std::cout << "对象开头保存的 vptr : "
<< vptr_value << '\n';
std::cout << "虚表第 0 个槽位 : "
<< first_slot << '\n';
std::cout << std::dec << std::noshowbase;
// 第四步:按照当前目标 ABI,近似构造函数指针类型。
using AbiFunction = void (*)(T*, int);
auto function =
reinterpret_cast<AbiFunction>(first_slot);
// 第五步:显式传入 this 和普通参数。
function(&object, argument);
}
int main() {
A a;
B b;
std::cout << "===== 正常虚函数调用 =====\n";
TestBase(a);
TestBase(b);
std::cout << "\n===== 手工读取并调用 A 的第 0 个槽位 =====\n";
ManualCallFirstVirtualSlot(a, 8);
std::cout << "\n===== 手工读取并调用 B 的第 0 个槽位 =====\n";
ManualCallFirstVirtualSlot(b, 8);
return 0;
}
在符合本文假设的环境中,可能得到类似输出:
text
===== 正常虚函数调用 =====
A::Test(99), value=123
B::Test(99)
===== 手工读取并调用 A 的第 0 个槽位 =====
对象地址 : 0x7ffd91f5abe0
对象开头保存的 vptr : 0x403d48
虚表第 0 个槽位 : 0x4014a2
A::Test(8), value=123
===== 手工读取并调用 B 的第 0 个槽位 =====
对象地址 : 0x7ffd91f5abd8
对象开头保存的 vptr : 0x403d20
虚表第 0 个槽位 : 0x40151c
B::Test(8)
实际地址每次运行都可能不同。
常见原因包括:
-
ASLR
-
PIE
-
编译器版本不同
-
链接器布局不同
-
优化级别不同
-
调试版和发布版差异
十三、把完整过程压缩成一次指针链
假设:
text
&a = 0x7ffc0010
对象内存:
text
[0x7ffc0010] = 0x403d50
虚表地址点:
text
[0x403d50] = 0x401200
那么完整过程是:
第一步:取得对象地址
text
&a
=
0x7ffc0010
第二步:读取对象开头
text
内存[0x7ffc0010]
=
0x403d50
得到 vptr。
第三步:读取虚表第 0 个槽位
text
内存[0x403d50]
=
0x401200
得到函数入口或 thunk 地址。
第四步:准备参数
text
this = 0x7ffc0010
x = 8
第五步:跳转执行
text
call 0x401200
完整链路可以写成:
text
&a
↓ 读取对象开头
vptr
↓ 读取第 0 个槽位
函数入口地址
↓ 传入 &a 和 8
A::Test(&a, 8)
十四、正常虚函数调用对应的汇编逻辑
下面不是某个编译器保证生成的固定汇编,而是典型的概念形式。
源代码:
cpp
Base* p = &a;
p->Test(10);
可能被编译为类似:
asm
mov rdi, p ; RDI = this
mov rax, [rdi] ; RAX = 对象开头的 vptr
mov rax, [rax + 0] ; RAX = 第 0 个虚函数槽位
mov esi, 10 ; ESI = 参数 x
call rax ; 间接调用
逐条解释:
asm
mov rdi, p
将对象地址作为隐藏的 this 参数。
asm
mov rax, [rdi]
从对象开头读取 vptr。
asm
mov rax, [rax + 0]
从虚表地址点读取 Test 对应的槽位。
asm
mov esi, 10
准备显式参数 x。
asm
call rax
跳转到动态类型对应的函数入口。
如果 p 指向 A,读取到的就是 A 对应的槽位。
如果 p 指向 B,读取到的就是 B 对应的槽位。
这就是运行时多态的核心:
调用代码基本不变,真正改变的是对象内部保存的 vptr。
十五、对象构造时,vptr 是如何变成 A 的虚表地址的?
执行:
cpp
A a;
时,构造过程通常可以概念化为:
text
进入 Base 构造阶段
↓
将 Base 子对象的 vptr 设置为 Base 对应虚表
↓
执行 Base 构造函数
↓
进入 A 构造阶段
↓
将主对象 vptr 更新为 A 对应虚表
↓
初始化 value
↓
执行 A 构造函数体
这也解释了为什么:
在基类构造函数和析构函数中调用虚函数,不会表现为完整对象阶段的动态分派。
因为在基类构造阶段,对象尚未完成派生类部分的构造,vptr 通常对应当前正在构造的类。
析构过程则大致相反:
text
执行 A 析构阶段
↓
对象逐步退化为 Base 子对象
↓
vptr 切换为 Base 对应虚表
↓
执行 Base 析构阶段
这不是简单的"编译器禁止多态",而是对象动态类型在构造和析构期间具有特殊规则。
十六、为什么不能简单断言"虚表按声明顺序存函数"?
在简单单继承示例中,虚函数槽位通常与声明顺序具有直接关系。
例如:
cpp
class Base {
public:
virtual void Test(int);
virtual ~Base();
};
在当前 ABI 下,地址点附近可能类似:
text
slot 0:Test
slot 1:完整对象析构入口
slot 2:删除析构入口
但实际情况可能受到以下因素影响:
-
虚析构函数可能占用多个槽位
-
多继承可能生成多个虚表
-
虚继承会加入额外偏移信息
-
覆盖函数可能使用 thunk
-
协变返回值可能生成返回值调整 thunk
-
编译器 ABI 不同
-
某些安全机制可能修改函数指针表示
-
链接器可能执行合并或折叠
因此,下面的说法过于绝对:
虚表里一定严格按照源码声明顺序保存函数本体地址。
更准确的说法是:
在特定 ABI 中,虚表槽位布局遵循该 ABI 的规则;槽位可能保存函数入口,也可能保存负责调整参数后再跳转的 thunk。
十七、多继承时,为什么情况会复杂得多?
考虑:
cpp
class Left {
public:
virtual void LeftFunc();
};
class Right {
public:
virtual void RightFunc();
};
class Derived : public Left, public Right {
public:
void LeftFunc() override;
void RightFunc() override;
};
Derived 对象中通常不再只有一个 vptr,而可能类似:
text
Derived 对象
┌────────────────────────────┐
│ Left 子对象 │
│ vptr for Left │
├────────────────────────────┤
│ Right 子对象 │
│ vptr for Right │
├────────────────────────────┤
│ Derived 自身成员 │
└────────────────────────────┘
如果通过:
cpp
Right* p = &derived;
调用:
cpp
p->RightFunc();
传入的 this 可能指向 Right 子对象,而不是完整 Derived 对象的起始地址。
此时虚表槽位中可能保存一个 thunk:
text
接收 Right* this
↓
将 this 减去 Right 子对象偏移
↓
得到 Derived* this
↓
跳转到 Derived::RightFunc
因此,虚表槽位中的地址不一定是成员函数机器码的真正起始地址。
它也可能是:
text
this-adjusting thunk
这也是为什么"直接取出虚表地址并手工调用"在多继承下极易出错。
十八、原始理解中需要修正的几个关键点
误区一:C++ 标准规定使用 vptr 和 vtable
不准确。
正确表述是:
vptr 和 vtable 是主流编译器实现虚函数分派的常见 ABI 机制,但不是 C++ 标准规定的对象布局。
误区二:&a 天然就是二级指针
不准确。
&a 的类型始终是:
cpp
A*
将其转换成 void**,只是要求编译器用另一种类型解释同一个地址。
误区三:对象首地址永远等于 vptr 地址
过于绝对。
在本文指定的单继承、主基类 ABI 场景中通常成立。
但在以下情况中不能直接推广:
-
多继承
-
虚继承
-
非主基类子对象
-
不同 ABI
-
特殊对象模型
误区四:vptr 一定指向整个虚表的物理起点
不准确。
在 Itanium ABI 中,vptr 通常指向虚表地址点,RTTI 和 offset-to-top 等信息可能位于负索引位置。
误区五:虚表中保存的一定是函数本体地址
不一定。
槽位中还可能保存:
-
this调整 thunk -
返回值调整 thunk
-
析构函数的不同入口
-
纯虚函数处理入口
误区六:x86-64 第二个参数放在整个 rsi 中
需要更精确。
对于:
cpp
int x
通常使用 RSI 的低 32 位:
text
ESI
写入 ESI 时,x86-64 会自动将 RSI 的高 32 位清零。
误区七:Windows MSVC 一定因为 __thiscall 崩溃
这个说法只对部分平台成立。
在 32 位 x86 MSVC 中:
-
非静态成员函数常使用
__thiscall -
this通常放入ECX -
普通自由函数的参数通常通过栈传递
因此错误的函数指针类型很容易导致参数错位。
但在 64 位 Windows 上,MSVC 使用统一的 Windows x64 调用约定:
text
第一个参数:RCX
第二个参数:RDX
第三个参数:R8
第四个参数:R9
普通函数和成员函数不再通过独立的 32 位 __thiscall 规则区分。
这不代表本文代码在 MSVC x64 上就具备可移植性,因为:
-
MSVC 使用自己的 C++ ABI
-
虚表结构与 Itanium ABI 不完全相同
-
槽位布局可能不同
-
原始地址到函数指针的转换仍非标准保证
十九、这段代码属于哪一种"未定义"或"不保证"?
这里存在多个层面的非可移植操作:
1. 假设对象开头是 vptr
C++ 标准不保证。
2. 假设 vptr 的大小等于 std::uintptr_t
主流平台通常成立,但不是所有实现都必须如此。
3. 读取编译器生成的虚表内存
虚表不是标准 C++ 对象模型中供程序直接访问的公开结构。
4. 假设第 0 个槽位对应 Test
依赖 ABI 和类声明。
5. 将整数地址转换为函数指针
结果依赖实现。
6. 使用普通函数指针调用成员函数入口
函数类型在 C++ 类型系统中并不真正兼容,只是在特定 ABI 下具有相同的底层传参形式。
7. 假设槽位中不是特殊 thunk
复杂继承结构下可能不成立。
所以这段程序的定位应当是:
用于理解目标 ABI 的对象模型实验,而不是一种可以依赖的 C++ 编程技巧。
二十、最终知识闭环
现在可以把整套机制准确地总结为五层。
第一层:对象
cpp
A a;
在当前 ABI 下,对象开头通常保存一个隐藏的 vptr。
第二层:vptr
从对象开头读取一个指针大小的数据:
text
对象地址
↓
vptr 值
第三层:虚表地址点
vptr 指向 A 对应的虚表地址点。
地址点之前还可能存在 RTTI 和偏移信息。
第四层:虚函数槽位
根据 ABI 规定的槽位偏移读取地址:
text
vptr[0]
得到函数入口或 thunk 地址。
第五层:间接调用
调用方将对象地址作为隐藏的 this 参数,再传入普通参数:
text
this = &a
x = 8
随后跳转到槽位中的地址。
最终链路是:
text
A 对象
↓ 对象开头保存
vptr
↓ 指向
A 的虚表地址点
↓ 第 0 个槽位
A::Test 或相关 thunk
↓ 隐式传入 this
执行 A::Test(8)
真正需要记住的不是某一行危险的强制转换,而是下面这条逻辑:
多态对象通过自身携带的动态类型相关信息,使同一段调用代码能够在运行时选择不同的函数入口。
vptr 和 vtable,只是主流 C++ ABI 实现这一语义的具体方式。