探究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::f Class Base1 size=4 align=4 base size=4 base align=4 Base1 (0xb6acb438) 0 nearly-empty vptr=((& Base1::_ZTV5Base1) + 8u) Vtable for Base2 Base2::_ZTV5Base2: 3u entries 0 (int (*)(...))0 4 (int (*)(...))(& _ZTI5Base2) 8 Base2::g Class Base2 size=4 align=4 base size=4 base align=4 Base2 (0xb6acb474) 0 nearly-empty vptr=((& 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_N7Derived1gEv Class Derived size=8 align=4 base size=8 base align=4 Derived (0xb6b12780) 0 vptr=((& Derived::_ZTV7Derived) + 8u) Base1 (0xb6acb4b0) 0 nearly-empty primary-for Derived (0xb6b12780) Base2 (0xb6acb4ec) 4 nearly-empty vptr=((& 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),%eax 8048740: 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),%eax 8048711: 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),%eax 804871d: 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),%eax 8048730: 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 %ebp 80488dd: 89 e5 mov %esp,%ebp 80488df: 83 ec 18 sub $0x18,%esp 80488e2: 8b 45 08 mov 0x8(%ebp),%eax 80488e5: 89 04 24 mov %eax,(%esp) 80488e8: e8 d3 ff ff ff call 80488c0 <_ZN5Base1C1Ev> 80488ed: 8b 45 08 mov 0x8(%ebp),%eax 80488f0: 83 c0 04 add $0x4,%eax 80488f3: 89 04 24 mov %eax,(%esp) 80488f6: e8 d3 ff ff ff call 80488ce <_ZN5Base2C1Ev> 80488fb: 8b 45 08 mov 0x8(%ebp),%eax 80488fe: c7 00 48 8a 04 08 movl $0x8048a48,(%eax) 8048904: 8b 45 08 mov 0x8(%ebp),%eax 8048907: c7 40 04 5c 8a 04 08 movl $0x8048a5c,0x4(%eax) 804890e: c9 leave 804890f: c3 ret
可以看到在代码中,Derived类的组织函数为实例的两个vptr赋初值,然则,这两个初值居然是马上数!马上数!马上数! 这说明了vtbl的生成并非运转时的,而是在编译期就已肯定了存放在这两个地点上的 !
这个地点不出预料的属于.rodata(只读数据段),运用 objdump -s -j .rodata 提掏出对应的内存视察:
80489e0 03000000 01000200 00000000 42617365 ............Base 80489f0 313a3a66 28290042 61736532 3a3a6728 1::f().Base2::g( 8048a00 29004465 72697665 643a3a66 28290044 ).Derived::f().D 8048a10 65726976 65643a3a 67282900 44657269 erived::g().Deri 8048a20 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 ............7Der 8048a80 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 %ebp 8048835: 89 e5 mov %esp,%ebp 8048837: 83 ec 18 sub $0x18,%esp
Bingo! g++在编译期就为每一个类肯定了vtbl的内容,并且在组织函数中增加响应代码使vptr可以指向已填好的vtbl的地点 。
这也同时为我们解答了第三个题目:
在Linux中运转的C++递次假造存储器中,vptr、vtbl存放在假造存储的什么位置?
直接看图:
虚函数在假造存储器中的位置
图中灰色部份应该是你已熟习的,彩色部份内容和相干联的箭头形貌了虚函数挪用的历程(图中展现的是经由历程new在堆区建立实例的状况,与示例代码有所区别,小失误,不要在乎): 当挪用虚函数时,起首经由历程位于栈区的实例的指针找到位于堆区中的实例地点,然后经由历程实例内存开首处的vptr找到位于.rodata段的vtbl,再依据偏移量找到想要挪用的函数地点,末了跳转到代码段中的函数地点实行目的函数 。
总结
研讨这些题目的原由是由于公司代码涌现了非常奇葩的行动,经由清查定位到虚函数表出了题目,因而才有时机踏踏实实的对虚函数完成举行一番探究。
或许你会想,纵然我不邃晓这些底层道理,也一样可以一般的运用虚函数,也一样可以写出很好的面相对象的代码啊?
这一点儿也没有错,然则,C++作为全宇宙最庞杂的递次设计语言,它供应的功用非常壮大,无异于武侠小说中锐利非常的屠龙宝刀。但武功不好的菜鸟假如胡乱舞弄宝刀,却很轻易反被其所伤。只要相识了C++底层的道理和机制,才让我们把C++这把屠龙宝刀运用的越发随心所欲,变化出越发华美的招式,成为真正的武林高手。
相干文章:
C#之虚函数
引见有关C++中继承与多态的基本虚函数类
以上就是探究C++虚函数在g++中的完成(动多态)_虚函数表理会的细致内容,更多请关注ki4网别的相干文章!