1.1 什么是 vptr 和 vtable
C++ 通过虚函数表(vtable)实现运行时多态。当一个类含有虚函数时,编译器会:
为这个类生成一张虚表(vtable):本质是一个函数指针数组,里面存放该类所有虚函数的地址。
在每个对象的内存布局最前面插入一个隐藏指针(vptr):指向该类的 vtable。
也就是说,一个含虚函数的对象,内存布局大致是:
+-------------------+ | vptr | <- 8 字节(64 位系统),指向 vtable +-------------------+ | 成员变量 1 | +-------------------+ | 成员变量 2 | +-------------------+ | ... | +-------------------+1.2 关键区别
| 概念 | 位置 | 说明 |
|---|---|---|
| vptr | 在对象内存里 | 每个对象一份,指向所属类的 vtable |
| vtable | 在只读数据段(.rodata) | 每个类一份,被所有对象共享 |
不同编译器(如 MSVC)的 vtable 布局可能不同,但核心思想一致。64 位系统下指针是 8 字节,vptr 占 8 字节。
二、实验代码
#include <iostream> class Base { public: Base() : a(0x11) {} virtual ~Base() {} virtual void func1() { std::cout << "Base::func1\n"; } virtual void func2() { std::cout << "Base::func2\n"; } virtual int func3(int x) { return x + 1; } int a; }; class Derived : public Base { public: Derived() : b(0x22) {} ~Derived() override {} void func1() override { std::cout << "Derived::func1\n"; } void func2() override { std::cout << "Derived::func2\n"; } virtual void func4() { std::cout << "Derived::func4\n"; } int b; }; int main() { Base base; Derived derived; Base* p1 = &base; Base* p2 = &derived; p1->func1(); p2->func1(); return 0; }编译时必须加-g保留调试信息,并关闭优化避免布局被改变:
g++ -g -O0 -o vtable_demo vtable_demo.cpp //-g 生成调试信息,供 gdb 等调试器使用 //-O0 关闭所有优化(这是默认级别,显式写出是为了强调)优化级别对比
| 级别 | 含义 | 编译速度 | 运行性能 | 调试友好度 |
|---|---|---|---|---|
-O0 | 不优化(默认) | 最快 | 最差 | 最好 |
-O1 | 基础优化 | 快 | 中等 | 较好 |
-O2 | 中等偏强优化 | 中等 | 好 | 一般 |
-O3 | 激进优化 | 慢 | 最好(多数情况) | 差 |
-Os | 优化代码体积 | 中等 | 较好 | 一般 |
-Ofast | 超越-O3,可能违反标准 | 慢 | 最高 | 差 |
三、用 GDB 观察 vptr
3.1 启动 GDB 并设置断点
gdb ./vtable_demo在 GDB 里执行:
(gdb) break main (gdb) run程序停在main开头。
设置断点的基本语法
break <位置> # 简写 b //当前文件的某一行: (gdb) b 42 //指定文件的某一行 (gdb) b vptr.cpp:42 //如果文件不在当前目录,GDB 可能找不到,需要用相对或绝对路径 (gdb) b /home/user/project/vptr.cpp:42 //普通函数 (gdb) b main (gdb) b func1 //类成员函数 (gdb) b Derived::func1 (gdb) b Base::func1 //带参数的函数名 (gdb) b Derived::func1(int) (gdb) b Derived::func1(const std::string&) //指定文件里的函数 (gdb) b vptr.cpp:Derived::func13.2 单步到对象构造完成
(gdb) next # 执行 Base base; (gdb) next # 执行 Derived derived;现在base和derived都已构造完成,vptr 已经被设置。
3.3 查看对象的内存布局
查看 base 对象:
(gdb) print base输出类似:
$1 = {_vptr.Base = 0x555555557d30 <vtable for Base+16>, a = 17}(gdb) print derived输出类似:
$2 = {<Base> = {_vptr.Base = 0x555555557d10 <vtable for Derived+16>, a = 17}, b = 34}可以看到,derived的 vptr 指向Derived的 vtable(地址不同)。
💡
+16是什么意思?这是 GDB 的友好提示:vtable 数组前面还有 2 个 8 字节的元数据(offset-to-top 和 typeinfo 指针),所以 vptr 实际指向的是 vtable 的第 3 个槽位(偏移 16 字节)。后面会详细讲。
3.4 直接查看对象内存的原始字节
上面的print是 GDB 用调试信息“美化”后的结果。要看真实内存,用x命令:
(gdb) x/4gx &base输出类似:
0x7fffffffe2d0: 0x0000555555557d30 0x0000000000000011 0x7fffffffe2e0: 0x0000000000000000 0x0000000000000000解读:
第 1 个 8 字节
0x0000555555557d30:vptr,指向 Base 的 vtable。第 2 个 8 字节
0x0000000000000011:成员a = 0x11 = 17。
(gdb) x/4gx &derived输出类似:
0x7fffffffe2c0: 0x0000555555557d10 0x0000000000000011 0x7fffffffe2d0: 0x0000000000000022 0x0000000000000000解读:
第 1 个 8 字节
0x0000555555557d10:vptr,指向 Derived 的 vtable。第 2 个 8 字节
0x0000000000000011:基类成员a = 0x11。第 3 个 8 字节
0x0000000000000022:派生类成员b = 0x22。
注意:Derived对象的成员a和b是连续存放的,vptr 在最前面。
四、深入观察 vtable 的内容
4.1 找到 vtable 的起始位置
前面看到base的 vptr 是0x555555557d30,GDB 提示它等于vtable for Base+16。这意味着 vtable 的真正起始地址是:
0x555555557d30 - 16 = 0x555555557d20用 GDB 查看这个地址开始的内存:
(gdb) x/8gx 0x555555557d20输出类似:
0x555555557d20: 0x0000000000000000 0x0000555555557c80 0x555555557d30: 0x0000555555555b0a 0x0000555555555b2c 0x555555557d40: 0x0000555555555b4e 0x0000555555555b70 0x555555557d50: 0x0000555555555b92 0x00000000000000004.2 解析 vtable 结构
对于 GCC 的 Itanium C++ ABI,vtable 的布局是:
+---------------------------+ | offset-to-top (8 字节) | <- vtable 起始 +---------------------------+ | typeinfo 指针 (8 字节) | <- 指向 RTTI 信息 +---------------------------+ | 虚函数 1 地址 (8 字节) | <- vptr 指向这里 +---------------------------+ | 虚函数 2 地址 (8 字节) | +---------------------------+ | ... | +---------------------------+所以上面的内存解读是:
| 地址 | 值 | 含义 |
|---|---|---|
0x555555557d20 | 0x0 | offset-to-top = 0(单继承,无偏移) |
0x555555557d28 | 0x555555557c80 | typeinfo 指针(指向typeinfo for Base) |
0x555555557d30 | 0x555555555b0a | 虚函数 1 地址(vptr 指向这里) |
0x555555557d38 | 0x555555555b2c | 虚函数 2 地址 |
0x555555557d40 | 0x555555555b4e | 虚函数 3 地址 |
0x555555557d48 | 0x555555555b70 | 虚函数 4 地址(析构相关) |
4.3 把地址翻译成函数名
用info symbol把地址翻译成函数名:
(gdb) info symbol 0x555555555b0a输出类似:
Base::func1() in section .text依次查看:
(gdb) info symbol 0x555555555b2c (gdb) info symbol 0x555555555b4e (gdb) info symbol 0x555555555b70你会看到类似:
Base::func2() in section .text Base::func3(int) in section .text Base::~Base() in section .text这证明了:vtable 里按声明顺序存放了虚函数的地址。
4.4 对 Derived 的 vtable 做同样操作
(gdb) x/8gx 0x555555557d00 (gdb) info symbol 0x555555555b0a # 应该看到 Derived::func1你会看到Derived的 vtable 里,func1和func2指向的是Derived的版本(地址不同),这就是虚函数覆盖(override)在内存层面的体现。
五、观察运行时多态的真实过程
5.1 在多态调用处下断点
(gdb) break vtable_demo.cpp:39 (gdb) continue或者直接在p2->func1()那行下断点。
5.2 查看调用时的 vptr
当程序停在p2->func1()时:
(gdb) print p2 (gdb) print *p2你会看到p2是Base*类型,但*p2的 vptr 指向的是Derived的 vtable。这就是动态绑定的关键:
编译器生成的代码是:
(*p2->vptr[0])(p2),即通过 vptr 找到 vtable,再取第 0 个函数指针调用。由于p2实际指向Derived对象,vptr 指向Derived的 vtable,所以调用的是Derived::func1。
5.3 反汇编验证
(gdb) disassemble /m main你会看到类似:
mov -0x18(%rbp), %rax ; 把 p2 加载到 rax mov (%rax), %rax ; 取 vptr(rax 指向对象首地址) mov (%rax), %rax ; 取 vtable[0](func1 地址) mov -0x18(%rbp), %rdi ; 把 p2 作为 this 参数 call *%rax ; 间接调用这就是多态的底层实现:两次解引用(vptr → vtable → 函数)。
六、进阶实验:多重继承的 vptr
如果类有多重继承,对象里会有多个 vptr。修改代码:
class A { public: virtual void fa() {} int a; }; class B { public: virtual void fb() {} int b; }; class C : public A, public B { public: void fa() override {} void fb() override {} int c; };用 GDB 查看C对象:
(gdb) x/8gx &c你会看到两个 vptr,分别指向C的两个 vtable(主 vtable 和次 vtable)。这是多重继承下对象布局的典型特征。
七、常见坑与注意事项
| 坑 | 说明 |
|---|---|
| 优化等级 | -O2会打乱布局,必须用-O0 |
| 调试信息 | 必须加-g,否则 GDB 无法识别类型 |
| vtable 地址不固定 | 每次运行 ASLR 会改变地址,用info symbol而非硬编码 |
| MSVC 布局不同 | Windows 下 MSVC 的 vtable 布局与 GCC 不同,本教程不适用 |
| 虚析构函数 | 析构函数也是虚函数,会占用 vtable 槽位 |
| 纯虚函数 | 纯虚函数在 vtable 里通常指向__cxa_pure_virtual,调用会崩溃 |
八、完整的 GDB 命令速查
# 启动与断点 gdb ./vtable_demo break main run # 查看对象 print base print derived print *p2 # 查看内存 x/4gx &base x/4gx &derived # 查看 vtable x/8gx 0x555555557d20 # 地址翻译 info symbol 0x555555555b0a # 反汇编 disassemble /m main # 查看类型信息 ptype Base ptype Derived九、总结:一张图看懂全貌
对象内存 vtable(.rodata) +----------------+ +---------------------+ | vptr ---------|-----------> | offset-to-top = 0 | +----------------+ +---------------------+ | 成员 a = 0x11 | | typeinfo ptr | +----------------+ +---------------------+ | &Base::func1 | +---------------------+ | &Base::func2 | +---------------------+ | &Base::func3 | +---------------------+ | &Base::~Base | +---------------------+多态调用p->func1()的底层路径:
p -> vptr -> vtable[0] -> Base::func1 或 Derived::func1推荐一个零声教育学习教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx ,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习:链接