C++ 虚表与多态:从源码到汇编的逐步解析(Animal / Dog / Cat)
本文基于代码随想录最强八股文给出的 C++ 源码 与对应的 x86-64(System V ABI 风格)反汇编,按"程序运行流程"一步步解释:
- 对象内存里 vptr(虚表指针) 在哪
- 构造函数如何 写入 vptr
Animal*指针如何通过 vtable 间接调用 到Dog::speak()/Cat::speak()- 为什么会看到
vtable for Animal+16
说明:反汇编属于 GCC/Clang 常见的 Itanium C++ ABI 风格(例如出现
vtable for X、typeinfo for X、std::__cxx11::basic_string...等符号)。不同编译器/优化等级会导致细节不同,但核心机制一致。
1. 源码回顾:我们要解释的"多态点"在哪里
源码(节选):
cpp
class Animal {
protected:
string name;
public:
Animal(string n) : name(n) {}
virtual void speak() {
cout << name << " is making a sound." << endl;
}
};
class Dog : public Animal {
public:
Dog(string n) : Animal(n) {}
void speak() override {
cout << name << " says: Woof!" << endl;
}
};
class Cat : public Animal {
public:
Cat(string n) : Animal(n) {}
void speak() override {
cout << name << " says: Meow!" << endl;
}
};
void animalSpeak(Animal* a) {
a->speak();
}
关键点只有一个: Animal::speak() 是 virtual。因此当你写 a->speak() 时,编译器必须在运行时根据 a 指向对象的真实类型(Dog/Cat)来决定调用哪个 speak()。
2. 对象内存布局:vptr 在对象最前面,name 紧随其后
只要一个类里存在虚函数,编译器通常会在对象里塞一个隐藏成员:vptr(指向虚表 vtable 的指针)。
在汇编中,反复出现 add rax, 8 来访问 name,这表明对象布局为:
this + 0 : vptr(8 字节)
this + 8 : std::string name(对象本体)
简图如下:
低地址
+--------------------+
| vptr (8B) | <-- this + 0
+--------------------+
| std::string name | <-- this + 8
+--------------------+
高地址
注意:
std::string的内部布局很复杂(SSO、小字符串优化等),本文不展开,只把它当作一个需要"构造/析构"的成员对象即可。
3. 运行流程总览:main 里发生了什么
main() 的逻辑是:
- 构造
Dog d("Buddy") - 构造
Cat c("Kitty") - 调用
animalSpeak(&d):期望触发Dog::speak() - 调用
animalSpeak(&c):期望触发Cat::speak() - 退出 main:析构
c、析构d
反汇编里你能看到类似(逻辑层面):
call Dog::Dog(...)call Cat::Cat(...)call animalSpeak(Animal*)两次call Cat::~Cat()、call Dog::~Dog()(离开作用域自动析构)
下面我们按这个流程逐段对齐。
4. Step 1:构造 Animal 子对象 ------ vptr 先被写成 Animal 的 vtable
4.1 Animal 构造函数做了两件事
源码:
cpp
Animal(string n) : name(n) {}
含义:
- 给对象写入 vptr(因为 Animal 有虚函数)
- 构造成员
name(调用std::string的拷贝构造/构造)
4.2 汇编:设置 vptr(最关键的 1 行)
Animal::Animal(...) 中有这一段:
asm
mov edx, OFFSET FLAT:vtable for Animal+16
mov rax, QWORD PTR [rbp-8] ; rax = this
mov QWORD PTR [rax], rdx ; *(this+0) = vptr
把它翻译成"等价伪 C++"就是:
cpp
*(void**)this = &vtable_for_Animal[0]; // 注意:这里的"[0]"指的是虚函数区起点
你可以把 mov [rax], rdx 看成:把对象头 8 字节写成虚表地址。
4.3 汇编:构造成员 name(this+8)
同一个构造函数里还有类似:
asm
mov rax, QWORD PTR [rbp-8]
lea rdx, [rax+8] ; rdx = this + 8 -> &name
mov rax, QWORD PTR [rbp-16] ; rax = 参数 n
mov rsi, rax
mov rdi, rdx
call std::__cxx11::basic_string<...>::basic_string(... const&)
重点是 lea rdx, [rax+8]:这就是在取 &this->name。
5. Step 2:构造 Dog ------ 先调用 Animal 构造,再把 vptr 改成 Dog 的 vtable
5.1 源码:Dog 构造函数
cpp
Dog(string n) : Animal(n) {}
5.2 汇编:先构造基类 Animal 子对象
在 Dog::Dog(...) 中,会看到(逻辑上):
- 先把参数
string n临时构造成一个对象(栈上) call Animal::Animal(...)- 释放临时
string - 设置 Dog 自己的 vptr
其中关键点是这句:
asm
call Animal::Animal(std::__cxx11::basic_string<...>) [base object constructor]
5.3 汇编:把对象的 vptr 最终改成 Dog
紧接着你又能看到:
asm
mov edx, OFFSET FLAT:vtable for Dog+16
mov rax, QWORD PTR [rbp-56] ; rax = this
mov QWORD PTR [rax], rdx ; *(this+0) = Dog 的 vptr
为什么要"改一次"?原因很直观:
- 在执行
Animal::Animal时,对象暂时被当成 "Animal 子对象" 来初始化,所以 vptr 会先指向 Animal。 - 当 Dog 自己构造完成后,对象的真实动态类型应当是 Dog,所以 vptr 必须指向 Dog 的虚表。
Cat::Cat(...) 也是同样的套路:先构造 Animal,再写 vtable for Cat+16。
6. Step 3:关键多态点 ------ animalSpeak(a) 如何通过 vtable 找到正确的 speak
6.1 源码:a->speak()
cpp
void animalSpeak(Animal* a) {
a->speak();
}
如果 a == &d(d 是 Dog),我们期待它调用 Dog::speak();
如果 a == &c(c 是 Cat),我们期待它调用 Cat::speak()。
6.2 汇编:典型的"虚调用"指针链
你贴出的 animalSpeak(Animal*) 里有这样的序列:
asm
mov rax, QWORD PTR [rbp-8] ; rax = a
mov rax, QWORD PTR [rax] ; rax = *a = vptr
mov rdx, QWORD PTR [rax] ; rdx = *(vptr+0) = vtable[0](第一个虚函数指针)
mov rax, QWORD PTR [rbp-8] ; rax = a
mov rdi, rax ; this 放入 rdi
call rdx ; 间接 call
把它翻译成"更直观的指针等价式":
cpp
// a 是 Animal*
void** vptr = *(void***)a; // 取对象头部 vptr
auto fn = (void(*)(Animal*))vptr[0]; // 取虚表第 0 个槽(这里就是 speak)
fn(a); // 以 a 作为 this 调用
这就是多态的本质:把"要调用的函数"变成"运行时从表里取出来的函数指针"。
7. Step 4:Dog::speak / Cat::speak / Animal::speak 汇编在做什么
源码(Dog 为例):
cpp
cout << name << " says: Woof!" << endl;
在 Dog::speak() 汇编中,关键点有两个:
- 取
this + 8作为name:
asm
mov rax, QWORD PTR [rbp-8] ; this
add rax, 8 ; this+8 -> &name
mov rsi, rax ; 作为 operator<< 参数
.LC1是常量字符串" says: Woof!",随后通过一系列operator<<输出到std::cout。
Animal::speak() 用 .LC0(" is making a sound."),Cat::speak() 用 .LC2(" says: Meow!"),模式相同。
8. 重点解惑:为什么是 vtable for Animal+16
你在汇编里看到:
asm
mov edx, OFFSET FLAT:vtable for Animal+16
同时在汇编末尾看到类似:
asm
vtable for Animal:
.quad 0
.quad typeinfo for Animal
.quad Animal::speak()
这三行在内存中是连续的 3 个 8 字节(共 24 字节)。我们把它按"偏移"写出来:
vtable for Animal +0 : 0
vtable for Animal +8 : typeinfo for Animal
vtable for Animal +16 : Animal::speak()
现在就能解释 +16 了:
+0和+8是 vtable 的"表头信息"(例如 RTTI 相关)- 从
+16开始才是虚函数指针区域 - 编译器让对象的 vptr 直接指向虚函数指针区域的起点 ,这样取第一个虚函数就可以用
*(vptr + 0)读取,少一次偏移换算
因此,当你看到:
asm
mov [this], vtable_for_Animal+16
可以理解为:
"把 vptr 指到 speak 的那一排函数指针数组开头。"
9. 程序退出:析构与清理(你汇编里看到的析构段)
你源码里没有写析构函数,但编译器仍会生成默认析构来销毁成员 std::string name。
在你贴出的 Animal::~Animal() 汇编里能看到:
- 先把 vptr 写回
vtable for Animal+16 - 对
this+8调用std::string析构:basic_string::~basic_string()
在 Dog::~Dog() / Cat::~Cat() 里,则是:
- 写回各自 vptr(Dog/Cat)
- 再
call Animal::~Animal()
另外你还看到很多 _Unwind_Resume:那是异常传播路径,表示"如果构造/输出过程中抛异常,需要按已经构造好的对象顺序逐一析构清理",属于编译器自动生成的异常安全框架。
animalSpeak(Animal* a) 执行:a->speak()
Cat 的虚表(槽 -> 函数实现)
Dog 的虚表(槽 -> 函数实现)
Cat 对象(内存布局)
Dog 对象(内存布局)
指向Dog对象
指向Cat对象
this = &Dog对象
this+0\] vptr vtable for Dog + 16 (虚函数槽数组起点) \[this+8\] name (std::string) this = \&Cat对象 \[this+0\] vptr vtable for Cat + 16 (虚函数槽数组起点) \[this+8\] name (std::string) slot\[0\] : speak Dog::speak() 代码地址 slot\[0\] : speak Cat::speak() 代码地址 a : Animal\* (静态类型 Animal\*) 运行时可能指向 Dog/Cat 1) vptr = \*(void\*\*)a (读对象头部 vptr) 2) fn = ((void\*\*)vptr)\[0
(取槽 slot[0])
3) call fn(a)
(间接调用,a 作为 this)
关键点:slot[0] 的地址 = vptr + 0 = vtable + 16
slot[0] 的内容 = *(vtable + 16) = 你看到的 第三个 .quad(比如 Dog::speak() / Cat::speak())
10. 一句话总结(把源码和汇编串起来)
- 构造阶段 :构造函数把
vptr写进对象头(mov [this], vtable+16),并构造成员name(this+8)。 - 调用阶段 :
animalSpeak通过a -> vptr -> vtable[0]取到函数指针并call,从而实现运行时多态。 - 退出阶段 :析构时销毁
name,并处理异常清理路径。