虚函数表位置思考

来源:互联网 发布:南京未来造价软件 编辑:程序博客网 时间:2024/06/08 12:36

原文:

http://blog.chinaunix.net/uid-26611383-id-3772200.html

http://blog.csdn.net/houdy/article/details/1496161

http://www.cnblogs.com/huhuuu/archive/2013/12/19/3482878.html

其实这是我前一段时间思考过的一个问题,是在看《深入探索C++对象模型》这本书的时候我产生的一个疑问,最近在网上又看到类似的帖子,贴出来看看:



我看到了很多有意思的答案,都回答的比较好,下面贴出一些具有代表性的:


Answer 1:


Answer 2:

我们都知道,虚函数是多态机制的基础,就是在程序在运行期根据调用的对象来判断具体调用哪个函数,现在我们来说说它的具体实现原理,主要说一下我自己的理解,如果有什么不对的地方请指正
在每个包含有虚函数的类的对象的最前面(是指这个对象对象内存布局的最前面,至于为什么是最前面,说来话长,这里就不说了,主要是考虑到效率问题)都有一个称之为虚函数指针(vptr)的东西指向虚函数表(vtbl),这个虚函数表(这里仅讨论最简单的单一继承的情况,若果是多重继承,可能存在多个虚函数表)里面存放了这个类里面所有虚函数的指针,当我们要调用里面的函数时通过查找这个虚函数表来找到对应的虚函数,这就是虚函数的实现原理。这里我假设大家都了解了,如果不了解可以去查下资料。好了,既然我们知道了虚函数的实现原理,虚函数指针vptr指向虚函数表vtbl,而且vptr又在对象的最前面,那么我们很容易可以得到虚函数表的地址,下面我写了一段代码测试了一下:


点击(此处)折叠或打开

  1. #include <iostream>
  2. #include <stdio.h>
  3. typedef void (*fun_pointer)(void);

  4. using namespace std;
  5. class Test
  6. {
  7.  public:
  8.    Test()
  9.        {
  10.      cout<<"Test()."<<endl;
  11.     }
  12.    virtual void print()
  13.        {
  14.      cout<<"Test::Virtual void print1()."<<endl;
  15.     }
  16.    virtual void print2()
  17.        {
  18.      cout<<"Test::virtual void print2()."<<endl;
  19.     }
  20. };

  21. class TestDrived:public Test
  22. {
  23.  public:
  24.  static int var;
  25.   TestDrived()
  26.     {
  27.      cout<<"TestDrived()."<<endl;
  28.     }
  29.   virtual void print()
  30.     {
  31.        cout<<"TestDrived::virtual void print1()."<<endl;
  32.     }
  33.   virtual void print2()
  34.     {
  35.     cout<<"TestDrived::virtual void print2()."<<endl;
  36.     }
  37.   void GetVtblAddress()
  38.     {
  39.          cout<<"vtbl address:"<<(int*)this<<endl;
  40.     }
  41.   void GetFirstVtblFunctionAddress()
  42.     {
  43.     cout<<"First vbtl funtion address:"<<(int*)*(int*)this+<< endl;
  44.     }
  45.   void GetSecondVtblFunctionAddress()
  46.     {
  47.     cout<<"Second vbtl funtion address:"<<(int*)*(int*)this+<< endl;
  48.     }
  49.   void CallFirstVtblFunction()
  50.     {
  51.     fun = (fun_pointer)* ( (int*) *(int*)this+);
  52.      cout<<"CallFirstVbtlFunction:"<<endl;
  53.      fun();
  54.     }
  55.   void CallSecondVtblFunction()
  56.     {
  57.     fun = (fun_pointer)* ( (int*) *(int*)this+);
  58.      cout<<"CallSecondVbtlFunction:"<<endl;
  59.      fun();
  60.     }
  61. private:
  62.     fun_pointer fun;
  63. };


  64. int TestDrived::var = 3;

  65. int main()
  66. {
  67.  cout<<"sizeof(int):"<<sizeof(int)<<"sizeof(int*)"<<sizeof(int*)<<endl;
  68.  fun_pointer fun = NULL;
  69.  TestDrived a;
  70.  a.GetVtblAddress();
  71.  cout<<"The var's address is:"<<&TestDrived::var<<endl;
  72.  a.GetFirstVtblFunctionAddress();
  73.  a.GetSecondVtblFunctionAddress();
  74.  a.CallFirstVtblFunction();
  75.  a.CallSecondVtblFunction();
  76.  return 0;
  77. }

