虚函数与虚函数表剖析(动多态)
探索C++虚函数在g++中的实现
本文是我在追查一个诡异core问题的过程中收获的一点心得,把公司项目相关的背景和特定条件去掉后,仅取其中通用的C++虚函数实现部分知识记录于此。
在开始之前,原谅我先借用一张图黑一下C++:
“无敌”的C++
如果你也在写C++,请一定小心…至少,你要先有所了解: 当你在写虚函数的时候,g++在写什么?
先写个例子
为了探索C++虚函数的实现,我们首先编写几个用来测试的类,代码如下:
C++
#include <iostream>using namespace std;class Base1 { public:virtual void f() {cout << "Base1::f()" << endl;} };class Base2 { public:virtual void g() {cout << "Base2::g()" << endl;} };class Derived : public Base1, public Base2 { public:virtual void f() {cout << "Derived::f()" << endl;}virtual void g() {cout << "Derived::g()" << endl;}virtual void h() {cout << "Derived::h()" << endl;} };int main(int argc, char *argv[]) {Derived ins;Base1 &b1 = ins;Base2 &b2 = ins;Derived &d = ins;b1.f();b2.g();d.f();d.g();d.h(); }
代码采用了多继承,是为了更多的分析出g++的实现本质,用UML简单的画一下继承关系:
示例代码UML图
代码的输出结果和预期的一致,C++实现了虚函数覆盖功能,代码输出如下:
Derived::f() Derived::g() Derived::f() Derived::g() Derived::h()
开始分析!
我写这篇文章的重点是尝试解释g++编译在底层是如何实现虚函数覆盖和动态绑定的,因此我假定你已经明白基本的虚函数概念以及虚函数表(vtbl)和虚函数表指针(vptr)的概念和在继承实现中所承担的作用,如果你还不清楚这些概念,建议你在继续阅读下面的分析前先补习一下相关知识,陈皓的 《C++虚函数表解析》 系列是一个不错的选择。
通过本文,我将尝试解答下面这三个问题:
- g++如何实现虚函数的动态绑定?
- vtbl在何时被创建?vptr又是在何时被初始化?
- 在Linux中运行的C++程序虚拟存储器中,vptr、vtbl存放在虚拟存储的什么位置?
首先是第一个问题:
g++如何实现虚函数的动态绑定?
这个问题乍看简单,大家都知道是通过vptr和vtbl实现的,那就让我们刨根问底的看一看,g++是如何利用vptr和vtbl实现的。
第一步,使用 -fdump-class-hierarchy 参数导出g++生成的类内存结构:
Vtable for Base1 Base1::_ZTV5Base1: 3u entries 0 (int (*)(...))0 4 (int (*)(...))(& _ZTI5Base1) 8 Base1::fClass Base1size=4 align=4base size=4 base align=4 Base1 (0xb6acb438) 0 nearly-emptyvptr=((& Base1::_ZTV5Base1) + 8u)Vtable for Base2 Base2::_ZTV5Base2: 3u entries 0 (int (*)(...))0 4 (int (*)(...))(& _ZTI5Base2) 8 Base2::gClass Base2size=4 align=4base size=4 base align=4 Base2 (0xb6acb474) 0 nearly-emptyvptr=((& Base2::_ZTV5Base2) + 8u)Vtable for Derived Derived::_ZTV7Derived: 8u entries 0 (int (*)(...))0 4 (int (*)(...))(& _ZTI7Derived) 8 Derived::f 12 Derived::g 16 Derived::h 20 (int (*)(...))-0x000000004 24 (int (*)(...))(& _ZTI7Derived) 28 Derived::_ZThn4_N7Derived1gEvClass Derivedsize=8 align=4base size=8 base align=4 Derived (0xb6b12780) 0vptr=((& Derived::_ZTV7Derived) + 8u)Base1 (0xb6acb4b0) 0 nearly-emptyprimary-for Derived (0xb6b12780)Base2 (0xb6acb4ec) 4 nearly-emptyvptr=((& Derived::_ZTV7Derived) + 28u)
如果看不明白这些乱七八糟的输出,没关系(当然能看懂更好),把上面的输出转换成图的形式就清楚了:
vptr和vtbl
其中有几点尤其值得注意:
- 我用来测试的机器是32位机,所有vptr占4个字节,每个vtbl中的函数指针也是4个字节
- 每个类的主要(primal)vptr放在类内存空间的起始位置(由于我没有声明任何成员变量,可能看不清楚)
- 在多继承中,对应各个基类的vptr按继承顺序依次放置在类内存空间中,且子类与第一个基类共用同一个vptr
- 子类中声明的虚函数除了覆盖各个基类对应函数的指针外,还额外添加一份到第一个基类的vptr中(体现了共用的意义)
有了内存布局后,接下来观察g++是如何在这样的内存布局上进行动态绑定的。
g++对每个类的指针或引用对象,如果是其类声明中虚函数,使用位于其内存空间首地址上的vptr寻找找到vtbl进而得到函数地址。如果是父类声明而子类未覆盖的虚函数,使用对应父类的vptr进行寻址。
先来验证一下,使用 objdump -S 得到 b1.f() 的汇编指令:
Assembly (x86)
b1.f();8048734: 8b 44 24 24 mov 0x24(%esp),%eax # 得到Base1对象的地址8048738: 8b 00 mov (%eax),%eax # 对对象首地址上的vptr进行解引用,得到vtbl地址804873a: 8b 10 mov (%eax),%edx # 解引用vtbl上第一个虚函数的地址804873c: 8b 44 24 24 mov 0x24(%esp),%eax8048740: 89 04 24 mov %eax,(%esp)8048743: ff d2 call *%edx # 调用函数
其过程和我们的分析完全一致,聪明的你可能发现了,b2怎么办呢?Derived类的实例内存首地址上的vptr并不是Base2类的啊!答案实际上是因为g++在引用赋值语句 Base2 &b2 = ins 上动了手脚:
Assembly (x86)
Derived ins;804870d: 8d 44 24 1c lea 0x1c(%esp),%eax8048711: 89 04 24 mov %eax,(%esp)8048714: e8 c3 01 00 00 call 80488dc <_ZN7DerivedC1Ev>Base1 &b1 = ins;8048719: 8d 44 24 1c lea 0x1c(%esp),%eax804871d: 89 44 24 24 mov %eax,0x24(%esp)Base2 &b2 = ins;8048721: 8d 44 24 1c lea 0x1c(%esp),%eax # 获得ins实例地址8048725: 83 c0 04 add $0x4,%eax # 添加一个指针的偏移量8048728: 89 44 24 28 mov %eax,0x28(%esp) # 初始化引用Derived &d = ins;804872c: 8d 44 24 1c lea 0x1c(%esp),%eax8048730: 89 44 24 2c mov %eax,0x2c(%esp)
虽然是指向同一个实例的引用,根据引用类型的不同,g++编译器会为不同的引用赋予不同的地址。例如b2就获得一个指针的偏移量,因此才保证了vptr的正确性。
PS:我们顺便也证明了C++中的引用的真实身份就是指针…
接下来进入第二个问题:
vtbl在何时被创建?vptr又是在何时被初始化?
既然我们已经知道了g++是如何通过vptr和vtbl来实现虚函数魔法的,那么vptr和vtbl又是在什么时候被创建的呢?
vptr是一个相对容易思考的问题,因为vptr明确的属于一个实例,所以vptr的赋值理应放在类的构造函数中。 g++为每个有虚函数的类在构造函数末尾中隐式的添加了为vptr赋值的操作 。
同样通过生成的汇编代码验证:
Assembly (x86)
class Derived : public Base1, public Base2 {80488dc: 55 push %ebp80488dd: 89 e5 mov %esp,%ebp80488df: 83 ec 18 sub $0x18,%esp80488e2: 8b 45 08 mov 0x8(%ebp),%eax80488e5: 89 04 24 mov %eax,(%esp)80488e8: e8 d3 ff ff ff call 80488c0 <_ZN5Base1C1Ev>80488ed: 8b 45 08 mov 0x8(%ebp),%eax80488f0: 83 c0 04 add $0x4,%eax80488f3: 89 04 24 mov %eax,(%esp)80488f6: e8 d3 ff ff ff call 80488ce <_ZN5Base2C1Ev>80488fb: 8b 45 08 mov 0x8(%ebp),%eax80488fe: c7 00 48 8a 04 08 movl $0x8048a48,(%eax)8048904: 8b 45 08 mov 0x8(%ebp),%eax8048907: c7 40 04 5c 8a 04 08 movl $0x8048a5c,0x4(%eax)804890e: c9 leave804890f: c3 ret
可以看到在代码中,Derived类的构造函数为实例的两个vptr赋初值,可是,这两个初值居然是立即数!立即数!立即数! 这说明了vtbl的生成并不是运行时的,而是在编译期就已经确定了存放在这两个地址上的 !
这个地址不出意料的属于.rodata(只读数据段),使用 objdump -s -j .rodata 提取出对应的内存观察:
80489e0 03000000 01000200 00000000 42617365 ............Base80489f0 313a3a66 28290042 61736532 3a3a6728 1::f().Base2::g(8048a00 29004465 72697665 643a3a66 28290044 ).Derived::f().D8048a10 65726976 65643a3a 67282900 44657269 erived::g().Deri8048a20 7665643a 3a682829 00000000 00000000 ved::h()........8048a30 00000000 00000000 00000000 00000000 ................8048a40 00000000 a08a0408 34880408 68880408 ........4...h...8048a50 94880408 fcffffff a08a0408 60880408 ............`...8048a60 00000000 c88a0408 08880408 00000000 ................8048a70 00000000 d88a0408 dc870408 37446572 ............7Der8048a80 69766564 00000000 00000000 00000000 ived............8048a90 00000000 00000000 00000000 00000000 ................8048aa0 889f0408 7c8a0408 00000000 02000000 ....|...........8048ab0 d88a0408 02000000 c88a0408 02040000 ................8048ac0 35426173 65320000 a89e0408 c08a0408 5Base2..........8048ad0 35426173 65310000 a89e0408 d08a0408 5Base1..........
由于程序运行的机器是小端机,经过简单的转换就可以得到第一个vptr所指向的内存中的第一条数据为0x80488834,如果把这个数据解释为函数地址到汇编文件中查找,会得到:
Assembly (x86)
08048834 <_ZN7Derived1fEv>: };class Derived : public Base1, public Base2 { public:virtual void f() {8048834: 55 push %ebp8048835: 89 e5 mov %esp,%ebp8048837: 83 ec 18 sub $0x18,%esp
Bingo! g++在编译期就为每个类确定了vtbl的内容,并且在构造函数中添加相应代码使vptr能够指向已经填好的vtbl的地址 。
这也同时为我们解答了第三个问题:
在Linux中运行的C++程序虚拟存储器中,vptr、vtbl存放在虚拟存储的什么位置?
直接看图:
虚函数在虚拟存储器中的位置
图中灰色部分应该是你已经熟悉的,彩色部分内容和相关联的箭头描述了虚函数调用的过程(图中展示的是通过new在堆区创建实例的情况,与示例代码有所区别,小失误,不要在意): 当调用虚函数时,首先通过位于栈区的实例的指针找到位于堆区中的实例地址,然后通过实例内存开头处的vptr找到位于.rodata段的vtbl,再根据偏移量找到想要调用的函数地址,最后跳转到代码段中的函数地址执行目标函数 。
总结
研究这些问题的起因是因为公司代码出现了非常奇葩的行为,经过追查定位到虚函数表出了问题,因此才有机会脚踏实地的对虚函数实现进行一番探索。
也许你会想,即使我不明白这些底层原理,也一样可以正常的使用虚函数,也一样可以写出很好的面相对象的代码啊?
这一点儿也没有错,但是,C++作为全宇宙最复杂的程序设计语言,它提供的功能异常强大,无异于武侠小说中锋利无比的屠龙宝刀。但武功不好的菜鸟如果胡乱舞弄宝刀,却很容易反被其所伤。只有了解了C++底层的原理和机制,才能让我们把C++这把屠龙宝刀使用的更加得心应手,变化出更加华丽的招式,成为真正的武林高手。
虚函数与虚函数表剖析(动多态)相关推荐
- 【C++ Primer | 15】虚函数表剖析(一)
一.虚函数 1. 概念 多态指当不同的对象收到相同的消息时,产生不同的动作 编译时多态(静态绑定),函数重载,运算符重载,模板. 运行时多态(动态绑定),虚函数机制. 为了实现C++的多态,C++使用 ...
- c++虚函数和虚函数表
前言 (1)虚基表与虚函数表是两个完全不同的概念 虚基表用来解决继承的二义性(虚基类可以解决). 虚函数用来实现泛型编程,运行时多态. (2)虚函数是在基类普通函数前加virtual关键字,是实现多态 ...
- C++虚函数及虚函数表解析
一.背景知识(一些基本概念) 虚函数(Virtual Function):在基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数. 纯虚函数(Pure Virtual Functi ...
- 【虚函数指针 虚函数表】
文章目录 虚函数指针和虚函数表 1.虚函数的含义 2.虚函数的作用 3.虚函数的实现原理 多态的实现原理 `普通类` `当类中存在虚函数` `子类继承父类不重写虚函数` 子类继承父类重写虚函数 1.虚 ...
- 【C++】虚函数与虚函数表
1. 虚函数表的结构 #include <iostream> using namespace std;typedef void (*Fun)(void);class Base {publi ...
- 虚函数与纯虚函数以及虚函数表之间的关系
1.虚函数 简单地说,那些被virtual关键字修饰的成员函数,就是虚函数.C++中虚函数的作用主要是实现多态机制.所谓多态就是用父类指针指向子类对象,然后通过父类指针调用实际子类的成员函数,这种技术 ...
- c++ 虚函数多态、纯虚函数、虚函数表指针、虚基类表指针详解
文章目录 静态多态.动态多态 虚函数 哪些函数类型不可以被定义成虚函数? 虚函数的访问方式 析构函数中的虚函数 虚函数表指针 vptr 多继承下的虚函数表 虚基类表指针 bptr 纯虚函数 抽象类 虚 ...
- C++虚函数和虚函数表原理
虚函数的地址存放于虚函数表之中.运行期多态就是通过虚函数和虚函数表实现的. 类的对象内部会有指向类内部的虚表地址的指针.通过这个指针调用虚函数. 虚函数的调用会被编译器转换为对虚函数表的访问: ptr ...
- C++虚函数,虚函数表,虚继承,虚继承表
一.虚函数 类中用virtual关键字修饰的函数. 作用:主要是实现了多态的机制.关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数.这种技术可以让父类的 ...
- C++中虚函数、虚指针和虚表详解
关于虚函数的背景知识 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数. 存在虚函数的类都有一个一维的虚函数表叫做虚表.每一个类的对象都有一个指向虚表开始的虚指针.虚表是和类对应的 ...
最新文章
- Science:便携式DNA测序仪在检测病毒疫情中大显身手
- 转: Python集合(set)类型的操作
- Python技巧之函数拆包裹
- Mysql如何创建短索引(前缀索引)
- Flask学习 黑马程序员-6节课入门Flask框架web开发视频(中途撤退,寻找py3教程)
- 无法打开登录所请求的数据库DbName 。登录失败。 用户 'IIS APPPOOL\DefaultAppPool' 登录失败。 的解决方案...
- linux 跑天龙八部游戏脚本,求推荐天龙八部脚本(能自动打怪,捡包之类的)
- 距离Java开发者玩转 Serverless,到底还有多远?
- jsp中使用vue,jsp中使用elementUI
- SQL Express数据库的连接问题
- qq飞车找不到服务器了,QQ飞车体验服务器专区
- 物联网智能家居有哪些应用
- Linux 平台安装 VNC
- WP模板兔模板V4.3 去除授权+多功能插件
- java h5在线音频_html5 mp3音频播放代码网页在线录音
- 红米Note通过卡刷获取root权限教程,附各版本root包
- 【精品推荐】程序员必定会爱上的十款软件:不用就太浪费了
- java visual linux,如何在 Linux 中安装 Visual Studio Code
- 阿里 c语言开发工程师,阿里巴巴2014秋季校园招聘软件研发工程师笔试题
- 导通压降与死区的开启电压区别
热门文章
- Revit标注墙偏移如何简便标注呢?万能标注?
- 51单片机的仿真实验——1602显示屏显示万年历与温度
- 1220 -- 青蛙过河
- Cat3.0.0监控本地部署+springboot接入cat例子
- 毕业步入职场,我是怎么用python自动化做到准时下班,薪资还高的
- Android使用NFC读卡实现 (一)
- 东北大学秦皇岛分校计算机类排名,东北大学秦皇岛分校全国排名,2021东北大学秦皇岛分校排名榜...
- word2013怎么去掉所有文字下面的波浪线
- 网络神经科学 Network neuroscience
- 已解决 You can enable repos with yum-config-manager --enable <repo>