这篇文章是我自己学虚函数时的"路径复盘"。我不是从背概念开始,而是用 VS 的反汇编 把虚调用指令看明白、再用 IDA 把二进制里的 vtable/vptr/RTTI 直接扒出来确认。
全文按 5 个问题分 5 个模块,每个模块都尽量给到:关键结论 → 为什么 → 我怎么验证 → 代码/反汇编/IDA 现象,方便我自己复习也方便别人照着走。
模块 1:虚函数基础知识------我先把"地图"画出来
1. 虚函数到底解决什么问题
虚函数的目的就是:让"基类指针/引用"在调用成员函数时,能在运行期决定调用哪个派生类实现,也就是运行时多态。
核心关键词:
- 动态绑定(dynamic binding)
- 运行时多态(runtime polymorphism)
- 接口一致 / 实现可替换
最典型的场景:
ini
Base* p = new Derived;
p->f(); // f 是 virtual → 调 Derived::f;不是 virtual → 调 Base::f
2. 两种绑定:静态绑定 vs 动态绑定
- 非虚函数:编译期就决定目标(静态绑定)
- 虚函数:运行期根据对象真实类型决定(动态绑定)
我理解它的关键是: "调用点看到的静态类型"≠"对象真实类型" 。
虚函数就是把"选择哪个函数"的决策推迟到运行时。
3. 虚函数语法规则(必须背熟)
virtual写在 基类 函数声明上即可;派生类建议写overrideoverride:保证你真的在重写(签名不匹配会直接编译报错)final:禁止继续重写(对类/对成员函数都可用)- 重写的签名匹配非常严格 :参数、返回类型、
const、引用限定符都算签名的一部分 - 返回类型可协变(返回指针/引用时允许更派生)
4. "重载 vs 重写 vs 隐藏"三者必须分清
- 重载 overload:同一作用域,参数不同
- 重写 override:派生类替换基类虚函数实现(签名匹配)
- 隐藏 hide:派生类声明同名函数会把基类同名都"遮住"(哪怕参数不同)
隐藏的解决:
arduino
using Base::f; // 把 Base::f 引入派生类作用域
// 或者在调用处明确写 Base::f()
5. 虚析构:为什么几乎总要写
我现在的原则很简单:
只要这个类可能作为多态基类使用(可能被继承 + 可能用 Base delete 派生对象),析构函数就应该是 virtual。 *
否则经典坑:
ini
Base* p = new Derived;
delete p; // 只调用 Base 析构 → 资源泄露/UB
一句话结论我会背:
"有虚函数/要做多态基类的类,析构函数通常要是 virtual。"
6. vptr / vtable 的直观模型(理解层面)
- 多态对象内部通常有一个隐藏指针:vptr
- vptr 指向一张表:vtable(函数指针数组)
- 虚调用大致是:
obj.vptr[slot](obj, args...)(本质是间接跳转)
我不死记 ABI 细节,但必须理解:虚调用比普通调用多一次"间接寻址/间接跳转" 。
7. 构造/析构里调用虚函数:到底调谁(这里先留个结论,模块 4 详讲)
结论先记:
构造/析构期间虚调用按"当前层"分派,不会越级到更派生层。
8. 纯虚函数与抽象类
virtual void f() = 0;→ 纯虚函数 → 类是抽象类,不能直接实例化- 抽象类常用于定义接口
- 纯虚函数也可以有定义(少见,但允许)
9. object slicing(对象切片)
ini
Base b = Derived(); // 派生部分被切掉,多态丢失
多态要用指针/引用承载,不要用值类型承载派生对象。
10. dynamic_cast / RTTI(模块 5 详讲)
先记一句:
只有多态类型(通常有虚函数)才有 RTTI 支撑,dynamic_cast 才能工作。
11. 性能与代价(不夸张,但要会说)
- 对象多一个 vptr(通常 8 字节)
- 每次虚调用多一次间接跳转,影响内联/分支预测
- 大多数业务不敏感;真要优化再考虑去虚化、
final、LTO、CRTP 等
模块 2:虚函数基本调用原理------我在 VS 反汇编里看到的到底是什么
这个模块我想搞懂的只有两件事:
- 普通成员函数调用:汇编里通常是 直接 call 固定符号
- 虚函数调用:汇编里通常是 通过 vptr/vtable 查槽位,再间接 call
1) 我用的最小示例代码(Debug 下就够)
arduino
#include <cstdio>
volatile int g_sink = 0;
struct Base {
void NonVirtual(int x) { g_sink += x + 1; }
virtual int Virtual(int x) { g_sink += x + 10; return g_sink; }
virtual ~Base() = default;
};
struct Derived : Base {
int Virtual(int x) override { g_sink += x + 20; return g_sink; }
};
int main() {
Derived d;
Base* p = &d;
p->NonVirtual(1); // 非虚:静态绑定
p->Virtual(2); // 虚:动态绑定(分派到 Derived::Virtual)
std::printf("%d\n", g_sink);
}
2) Windows x64 调用约定(看反汇编必须知道)
成员函数有隐藏参数 this:
- RCX = this
- RDX = 第一个显式参数
- 之后 R8/R9,再往后栈
所以看到:
mov rcx, ...通常是在准备 thismov edx, 2通常是在准备参数 2
3) 非虚函数调用:我看到的是"直接 call"
语义:p->NonVirtual(1);
反汇编特征:
- 目标固定:
call Base::NonVirtual - 没有取 vptr/查表
我对它的总结:
编译器编译期已经决定:这句一定调用 Base::NonVirtual(因为它不是 virtual)。
4) 虚函数调用:我看到的是"取 vptr → 查槽位 → 间接 call"
语义:p->Virtual(2);
我在 VS 里看到过(你也贴过)这种典型序列:
ini
mov rax, qword ptr [p] ; rax = p(对象地址)
mov rax, qword ptr [rax] ; rax = *(void**)p(对象头8字节:vptr→vtable地址)
mov edx, 2 ; 参数x=2
mov rcx, qword ptr [p] ; this=p
call qword ptr [rax] ; 间接调用 vtable[0]
逐行理解:
[p]:从局部变量取出对象地址[rax]:从对象内存开头取出 vptr(对象头 8 字节)call qword ptr [rax]:用 vtable 的第 0 槽位做间接调用(真正的动态分派发生在这里)
伪代码对应:
ini
auto vtable = *(void***)p;
auto fp = (FnType)vtable[0];
fp(p, 2);
Debug 模式里你看到"多读一次 p"(先给 rax 又给 rcx)很常见,是保守生成,不用纠结。
5) 槽位偏移:第二个槽位为什么要 +8
在单继承 + x64 指针 8 字节的场景下:
- slot0:
[vtable + 0]→call [rax] - slot1:
[vtable + 8]→call [rax+8] - slot2:
[vtable + 16]→call [rax+16]
公式:
slot_addr = vtable + index * 8
(多继承/虚继承会复杂很多,但我这个入门例子按这个理解完全正确。)
6) 构造函数为什么会出现 call(我当时的疑问)
我当时在反汇编里看到过:
arduino
lea rcx, [d]
call Derived::Derived
解释:
lea rcx,[d]:把栈上对象 d 的地址算出来传给 RCX(this)call Derived::Derived:调用构造函数入口
关键结论我后来确认了:
不是"因为虚函数所以系统额外分配构造函数",而是:有虚函数→对象需要 vptr→构造时必须初始化 vptr→默认构造不再 trivial→必须生成/执行初始化代码。
7) 构造顺序:先进入派生构造,再构造基类?
从"调用结构"看,确实是 call Derived::Derived 先进入派生构造入口;
但在 Derived::Derived 内部会先 call Base::Base。
语义顺序我记成一句:
Base 子对象 → 成员 → Derived 构造体。
"入口先到 Derived"与"语义先构造 Base"并不矛盾,因为 Base 构造发生在 Derived 构造函数内部的最前面阶段。
模块 3:vtable 在二进制哪里、长啥样------我用 IDA 怎么把它"扒出来看懂"
这个模块我想搞清楚两点:
- 在 Windows/MSVC 下,vtable 在 PE 的哪个段
- vtable 附近到底有哪些数据(虚函数槽位、析构入口、RTTI)
1) vtable 在哪个段(我在 IDA 里看到的结论)
在我这个 Windows/MSVC/PE 的例子里:
- vtable(MSVC 常叫 vftable)通常在
.rdata只读数据段 - RTTI 相关数据也多在
.rdata
我在 IDA 里看到地址前缀 .rdata:,这就是直接证据。
2) 我定位 vtable 的最稳方式:从构造函数写 vptr 反推
我当时 IDA 不熟,但这个方法特别"机械":
- 找到
Derived::Derived - 在里面找到写 vptr 的那几行(你也贴过):
-
先
call Base::Base -
然后:
lea rcx, ??_7Derived@@6B@mov [this], rcx
这说明对象头的 vptr 被写成 ??_7Derived@@6B@。
- 对
??_7Derived@@6B@回车 → 直接跳到 vtable 本体。
3) vtable 附近我实际看到的布局(MSVC 典型)
在 IDA 里它往往不是"孤零零一张表",而是:
- vtable[-1] :RTTI Complete Object Locator(COL)指针
典型符号:??_R4Derived@@6B@ - vtable 起点 :
??_7Derived@@6B@(Derived::'vftable')
vptr 指向这里 - 从 vtable 起点往下 :一串
dq offset ...(每个 8 字节一个槽位)
我当时还遇到一个疑问:
"为什么我看到第一个是 Base::Virtual?"
后来我确认:那是因为 .rdata 里Base 和 Derived 的两张表挨着放 ,我当时看到的是 ??_7Base@@6B@ 那张表的 slot0。
4) vtable 里到底有哪些条目(我这个例子:1 个虚函数 + 虚析构)
在 IDA 里我看到 vftable'[3],意味着函数槽位数组长度是 3。对 Derived 来说常见是:
- slot0:我定义的
Virtual(int)(经常显示成j_?Virtual...,j_是 jump thunk) - slot1:deleting destructor 相关入口(IDA 常标 scalar deleting destructor,符号常见
??_G...) - slot2:另一个 deleting destructor 入口(常见
??_E...vector deleting destructor 或 thunk;有时 IDA 显示成dq (offset qword_xxx + ...))
我当时的一个疑问:为什么 Base 表里看起来没有 ??_G?
后来我把它当作"实现细节"处理:
- MSVC 里
??_E很多时候是更通用的入口(带 flags) ??_G可能是 wrapper/thunk,可能被合并/折叠/不以你期待的方式显示- 截图里出现的
dq 0很可能只是 padding/对齐
我的最终经验:
不要只看名字猜,最靠谱是对每个
dq offset ...回车跟进去,看它最终跳到哪、做了什么。
5) 我最后怎么回答"vtable 在哪 + 里面有什么"
我现在会这样答(更严谨):
在 Windows/MSVC 下,vtable(vftable) 通常位于 PE 的
.rdata段。对象内含 vptr 指向??_7Class@@6B@这样的 vtable 起点。vtable 本体是一串 8 字节槽位(函数指针或 thunk),包含类的虚函数入口;若类有虚析构,表里还常包含 MSVC 的 deleting destructor 相关入口(??_E/??_G或跳板)。vtable 前面通常紧挨着 RTTI 的 Complete Object Locator(??_R4...),用于typeid/dynamic_cast。
模块 4:构造/析构里调虚函数到底调谁------我一开始答错的经典题
我当时的初步回答是:应该调派生类的虚函数 。
这个回答是不对的(至少在 Base 构造/析构阶段不对)。
1) 标准结论(我现在的最终答案)
构造/析构期间的虚调用只会分派到"当前正在构造/析构的那一层",不会越级到更派生层。
因此:
- Base 构造/析构里 调用虚函数 → 调 Base 版本
- Derived 构造/析构里 调用虚函数 → 调 Derived 版本
- Base 阶段不会跑去调 Derived override
2) 我为什么相信这个结论(我在 IDA 里看到了 vptr 的"分阶段")
我在 Derived::Derived 里看到非常关键的顺序:
-
call Base::Base -
Base 构造返回后,Derived 构造才写:
vptr = &??_7Derived@@6B@
这意味着:在 Base::Base 执行期间,vptr 指向 Base 的 vtable 。
所以 Base 构造里调用虚函数,只能查 Base 表 → 调 Base::Virtual。
析构过程反过来:派生部分先销毁,再进入 Base 析构阶段,虚调用同样不会去调用已经"生命周期结束"的派生实现。
3) 直观记忆法(我用这个解释给别人听)
- Base 构造时:Derived 部分还没构造好,调用 Derived 虚函数可能访问未初始化成员 → 不安全
- Base 析构时:Derived 部分已销毁,调用 Derived 虚函数可能访问已销毁资源 → 不安全
所以语言/实现让虚调用"按当前层绑定"。
4) 面试一句话版(我现在背这个)
构造/析构期间虚调用不会向下分派到更派生类,只会落到当前构造/析构层对应的实现。
模块 5:dynamic_cast 是什么、怎么实现、和 vtable 有什么关系
这个模块我想搞懂 3 件事:
- dynamic_cast 在语义上到底做什么、失败怎么表现
- 它为什么要求"多态类型"
- 我在 IDA 里看到的 RTTI(
??_R4)与 vtable 的关系是什么
1) dynamic_cast 是什么(解决什么问题)
dynamic_cast 是 运行时安全类型转换,在多态体系里用来:
- 向下转型 :
Base* -> Derived* - 横向转型(cross-cast) :多继承时 A* → B*
- 转成 void* :得到 most-derived 对象起始地址
它的价值是:运行时检查真实类型,不合法就失败,而不是 UB。
对比:
static_cast:编译期转换,不做运行时检查(向下转型错了会 UB)reinterpret_cast:比特重解释(几乎不用于类型安全)
2) 规则与失败行为
2.1 只能对"多态类型"做(关键前提)
对类指针/引用做 dynamic_cast,源类型必须是多态类型(至少一个 virtual;最常见是 virtual destructor)。
原因:dynamic_cast 需要 RTTI。没有虚函数通常也就没有可靠的 RTTI 入口。
(补充:编译器关 RTTI,如 MSVC /GR-,这类 dynamic_cast 通常不可用。)
2.2 指针 vs 引用:失败行为不同
dynamic_cast<Derived*>(p)失败返回nullptrdynamic_cast<Derived&>(r)失败抛std::bad_cast
3) 它怎么实现(我用一句话概括)
dynamic_cast 依赖 RTTI;RTTI 通常能通过对象的 vptr 间接找到。运行时读取对象真实类型信息,在继承图里判断目标类型是否合法,并在多继承/虚继承时计算指针调整,成功返回调整后的指针,失败返回空/抛异常。
4) 它和 vtable/vptr 的关系(我在 IDA 里看到的"实锤")
我在 IDA 里看到过:
??_7Derived@@6B@:Derived 的 vtable 起点- 它前面紧挨着
??_R4Derived@@6B@:RTTI Complete Object Locator
这就是关键关系:
- 对象头有 vptr:
vptr = *(void**)this - vptr 指向 vtable(
.rdata) - vtable 的"前一格"(常见 vtable[-1])指向 RTTI 的定位结构(COL)
- dynamic_cast 就是通过这套 RTTI 结构去做类型判断和偏移计算
所以我现在能一句话回答"关系是什么":
dynamic_cast 的 RTTI 信息通常通过 vptr 可达,在 MSVC 下 RTTI 的 COL 常紧挨在 vtable 前面;因此 dynamic_cast 会先经由 vptr 找到 RTTI,再做类型匹配与指针调整。
5) dynamic_cast 的运行时流程(我记成 5 步)
以 Derived* pd = dynamic_cast<Derived*>(pb); 为例:
- 从
pb指向对象读 vptr - 通过 vptr 找到 RTTI(真实类型 = most-derived type)
- 判断真实类型是否包含目标子对象、转换是否语义合法
- 若合法:计算指针调整(多继承/虚继承时关键)
- 返回调整后的指针;否则返回
nullptr/抛bad_cast
6) 为什么多继承时 dynamic_cast 特别关键(我用这个例子记)
less
struct A { virtual ~A(){} };
struct B { virtual ~B(){} };
struct D : A, B {};
A* pa = new D;
B* pb = dynamic_cast<B*>(pa); // cross-cast,需要运行时算 B 子对象偏移
这里 pa 指向的是 D 里 A 子对象那块内存,要转成 B* 必须做"指针平移"。
这个平移量只有 RTTI 可靠知道,所以这类场景 dynamic_cast 很有意义。
我自己的最终"复习闭环"
到这里我对虚函数的入门闭环是这样的:
- 概念地图:虚函数解决多态,语法规则/隐藏/虚析构/切片/代价
- VS 反汇编:非虚 call 固定目标;虚调用查 vptr → vtable → slot 间接 call
- IDA 实锤 :vtable 在
.rdata;vptr 写入发生在构造;vtable 附近有 RTTI(??_R4) - 构造/析构虚调用:按当前层绑定,不越级
- dynamic_cast:靠 RTTI(经由 vptr 可达)做运行时检查 + 指针调整