1. 从对象内存布局说起:vptr和vtable是怎么混进来的
1.1 普通成员函数不占对象空间,为什么加个virtual就变了
很多C++初学者都有过这种困惑:一个类里写了十几个成员函数,sizeof(对象)依然等于成员变量的大小之和,感觉函数是"白送"的。确实,非虚成员函数本质上是普通的全局函数,编译器只是把this指针作为隐藏参数传进去而已,对象内存里只需要存放成员变量。
但一旦类里出现了virtual关键字,事情就变了。哪怕这个类只有一个int成员变量再加一个虚函数,sizeof都会从4变成16(64位系统下是8或者16)。凭空多出来的这部分,就是编译器悄悄塞进去的一个指针——我们习惯叫它vptr(virtual table pointer),它指向该对象所属类的那张虚函数表(vtable)。
这个指针在对象内存里通常位于起始位置,也就是偏移量为0的地方。这样做的好处是:编译器在编译阶段就能确定vptr的偏移,生成代码时不需要额外的查表操作去定位它。我们后面做实验时也会基于"vptr在对象开头"这个事实来读取。
提示:vptr的具体位置属于ABI(应用二进制接口)层面的约定,不是C++标准强制的。但主流的Itanium C++ ABI和MSVC ABI都把vptr放在对象起始处,至少对单继承类来说是这样。我们讨论的是实践中最普遍的现象。
1.2 虚函数表里到底存了什么
虚函数表本质上就是一个数组,数组里每个元素是一个函数指针。存放顺序是有讲究的:编译器按照虚函数在类中首次声明的顺序排列这些函数指针,基类的虚函数排在前,派生类新增的虚函数接在后面,如果派生类覆盖了基类的某个虚函数,那张表里对应位置的指针就会被替换成派生类版本的地址。
假如我们定义下面这个类:
cpp复制class Base {
public:
virtual void f1() { std::cout << "Base::f1" << std::endl; }
virtual void f2() { std::cout << "Base::f2" << std::endl; }
void notVirtual() { std::cout << "Base::notVirtual" << std::endl; }
};
class Derived : public Base {
public:
void f1() override { std::cout << "Derived::f1" << std::endl; }
virtual void f3() { std::cout << "Derived::f3" << std::endl; }
};
那么Derived的vtable布局大致如下:
| 槽位索引 | 函数指针指向 |
|---|---|
| 0 | Derived::f1()(覆盖了Base版本) |
| 1 | Base::f2()(没有被覆盖,直接用基类的) |
| 2 | Derived::f3()(派生类新添加的虚函数) |
普通成员函数notVirtual不会进这张表,因为它不需要动态解析。只要编译器在编译期知道对象的静态类型,就可以直接生成一条call指令调到固定地址去。
1.3 对象构造的瞬间,vptr是被赋值出来的
这里有个很多人没认真想过的细节:vptr不是"天生"在对象里的,它是在构造函数里被写入的。编译器在每个构造函数(包括编译器自动生成的默认构造函数)的起始位置都会插入一段代码,把当前类的vtable地址赋值给对象的vptr。也就是说,vptr的赋值发生在成员初始化列表之前、构造函数体执行之前。
正因为vptr是构造函数写入的,所以对象在内存里真正"活过来"的标志之一,就是vptr指向了正确的vtable。如果我们用memset把对象内存清零,再调用虚函数,大概率会直接段错误——因为vptr变成nullptr了。
这个特点在调试时特别有用。碰到莫名其妙的崩溃,如果崩溃现场是在打电话虚函数,第一步基本都会去看对象的vptr是否正常,vptr不对就说明对象内存被破坏、生命周期已经结束、或者用了未定义行为把对象字节覆盖了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 亲手读取虚表:把编译器的底裤扒出来看看
2.1 在C++里取出vptr的两种土办法
理论说再多,不如动手看一眼。我经常在实战排查里用一些"非正规"手段来验证类布局,虽然这些手段依赖于当前平台和编译器实现,但用来理解机制非常有帮助。
第一种办法是利用对象地址转换。在64位Linux下,vptr占用前8个字节:
cpp复制#include <iostream>
class Base {
public:
virtual void f1() { std::cout << "Base::f1" << std::endl; }
virtual void f2() { std::cout << "Base::f2" << std::endl; }
int x = 42;
};
int main() {
Base b;
// 取出对象首地址的前8个字节,作为vptr
long long vptr = *reinterpret_cast<long long*>(&b);
s
