代码编译运行环境:VS2017 Debug Win32
所谓动态联编,是指被调函数入口地址是在运行时、而不是在编译时决定的。C 语言利用动态联编来完成虚函数调用。C 标准并没有规定如何实现动态联编,但大多数的C 编译器都是通过虚指针(vptr)和虚函数表(vtable)来实现动态联编。 基本的思路是: (1)为每一个包含虚函数的类建立一个虚函数表,虚函数表的每一个表项存放的是个虚函数在内存中的入口地址; (2)在该类的每个对象中设置一个指向虚函数表的指针,在调用虚函数时,先采用虚指针找到虚函数表,确定虚函数的入口地址在表中的位置,获取入口地址完成调用。
我们将从以下几个方面来考察动态联编的实现细节。
1.虚指针(vptr)的存放位置
虚指针是作为对象的一部分存放在对象的空间中。一个类只有一个虚函数表,因此类的所有对象中的虚指针都指向同一个地方。在不同的编译器中,虚指针在对象中的位置时不同的。两种典型的做法是: (1)在Visual C 中,虚指针位于对象的起始位置; (2)在GNU C 中,虚指针位于对象的尾部而不是头部。
可通过下面的程序考察在Visual C 中,虚指针在对象中的位置。
代码语言:javascript复制#include <iostream>
using namespace std;
int globalv;
class NoVirtual
{
int i;
public:
void func()
{
cout<<"no virtual function"<<endl;
}
NoVirtual()
{
i= globalv;
}
};
class HaveVirtual:public NoVirtual
{
public:
virtual void func()
{
cout<<"Virtual Function"<<endl;
}
};
int main()
{
NoVirtual n1, n2;
HaveVirtual h1, h2;
unsigned long* p;
cout<<"sizeof(NoVirtual):"<<sizeof(NoVirtual)<<endl;
cout<<"sizeof(HaveVirtual):"<<sizeof(HaveVirtual)<<endl;
p=reinterpret_cast<unsigned long*>(&n1);
cout<<"first 4 bytes of n1:"<<p[0]<<endl;
p=reinterpret_cast<unsigned long*>(&n2);
cout<<"first 4 bytes of n2:"<<p[0]<<endl;
p=reinterpret_cast<unsigned long*>(&h1);
cout<<"first 4 bytes of h1: 0x"<<hex<<p[0]<<endl;
p=reinterpret_cast<unsigned long*>(&h2);
cout<<"first 4 bytes of h2: 0x"<<hex<<p[0]<<endl;
}
程序运行结果:
代码语言:javascript复制sizeof(NoVirtual):4
sizeof(HaveVirtual):16
first 4 bytes of n1:1
first 4 bytes of n2:2
first 4 bytes of h1: 0x3fe43340
first 4 bytes of h2: 0x3fe43340
从程序的输出结果中,可以得出以下两个结论。 (1)可以清楚地的看到虚指针对类对象大小的影响。类NoVirtual不包含虚函数,因此类NoVirtual的对象中只包含数据成员i,所以sizeof(NoVirtual)为4。类HaveVirtual包含虚函数,因此类HaveVirtual的对象不近要包含数据成员i,还要包含一个指向虚函数表的指针(大小为4B),所以sizeof(HaveVirtual)为8。
(2)虚指针如果不在对象的头部,那么对象h1和对象h2的头4个字节(代表整型成员变量i)的值应该是3和4。而程序结果显示,类HaveVirtual的两个对象h1和h2的头4个字节的内容相同,这个值就是类HaveVirtual的虚函数表所在地址。
2.虚函数表(vtable)的内部结构
虚函数表是为拥有虚函数的类准备的。虚函数表中存放的是类的各个虚函数的入口地址。那么,可以思考以下几个问题: (1)虚函数的入口地址是按照什么顺序存放在虚函数表中的呢? (2)不同的类(比如说父类和子类)是否可以共享同一张虚函数表的呢? (3)虚函数表是一个类的对象共享,还是一个对象就拥有一个虚函数表? (4)多重继承的情况下,派生类有多少个虚函数表呢?
考察如下程序:
代码语言:javascript复制#include <iostream>
using namespace std;
#define ShowFuncAddress(function) _asm{
mov eax, function}
_asm{mov p,eax}
cout<<"Address of "#function": "<<p<<endl;
void showVtableContent(char* className, void* pObj, int index)
{
unsigned long* pAddr=NULL;
pAddr=reinterpret_cast<unsigned long*>(pObj);
pAddr=(unsigned long*)*pAddr; //获取虚函数表指针
cout<<className<<"'s vtable["<<index<<"]";
cout<<": 0x"<<(void*)pAddr[index]<<endl;
}
class Base
{
int i;
public:
virtual void f1()
{
cout<<"Base's f1()"<<endl;
}
virtual void f2()
{
cout<<"Base's f2()"<<endl;
}
virtual void f3()
{
cout<<"Base's f3()"<<endl;
}
};
class Derived:public Base
{
int i;
public:
virtual void f4()
{
cout<<"Derived's f4()"<<endl;
}
void f3()
{
cout<<"Derived's f3()"<<endl;
}
void f1()
{
cout<<"Derived's f1()"<<endl;
}
};
void func()
{
cout<<"lala"<<endl;
}
int main()
{
Base b;
Derived d;
void *p;
unsigned long *pAddr;
pAddr=reinterpret_cast<unsigned long *>(&b);
cout<<"address of vtable of Base is Ox"<<(void*)*pAddr<<endl;
pAddr=reinterpret_cast<unsigned long *>(&d);
cout<<"address of vtable of Derived is Ox"<<(void*)*pAddr<<endl;
ShowFuncAddress(Base::f1);
showVtableContent("Base",&b,0);
ShowFuncAddress(Base::f2);
showVtableContent("Base",&b,1);
ShowFuncAddress(Base::f3);
showVtableContent("Base",&b,2);
ShowFuncAddress(Derived::f1);
showVtableContent("Derived",&d,0);
ShowFuncAddress(Derived::f2);
showVtableContent("Derived",&d,1);
ShowFuncAddress(Derived::f3);
showVtableContent("Derived",&d,2);
ShowFuncAddress(Derived::f4);
showVtableContent("Derived",&d,3);
}
程序运行结果:
代码相关说明: C 规定,类的静态成员函数和全局函数可以直接通过函数名或类名::函数名来获取函数的入口地址。但是,对于类的非静态成员函数,不可以直接获取类成员函数的地址,需要利用内联汇编来获取成员函数的入口地址或者用union类型来逃避C 的类型转换检测。两种方法都是利用了某种机制逃避C 的类型转换检测,为什么C 编译器干脆不直接放开这个限制,一切让程序员自己作主呢?当然是有原因的,因为类成员函数和普通函数还是有区别的,允许转换后,很容易出错。
因此,在程序中使用了宏ShowFuncAddress,利用内联汇编来获取类的非静态成员函数的入口地址。这是一个带参数的宏,并且对宏的参数做了一些特殊处理,如字符串化的处理。
程序结果说明: (1)基类Base虚函数表的地址与派生类Derived的虚函数表的地址是不同的,尽管类Base是类Derived的父类,但它们却各自使用不同的虚函数表。可见,所有的类都不会和其他的类共享同一张虚函数表。
(2)对任意包含虚函数的类,将虚函数的入口地址写入虚函数表,按照如下的步骤进行: a.确定当前类所包含的虚函数个数。一个类的虚函数有两个来源,一是继承自父类(在当前类中可能被改写),其他的是在当前类中新申明的虚函数。
b.为所有虚函数排序。继承自父类的所有虚函数,排在当前类新生命的虚函数之前。心声明的虚函数,按照在当前类中申明的顺序排列。
c.确定虚函数的入口地址。继承自父类的虚函数,如果在当前类中被改写,则虚函数的入口地址是改写之后的函数的地址,否则保留父类中的虚函数的入口地址。新声明的虚函数,其入口地址就是在当前类中的函数的入口地址。
d.将所有虚函数的入口地址按照排定的次序写入虚函数表中。
(3)虚函数表是一个类的所有对象共享,而不是一个对象就拥有一个虚函数表,读者可自行验证。 以上代码描述的是单继承情况下父类和子类的虚函数表在内存中的结构,直观的图示描述如下:
注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“ ”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在Visual C 下,这个值是NULL。而在GNU C 下,这个值如果是1,表示还有下一个虚函数表,如果值是0,表示当前是最后一个虚函数表。
(4)多重继承的情况下,派生类有多少个虚函数表呢? 子类如果继承了多个父类,并重写了继承而来的虚函数,那么子类实例的内存布局如下图所示:
我们可以看见,子类有多少个父类,就有多少个虚函数表。三个父类虚函数表中的f()位置被替换成了子类的函数。这样,我们就可以以任一静态类型的父类来指向子类,动态调用子类的f()。如:
代码语言:javascript复制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()
注意,第一个虚函数表的最后一项Derive::g1()是子类新增的虚函数。
3.虚函数表(vtable)的放在哪里
虚函数表放在应用程序的常量区。将上面的代码编译之后生成汇编代码文件,查看.asm文件可以发现这样两端内容:
代码语言:javascript复制CONST SEGMENT
??_7Base@@6B@ DD FLAT:??_R4Base@@6B@ ; Base::`vftable’
DD FLAT:?f1@Base@@UAEXXZ
DD FLAT:?f2@Base@@UAEXXZ
DD FLAT:?f3@Base@@UAEXXZ
CONST ENDS
CONST SEGMENT
??_7Derived@@6B@ DD FLAT:??_R4Derived@@6B@ ; Derived::`vftable’
DD FLAT:?f1@Derived@@UAEXXZ
DD FLAT:?f2@Base@@UAEXXZ
DD FLAT:?f3@Derived@@UAEXXZ
DD FLAT:?f4@Derived@@UAEXXZ
CONST ENDS
这里说明一下如何在VS2017中生成汇编代码文件。需要进行如下设置:
代码语言:javascript复制项目 ---》属性 ---》 配置属性 ---》 c/c ---》 输出文件 ---》 右边内容项:汇编输出 ---》带源代码的程序集(/Fas )。
这样在项目里面生成后缀为*.asm 的文件。里面还有注释,有利于分析。
从汇编代码可以看出,这是两个常量段,其中分别存放了Base类的虚函数表和Derived类的虚函数表。从中可以发现,虚函数表中的每一项代表了一个函数的入口地址,类型是Double Word。类中每个虚函数的入口地址在虚函数表中的排放顺序,也可以从相应的标识符看出。
4.通过访问虚函数表手动调用虚函数
既然知道了虚函数表的位置和结构,那么就可以通过访问虚函数表,手动调用虚函数。虽然在利用C 编写程序时没有必要这样做,但如果想了解动态联编的实现机理,请参考如下代码:
代码语言:javascript复制#include <iostream>
using namespace std;
typedef void (*pFunc)();
void executeVirtualFunc(void* pObj, int index)
{
pFunc p;
unsigned long* pAddr;
pAddr=reinterpret_cast<unsigned long*>(pObj);
pAddr=(unsigned long*)*pAddr; //获取虚函数表地址
p=(pFunc)pAddr[index]; //获取虚函数入口地址
_asm mov ecx, pObj
p(); //实施函数调用
}
class Base
{
int i;
public:
Base(){i=0;}
virtual void f1()
{
cout<<"Base's f1()"<<endl;
}
virtual void f2()
{
cout<<"Base's f2()"<<endl;
}
virtual void f3()
{
cout<<"Base's f3()"<<endl;
}
};
class Derived:public Base
{
int j;
public:
Derived(){j=1;}
virtual void f4()
{
cout<<"Derived's f4(),j="<<j<<endl;
}
void f3()
{
cout<<"Derived's f3()"<<endl;
}
void f1()
{
cout<<"Derived's f1()"<<endl;
}
};
int main()
{
Base b;
Derived d;
executeVirtualFunc(&b,1);
executeVirtualFunc(&d,3);
}
执行executeVirtualFunc(&b,1);就是调用基类对象b的第二个虚函数(b.f2()),执行executeVirtualFunc(&d,3);就是调用子类d的第四个虚函数(d.f4())。程序的输出结果是:
代码语言:javascript复制Base's f2()
Derived's f4(),j=1
结果表明,成功的对不同对象上的不同虚函数实现了调用。这些调用是通过访问每个对象虚函数表来实现的。由于在调用类对象的非静态成员函数时,必须同时给出对象的首地址,所以在程序中使用了内联汇编代码_asm mov ecx,pObj;来达到这个目的。在Visual C 中,在调用类的费静态成员函数之前,对象的首地址都是送往寄存器ecx的。
参考文献
[1] VC6.0和VS2005查看查看C或者C 文件汇编代码的方法 [2] C 虚函数表解析 [3] VC知识库 [4] 陈刚.C 高级进阶教程[M].武汉:武汉大学出版社,2008[8.6(P304-P310)]