动态联编实现原理分析

代码编译运行环境:VS2012+Debug+Win32


所谓动态联编,是指被调函数入口地址是在运行时、而不是在编译时决定的。C++语言利用动态联编来完成虚函数调用。C++标准并没有规定如何实现动态联编,但大多数的C++编译器都是通过虚指针(vptr)和虚函数表(vtable)来实现动态联编。 基本的思路是: (1)为每一个包含虚函数的类建立一个虚函数表,虚函数表的每一个表项存放的是个虚函数在内存中的入口地址;

(2)在该类的每个对象中设置一个指向虚函数表的指针,在调用虚函数时,先采用虚指针找到虚函数表,确定虚函数的入口地址在表中的位置,获取入口地址完成调用。

我们将从以下几个方面来考察动态联编的实现细节。


1.虚指针(vptr)的存放位置

虚指针是作为对象的一部分存放在对象的空间中。一个类只有一个虚函数表,因此类的所有对象中的虚指针都指向同一个地方。在不同的编译器中,虚指针在对象中的位置时不同的。两种典型的做法是: (1)在Visual C++中,虚指针位于对象的起始位置;

(2)在GNU C++中,虚指针位于对象的尾部而不是头部。

可通过下面的程序考察在Visual C++中,虚指针在对象中的位置。

#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;
}
程序运行结果: 
 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)多重继承的情况下,派生类有多少个虚函数表呢?

考察如下程序:

#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)虚函数表是一个类的所有对象共享,而不是一个对象就拥有一个虚函数表,读者可自行验证。 以上代码描述的是单继承情况下父类和子类的虚函数表在内存中的结构,直观的图示描述如下:

注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“\0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在Visual C++下,这个值是NULL。而在GNU C++下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。

(4)多重继承的情况下,派生类有多少个虚函数表呢? 子类如果继承了多个父类,并重写了继承而来的虚函数,下面是对于子类实例中的虚函数表的图:

我们可以看见,子类有多少个父类,就有多少个虚函数表。三个父类虚函数表中的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()

注意:第一个虚函数表的最后一项Derive::g1()是子类新增的虚函数。


3.虚函数表(vtable)的放在哪里

虚函数表放在应用程序的常量区。将上面的代码编译之后生成汇编代码文件,查看.asm文件可以发现这样两端内容:

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

这里说明一下如何在VS2012中生成汇编代码文件。需要进行如下设置:

项目 ---》属性 ---》 配置属性 ---》 c/c++ ---》 输出文件 ---》 右边内容项:汇编输出 ---》带源代码的程序集(/Fas )。

这样在项目里面生成后缀为*.asm 的文件。里面还有注释,有利于分析。

从汇编代码可以看出,这是两个常量段,其中分别存放了Base类的虚函数表和Derived类的虚函数表。从中可以发现,虚函数表中的每一项代表了一个函数的入口地址,类型是Double Word。类中每个虚函数的入口地址在虚函数表中的排放顺序,也可以从相应的标识符看出。


4.通过访问虚函数表手动调用虚函数

既然知道了虚函数表的位置和结构,那么就可以通过访问虚函数表,手动调用虚函数。虽然在利用C++编写程序时没有必要这样做,但如果想了解动态联编的实现机理,请参考如下代码:

#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())。程序的输出结果是: Base’s f2() Derived’s f4(),j=1

结果表明,成功的对不同对象上的不同虚函数实现了调用。这些调用是通过访问每个对象虚函数表来实现的。由于在调用类对象的非静态成员函数时,必须同时给出对象的首地址,所以在程序中使用了内联汇编代码_asm mov ecx,pObj;来达到这个目的。在Visual C++中,在调用类的费静态成员函数之前,对象的首地址都是送往寄存器ecx的。


参考文献:

[1] http://blog.chinaunix.net/uid-26855683-id-3547249.html [2] http://blog.csdn.net/haoel/article/details/1948051/ [3] http://www.vckbase.com/index.php/wv/1514 [4] 陈刚.C++高级进阶教程[M].武汉:武汉大学出版社,2008[8.6(P304-P310)]

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏搜云库

BTA 常问的 Java基础40道常见面试题及详细答案

最近看到网上流传着,各种面试经验及面试题,往往都是一大堆技术题目贴上去,而没有答案。

6026
来自专栏我是攻城师

Apache Pig学习笔记之内置函数(三)

4424
来自专栏海天一树

图的广度优先搜索

广度优先搜索算法是最简便的图的搜索算法之一,属于一种盲目搜寻法,目的是系统地展开并检查图中的所有节点,以找寻结果。换句话说,它并不考虑结果的可能位置,彻底地搜索...

1292
来自专栏博岩Java大讲堂

关于Java泛型"擦除"的一点思考

3295
来自专栏章鱼的慢慢技术路

牛客网_Go语言相关练习_判断&选择题(4)

错误指的是可能出现问题的地方出现了问题,比如打开一个文件时失败,这种情况在人们的意料之中;而异常指的是不应该出现问题的地方出现了问题,比如引用了空指针,这种情况...

1212
来自专栏Felix的技术分享

KMP子字符串查找算法

2446
来自专栏ppjun专栏

Android十八章:Java硬软弱虚引用,GC回收,内存碎片

又叫java虚拟机栈区,是每一个方法被执行的时候,创建出一个栈帧用来放的成员变量,操作链表,动态链接,方法出口。很多个栈帧又存储在栈区。

1562
来自专栏开发与安全

《linux c 编程一站式学习》课后部分习题解答

1、假设变量x和n是两个正整数,我们知道x/n这个表达式的结果要取Floor,例如x是17,n是4,则结果是4。如果希望结果取Ceiling应该怎么写表达式呢?...

4576
来自专栏佳爷的后花媛

java学习要点

作为一个程序员,在找工作的过程中,都会遇到笔试,而很多笔试里面都包括java,尤其是作为一个Android开发工程师,java是必备技能之一.所以为了笔试过程中...

3485
来自专栏me的随笔

Python知识梳理

我们可以使用type()函数类获取对象的类型,Python3中内置数据类型包括:None,int,float,complex,str,list,dict,tup...

1522

扫码关注云+社区

领取腾讯云代金券