http://blog.kongfy.com/2015/08/探索c虚函数在g中的实现/?utm_source=tuicool&utm_medium=referral
https://blog.csdn.net/haoel/article/details/1948051
目录
在Linux中运行的C++程序虚拟存储器中,vptr、vtbl存放在虚拟存储的什么位置?
一、虚函数表解析
前言
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术(更多时候泛型特指模板编程)。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。
关于虚函数的使用方法,我在这里不做过多的阐述。大家可以看看相关的C++的书籍。在这篇文章中,我只想从虚函数的实现机制上面为大家 一个清晰的剖析。
当然,相同的文章在网上也出现过一些了,但我总感觉这些文章不是很容易阅读,大段大段的代码,没有图片,没有详细的说明,没有比较,没有举一反三。不利于学习和阅读,所以这是我想写下这篇文章的原因。也希望大家多给我提意见。
言归正传,让我们一起进入虚函数的世界。
虚函数表
对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
这里我们着重看一下这张虚函数表。C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
听我扯了那么多,我可以感觉出来你现在可能比以前更加晕头转向了。 没关系,下面就是实际的例子,相信聪明的你一看就明白了。
假设我们有这样的一个类:
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
};
按照上面的说法,我们可以通过Base的实例来得到虚函数表。 下面是实际例程:
typedef void(*Fun)(void);
Base b;
Fun pFun = NULL;
cout << "虚函数表地址:" << (int*)(&b) << endl;
cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;
// Invoke the first virtual function
pFun = (Fun)*((int*)*(int*)(&b));
pFun();
实际运行经果如下:(Windows XP+VS2003, Linux 2.6.22 + GCC 4.1.3)
虚函数表地址:0012FED4
虚函数表 — 第一个函数地址:0044F148
Base::f
通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()和Base::h(),其代码如下:
(Fun)*((int*)*(int*)(&b)+0); // Base::f()
(Fun)*((int*)*(int*)(&b)+1); // Base::g()
(Fun)*((int*)*(int*)(&b)+2); // Base::h()
这个时候你应该懂了吧。什么?还是有点晕。也是,这样的代码看着太乱了。没问题,让我画个图解释一下。如下所示:
注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“/0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在WinXP+VS2003下,这个值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。
下面,我将分别说明“无覆盖”和“有覆盖”时的虚函数表的样子。没有覆盖父类的虚函数是毫无意义的。我之所以要讲述没有覆盖的情况,主要目的是为了给一个对比。在比较之下,我们可以更加清楚地知道其内部的具体实现。
一般继承(无虚函数覆盖)
下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:
请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
对于实例:Derive d; 的虚函数表如下:
我们可以看到下面几点:
- 虚函数按照其声明顺序(C++所有初始化顺序都是按照声明的顺序)放于表中。
- 父类的虚函数在子类的虚函数前面。
- (还有很重要的一点就是在子类的内存布局中,父类和子类共用一个虚表,在多继承中子类和第一个父类共享)
我相信聪明的你一定可以参考前面的那个程序,来编写一段程序来验证。
一般继承(有虚函数覆盖)
覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。
为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:
我们从表中可以看到下面几点,
1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
2)没有被覆盖的函数依旧。
这样,我们就可以看到对于下面这样的程序,
Base *b = new Derive();
b->f();
由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。
多重继承(无虚函数覆盖)
下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。
对于子类实例中的虚函数表,是下面这个样子:
我们可以看到:
1) 每个父类都有自己的虚表。
2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)
这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。
多重继承(有虚函数覆盖)
下面我们再来看看,如果发生虚函数覆盖的情况。
下图中,我们在子类中覆盖了父类的f()函数。
下面是对于子类实例中的虚函数表的图:
我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
安全性
每次写C++的文章,总免不了要批判一下C++。这篇文章也不例外。通过上面的讲述,相信我们对虚函数表有一个比较细致的了解了。水可载舟,亦可覆舟。下面,让我们来看看我们可以用虚函数表来干点什么坏事吧。
1、通过父类型的指针访问子类自己的虚函数
我们知道,子类没有重载父类的虚函数是一件毫无意义的事情。因为多态也是要基于函数重载的。虽然在上面的图中我们可以看到Base1的虚表中有Derive的虚函数,但我们根本不可能使用下面的语句来调用子类的自有虚函数:
Base1 *b1 = new Derive();
b1->f1(); //编译出错
任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点)
2、访问non-public的虚函数
另外,如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。如:
class Base {
private:
virtual void f() { cout << "Base::f" << endl; }
};
class Derive : public Base{
};
typedef void(*Fun)(void);
void main() {
Derive d;
Fun pFun = (Fun)*((int*)*(int*)(&d)+0);
pFun();
}
结束语
C++这门语言是一门Magic的语言,对于程序员来说,我们似乎永远摸不清楚这门语言背着我们在干了什么。需要熟悉这门语言,我们就必需要了解C++里面的那些东西,需要去了解C++中那些危险的东西。不然,这是一种搬起石头砸自己脚的编程语言。
附录一:VC中查看虚函数表
我们可以在VC的IDE环境中的Debug状态下展开类的实例就可以看到虚函数表了(并不是很完整的)
附录 二:例程
下面是一个关于多重继承的虚函数表访问的例程:
#include <iostream>
using namespace std;
class Base1 {
public:
virtual void f() { cout << "Base1::f" << endl; }
virtual void g() { cout << "Base1::g" << endl; }
virtual void h() { cout << "Base1::h" << endl; }
};
class Base2 {
public:
virtual void f() { cout << "Base2::f" << endl; }
virtual void g() { cout << "Base2::g" << endl; }
virtual void h() { cout << "Base2::h" << endl; }
};
class Base3 {
public:
virtual void f() { cout << "Base3::f" << endl; }
virtual void g() { cout << "Base3::g" << endl; }
virtual void h() { cout << "Base3::h" << endl; }
};
class Derive : public Base1, public Base2, public Base3 {
public:
virtual void f() { cout << "Derive::f" << endl; }
virtual void g1() { cout << "Derive::g1" << endl; }
};
typedef void(*Fun)(void);
int main()
{
Fun pFun = NULL;
Derive d;
int** pVtab = (int**)&d;
//Base1's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);
pFun = (Fun)pVtab[0][0];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);
pFun = (Fun)pVtab[0][1];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+2);
pFun = (Fun)pVtab[0][2];
pFun();
//Derive's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+3);
pFun = (Fun)pVtab[0][3];
pFun();
//The tail of the vtable
pFun = (Fun)pVtab[0][4];
cout<<pFun<<endl;
//Base2's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);
pFun = (Fun)pVtab[1][0];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);
pFun = (Fun)pVtab[1][1];
pFun();
pFun = (Fun)pVtab[1][2];
pFun();
//The tail of the vtable
pFun = (Fun)pVtab[1][3];
cout<<pFun<<endl;
//Base3's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);
pFun = (Fun)pVtab[2][0];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);
pFun = (Fun)pVtab[2][1];
pFun();
pFun = (Fun)pVtab[2][2];
pFun();
//The tail of the vtable
pFun = (Fun)pVtab[2][3];
cout<<pFun<<endl;
return 0;
}
二、通过汇编探索C++虚函数在g++中的实现
本文是我在追查一个诡异core问题的过程中收获的一点心得,把公司项目相关的背景和特定条件去掉后,仅取其中通用的C++虚函数实现部分知识记录于此。
在开始之前,原谅我先借用一张图黑一下C++:
“无敌”的C++
如果你也在写C++,请一定小心…至少,你要先有所了解:当你在写虚函数的时候,g++在写什么?
先写个例子
为了探索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() 的汇编指令:
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 上动了手脚:
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赋值的操作。
同样通过生成的汇编代码验证:
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 提取出对应的内存观察:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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所指向的内存中的第一条数据为0x08048834,如果把这个数据解释为函数地址到汇编文件中查找,会得到:
1 2 3 4 5 6 7 8 9 10 |
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++这把屠龙宝刀使用的更加得心应手,变化出更加华丽的招式,成为真正的武林高手。