这里我们通过得到虚函数表的地址调用了里面的虚函数。

这几天又查了下资料,终于搞清楚虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函数表存放在常量段存在一些差别。将上面的文件编译生成最终的可执行文件,然后利用命令:
objdump -s -x  -d a.out | c++filt | grep "vtable" 可以得到以下输出

上面已经很清楚了这两个类Test和TestDrived都存放在.rodata内,至于上面这条命令,稍微解释以下,objdump可以读取
可执行文件中的详细信息,包括可执行文件的header, section, symbol等等,用objdump获得了可执行文件的符号很多都是
我们看不懂的,或者说与我们源代码中的函数或者变量不太一样,这是因为C++支持函数重载,C++对所有的符号都做了
修饰,很多资料称之为“函数签名”或者“符号修饰”类似的概念,但是我们要将其转换为我们源代码中的符号,这就要用到
c++filt命令了,好了,到这里告一段落了,总之关于虚函数表的具体细节就介绍到这里。


几个值得注意的问题

  1.   虚函数表是class specific的,也就是针对一个类来说的,这里有点像一个类里面的staic成员变量,即它是属于一个类所有对象的,不是属于某一个对象特有的,是一个类所有对象共有的。
  2.  虚函数表是编译器来选择实现的,编译器的种类不同,可能实现方式不一样,就像前面我们说的vptr在一个对象的最前面,但是也有其他实现方式,不过目前gcc 和微软的编译器都是将vptr放在对象内存布局的最前面。
  3.  虽然我们知道vptr指向虚函数表,那么虚函数表具体存放在内存哪个位置呢,虽然这里我们已经可以得到虚函数表的地址。实际上虚函数指针是在构造函数执行时初始化的,而虚函数表是存放在可执行文件中的。下面的一篇博客测试了微软的编译器将虚函数表存放在了目标文件或者可执行文件的常量段中,http://blog.csdn.net/vicness/article/details/3962767,不过我在gcc下的汇编文件中没有找到vtbl的具体存放位置,主要是对可执行文件的装载和运行原理还没有深刻的理解,相信不久有了这些知识之后会很轻松的找到虚函数表到底存放在目标文件的哪一个段中。
  4. 经过测试,在gcc编译器的实现中虚函数表vtable存放在可执行文件的只读数据段.rodata中。



引言:近日CSDN的"C/C++语言"版的一个问题引起了我的注意:"请问虚函数表放在哪里?"。我也曾经思考过这个问题,零零散散也有一定的收获,这次正好趁这个机会把我对这一部分的理解整理一下。 首先值得声明的是,本文的编译环境是VS2002+WinXP。C++标准并没有对虚函数的实现作出任何的说明,甚至都没有提到虚函数的实现需要用虚表来实现,只不过主流的C++编译器的虚函数机制都是通过虚表来实现的,所以用虚表来实现虚函数就成了"不是标准的标准"。但是这并不代表所有编译器在实现细节上的处理都是完全一致的,它们或多或少都存在一定的个体差异。所以,本文的结论不一定适用于其他的编译情况。

虚函数/虚表的基础知识
一个类存在虚函数,那么编译器就会为这个类生成一个虚表,在虚表里存放的是这个类所有虚函数的地址。当生成类对象的时候,编译器会自动的将类对象的前四个字节设置为虚表的地址,而这四个字节就可以看作是一个指向虚表的指针。虚表里依次存放的是虚函数的地址,每个虚函数的地址占4个字节。

