C++ 虚表与多态:从源码到汇编的逐步解析
本文基于代码随想录最强八股文给出的C 源码与对应的x86-64System V ABI 风格反汇编按“程序运行流程”一步步解释对象内存里vptr虚表指针在哪构造函数如何写入 vptrAnimal*指针如何通过vtable 间接调用到Dog::speak()/Cat::speak()为什么会看到vtable for Animal16说明反汇编属于 GCC/Clang 常见的 Itanium C ABI 风格例如出现vtable for X、typeinfo for X、std::__cxx11::basic_string...等符号。不同编译器/优化等级会导致细节不同但核心机制一致。1. 源码回顾我们要解释的“多态点”在哪里源码节选classAnimal{protected:string name;public:Animal(string n):name(n){}virtualvoidspeak(){coutname is making a sound.endl;}};classDog:publicAnimal{public:Dog(string n):Animal(n){}voidspeak()override{coutname says: Woof!endl;}};classCat:publicAnimal{public:Cat(string n):Animal(n){}voidspeak()override{coutname says: Meow!endl;}};voidanimalSpeak(Animal*a){a-speak();}关键点只有一个Animal::speak()是virtual。因此当你写a-speak()时编译器必须在运行时根据a指向对象的真实类型Dog/Cat来决定调用哪个speak()。2. 对象内存布局vptr 在对象最前面name 紧随其后只要一个类里存在虚函数编译器通常会在对象里塞一个隐藏成员vptr指向虚表 vtable 的指针。在汇编中反复出现add rax, 8来访问name这表明对象布局为this 0 : vptr8 字节 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 的 vtable4.1 Animal 构造函数做了两件事源码Animal(string n):name(n){}含义给对象写入 vptr因为 Animal 有虚函数构造成员name调用std::string的拷贝构造/构造4.2 汇编设置 vptr最关键的 1 行Animal::Animal(...)中有这一段mov edx, OFFSET FLAT:vtable for Animal16 mov rax, QWORD PTR [rbp-8] ; rax this mov QWORD PTR [rax], rdx ; *(this0) vptr把它翻译成“等价伪 C”就是*(void**)thisvtable_for_Animal[0];// 注意这里的“[0]”指的是虚函数区起点你可以把mov [rax], rdx看成把对象头 8 字节写成虚表地址。4.3 汇编构造成员 namethis8同一个构造函数里还有类似mov rax, QWORD PTR [rbp-8] lea rdx, [rax8] ; 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, [rax8]这就是在取this-name。5. Step 2构造 Dog —— 先调用 Animal 构造再把 vptr 改成 Dog 的 vtable5.1 源码Dog 构造函数Dog(string n):Animal(n){}5.2 汇编先构造基类 Animal 子对象在Dog::Dog(...)中会看到逻辑上先把参数string n临时构造成一个对象栈上call Animal::Animal(...)释放临时string设置 Dog 自己的 vptr其中关键点是这句call Animal::Animal(std::__cxx11::basic_string...) [base object constructor]5.3 汇编把对象的 vptr 最终改成 Dog紧接着你又能看到mov edx, OFFSET FLAT:vtable for Dog16 mov rax, QWORD PTR [rbp-56] ; rax this mov QWORD PTR [rax], rdx ; *(this0) Dog 的 vptr为什么要“改一次”原因很直观在执行Animal::Animal时对象暂时被当成 “Animal 子对象” 来初始化所以 vptr 会先指向 Animal。当 Dog 自己构造完成后对象的真实动态类型应当是 Dog所以 vptr 必须指向 Dog 的虚表。Cat::Cat(...)也是同样的套路先构造 Animal再写vtable for Cat16。6. Step 3关键多态点 —— animalSpeak(a) 如何通过 vtable 找到正确的 speak6.1 源码a-speak()voidanimalSpeak(Animal*a){a-speak();}如果a dd 是 Dog我们期待它调用Dog::speak()如果a cc 是 Cat我们期待它调用Cat::speak()。6.2 汇编典型的“虚调用”指针链你贴出的animalSpeak(Animal*)里有这样的序列mov rax, QWORD PTR [rbp-8] ; rax a mov rax, QWORD PTR [rax] ; rax *a vptr mov rdx, QWORD PTR [rax] ; rdx *(vptr0) vtable[0]第一个虚函数指针 mov rax, QWORD PTR [rbp-8] ; rax a mov rdi, rax ; this 放入 rdi call rdx ; 间接 call把它翻译成“更直观的指针等价式”// a 是 Animal*void**vptr*(void***)a;// 取对象头部 vptrautofn(void(*)(Animal*))vptr[0];// 取虚表第 0 个槽这里就是 speakfn(a);// 以 a 作为 this 调用这就是多态的本质把“要调用的函数”变成“运行时从表里取出来的函数指针”。7. Step 4Dog::speak / Cat::speak / Animal::speak 汇编在做什么源码Dog 为例coutname says: Woof!endl;在Dog::speak()汇编中关键点有两个取this 8作为namemov rax, QWORD PTR [rbp-8] ; this add rax, 8 ; this8 - 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 Animal16你在汇编里看到mov edx, OFFSET FLAT:vtable for Animal16同时在汇编末尾看到类似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)读取少一次偏移换算因此当你看到mov [this], vtable_for_Animal16可以理解为“把 vptr 指到 speak 的那一排函数指针数组开头。”9. 程序退出析构与清理你汇编里看到的析构段你源码里没有写析构函数但编译器仍会生成默认析构来销毁成员std::string name。在你贴出的Animal::~Animal()汇编里能看到先把 vptr 写回vtable for Animal16对this8调用std::string析构basic_string::~basic_string()在Dog::~Dog()/Cat::~Cat()里则是写回各自 vptrDog/Cat再call Animal::~Animal()另外你还看到很多_Unwind_Resume那是异常传播路径表示“如果构造/输出过程中抛异常需要按已经构造好的对象顺序逐一析构清理”属于编译器自动生成的异常安全框架。animalSpeak(Animal* a) 执行a-speak()Cat 的虚表槽 - 函数实现Dog 的虚表槽 - 函数实现Cat 对象内存布局Dog 对象内存布局指向Dog对象指向Cat对象this Dog对象[this0] vptrvtable for Dog 16(虚函数槽数组起点)[this8] name (std::string)this Cat对象[this0] vptrvtable for Cat 16(虚函数槽数组起点)[this8] name (std::string)slot[0] : speakDog::speak() 代码地址slot[0] : speakCat::speak() 代码地址a : Animal* (静态类型 Animal*)运行时可能指向 Dog/Cat1) vptr *(void**)a(读对象头部 vptr)2) fn ((void**)vptr)[0](取槽 slot[0])3) call fn(a)(间接调用a 作为 this)关键点slot[0] 的地址 vptr 0 vtable 16slot[0] 的内容 *(vtable 16) 你看到的 第三个 .quad比如 Dog::speak() / Cat::speak()10. 一句话总结把源码和汇编串起来构造阶段构造函数把vptr写进对象头mov [this], vtable16并构造成员namethis8。调用阶段animalSpeak通过a - vptr - vtable[0]取到函数指针并call从而实现运行时多态。退出阶段析构时销毁name并处理异常清理路径。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2457851.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!