【无标题】

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 Xtypeinfo for Xstd::__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() 的逻辑是:

  1. 构造 Dog d("Buddy")
  2. 构造 Cat c("Kitty")
  3. 调用 animalSpeak(&d):期望触发 Dog::speak()
  4. 调用 animalSpeak(&c):期望触发 Cat::speak()
  5. 退出 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(...) 中,会看到(逻辑上):

  1. 先把参数 string n 临时构造成一个对象(栈上)
  2. call Animal::Animal(...)
  3. 释放临时 string
  4. 设置 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() 汇编中,关键点有两个:

  1. this + 8 作为 name
asm 复制代码
mov     rax, QWORD PTR [rbp-8]  ; this
add     rax, 8                  ; this+8 -> &name
mov     rsi, rax                ; 作为 operator<< 参数
  1. .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. 一句话总结(把源码和汇编串起来)

  1. 构造阶段 :构造函数把 vptr 写进对象头(mov [this], vtable+16),并构造成员 namethis+8)。
  2. 调用阶段animalSpeak 通过 a -> vptr -> vtable[0] 取到函数指针并 call,从而实现运行时多态。
  3. 退出阶段 :析构时销毁 name,并处理异常清理路径。
相关推荐
楚Y6同学2 小时前
为什么 C++ 要设计函数重载
开发语言·c++
码云数智-大飞2 小时前
PHP OPcache 深度调优:从性能陷阱到生产环境最佳实践
开发语言
weixin_433179332 小时前
Python - 调试
java·开发语言·python
Elastic 中国社区官方博客2 小时前
我们如何修复 OpenTelemetry 中基于 head 的采样
大数据·开发语言·python·elasticsearch·搜索引擎
20岁30年经验的码农2 小时前
Java NIO底层实现原理
开发语言·php
Trouvaille ~2 小时前
【项目篇】从零手写高并发服务器(十):性能测试与项目总结
linux·运维·c++·reactor·性能测试·高并发服务器·webbench
C++ 老炮儿的技术栈2 小时前
Tcp客户端报错原因分析
linux·c语言·网络·c++·网络协议·tcp/ip
飞鱼计划2 小时前
EasyExcel 3.3.2 模板方式写入数据完整指南
java·开发语言
xiaoye-duck2 小时前
《算法题讲解指南:优选算法-哈希表》--56.两数之和,57.判断是否互为字符重排
c++·算法·哈希表