编译模块内部虚表存放的位置
如果一个模块定义了拥有虚表的类,那么这个类的虚表存放在那里呢?要回答这个问题,我们还是需要用汇编代码入手,我首先建立了一个简单的Win32 Console Application,然后定义了一个带虚函数的类,在相应的汇编代码中,我找到了重要的破解虚表存放位置的重要线索:

CONST    SEGMENT
  
??_7CDerived@@6B@                                     ; CDerived::`vftable'
      DD FLAT:?foobar@CDerived@@UAEXXZ
      DD FLAT:
?callMe@CDerived@@UAEXXZ
  ; Function compile flags: 
/Odt /RTCsu /ZI
CONST    ENDS

以上的汇编代码给了我们这样的信息:
1> 虚表存放的位置应该实在模块的常量段中;
2> 这个类有两个虚函数,它们分别是?foobar@CDerived@@UAEXXZ和?callMe@CDerived@@UAEXXZ。

外部模块虚表存放的位置
当一个模块导出了一个带虚表的类,而另外一个模块又使用了这个导出类,这时候情况又是什么样的呢?这里存在两种很自然的处理方式:
1。维护一份虚表。虚表放在定义导出类的那个模块,任何使用这个导出类的其他模块都要通过这个模块来使用导出类。
2。维护多份虚表。这时候每一个使用导出类的模块都会有一份虚表的拷贝。
VS2002是使用那一种情况呢?在假设存在多份虚表的前提下,我们可以使用这样的策略来判断VS2002使用那种方式:
1。在类定义模块中创建一个类对象,并在另外一个模块中使用这个类对象。在类定义模块中创建类对象保证编译器用类定义模块中的虚表来初始化类对象。
2。在模块(非类定义模块)中创建并类对象并使用它。这样就保证编译器会用模块中的虚表来初始化类对象。
3。分别获取两种情况下两个类对象的虚表指针。如果它们的值相等,就说明只存在一份虚表;如果它们的值不等,就说明存在多份虚表。
4。如果两个虚表指针的值相等,则虚表来自于两个模块中的一个模块,判断这个虚表来自于那个模块。

应用上面的策略,我们首先建立一个Win32 DLL工程导出一个带虚表的类,再建立一个Win32 Consle Application使用这个导出类。在Win32 Consle Application的主函数中,我写了以下的代码:
    CDllInDepth* pObjInAnotherDLL = createObject();
    
int vTableAdress = *reinterpret_cast<int*>(pObjInAnotherDLL);
    
int vFuncAddress = *reinterpret_cast<int*>(vTableAdress);
    pObjInAnotherDLL->dumpMe();

    CDllInDepth* pObjInMyApp = 
new CDllInDepth;
    
int vTableAdress2 = *reinterpret_cast<int*>(pObjInMyApp);
    
int vFuncAddress2 = *reinterpret_cast<int*>(vTableAdress);
    pObjInMyApp->dumpMe();

对这段代码做如下的解释:
1。createObject()是DLL导出了一个全局函数。这个全局函数实现的功能就是生成一个类对象并将类对象的地址传出。这样做的目的就是为了在类定义模块中生成一个类对象。
2。获得虚表指针和虚函数的代码可以这样分析:由于虚表指针存放在类对象的前4个字节中,我们首先需要将类对象的首地址转化成int型指针,并通过这个int型指针获得前4个字节的内容,这个内容就是虚表的地址。接着我们将这个虚表的地址再转化成int型指针,并通过这个int型指针获得虚表的前4个字节的内容,这个内容就是虚表的第一项的值,也就是一个虚函数的地址。

通过调试,我们得出这样的结果:
vTableAdress  = 0x1001401C        vFuncAddress  = 0x1001103C
vTableAdress2 
= 0x1001401C        vFuncAddress2 = 0x1001103C

比较vTableAdress和vTableAdress2的值我们发现它们的值是完全一样的,这就说明我们的假设是不正确的,这里是存在一份虚表。那最后的一个问题就是这个虚表是来自于那个模块呢?这个答案我们需要通过比较虚表的地址以及模块所占的内存空间来解答。在调试状态下,打开"模块"窗口,我们就可以找到模块的地址:
  TestApp.exe            00400000-00417000
  DllInDepth.dll         
10000000-10019000

其中的DllInDepth.dll模块就是定义导出类的模块,而TestApp.exe就是使用这个类的模块。通过比较不难发现,虚表的地址落在DllInDepth.dll的地址范围内,这就说明了虚表来在于类定义的模块。

到了现在,关于虚表存放的问题基本上都得到了圆满的解决,但是我又有了一个新的问题:为什么会是这样的情况呢?我想,大概应该是这样的原因吧:类对象虚表指针的初始化应该发生在构造函数被调用的时候,更具体的说应该实在进入到构造函数"{"之前,这个时机就是通常所说的构造函数"初始化列表"被执行的时候。由于构造函数是在类定义模块中执行的,当然虚表也应该是类定义模块的虚表,对于其他的模块而言就是导入函数的调用,这样就没有必要维护多份虚表了。

后记

虽然本文的主要内容是讨论虚表的位置,实际上本文涉及到DLL导出类的内容。在论坛上也经常看到一些网友对DLL导出类的内容感到迷惑。相对于简单的函数和数据,类的构成将显得比较复杂,类声明中可以包含任意类型的数据,成员函数,虚函数,静态函数,我们就不禁迷惑这些东西是以什么样的方式导出并让其他的模块使用的?对于这个问题,我不禁想到了一个很有名的缩写"KISS(Keep It Simple, Stupid)"。这是一个很有用的思维方式,我们就不妨尝试使用这种思维方法从简单的出发点开始思考。对于DLL来说,作为一个模块,用户感兴趣的无非是代码和数据:
  •     对于代码来说,DLL是以函数符号的形式导出的。
  •     对于数据来说,DLL是以数据符号的形式导出的。

在对C++类的结构(或者说模型)进行深入分析的基础上,我们知道,对于C++类,它既有代码,也有数据:

  •    代码是以类的成员函数,类的虚函数和类的静态函数的形式存在的;
  •    数据包含类的静态成员变量和类的虚表。

由此可见,从本质上来说,DLL导出类的情况就是导出函数和数据,并没有什么神秘的。如果我们再加上类的特殊性的分析,问题的答案就清晰了:

  1. 对于成员函数,虚函数,静态函数和静态数据,他们都处于类的作用域内,所以他们导出的函数符号中应该包含类的信息。
  2. 对于成员函数和虚函数,他们的第一个参数应该是指向类对象的指针,并且他们以"__thiscall"的调用习惯(calling convention)调用。
  3. 对于类的静态函数和静态数据,DLL按照全局函数和全局数据的处理方式一样处理他们。
  4. 虚表是以常量的形式导出的。

^_^,DLL导出类的情况尽是如此的简单,没有想到吧,不过"情况就是这样的"。




以前虽然考虑过这个问题,但是试了下以后就以为虚函数表在内存的代码区里,后来还被问到虚函数表在内存中的哪里,回答不同编译器对虚函数的处理是不一样的,今天仔细的测了测。

  当然以下的测试是在win7的VS2010下。有错误欢迎批评指出,谢谢。

  测试代码

复制代码
#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(){    Base1* d=new Derive;    return 0;}
复制代码

观察其中一个虚函数的指针指向的位置:

注意下面那个字符串,如果你没有看到字符串的话一定不知道这是在内存的哪个区,看到字符串就知道,这是在内存的常量区,也就是虚函数被放在这里,被放在这里也还是比较容易理解的,因为,虚函数表是不需要改变的,放在常量区是比较保险的做法。

后再在网查了查,知道虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata)。

 

总结:虚函数表由于一旦产生就具有不变性,所以编译器就会经量把它放到稳定(或者说是只读)的内存区。


0 0