C++虚函数表与对象布局(转)

来源:百度文库 编辑:神马文学网 时间:2024/06/03 16:08:05
每个含有虚函数的类有一张虚函数表(vtbl),表中每一项指向一个虚函数的地址,实现上是一个函数指针的数组。

  虚函数表既有继承性又有多态性。每个派生类的vtbl继承了它各个基类的vtbl,如果基类vtbl中包含某一项,则其派生类的vtbl中也将包含同样的一项,但是两项的值可能不同。如果派生类重载(override)了该项对应的虚函数,则派生类vtbl的该项指向重载后的虚函数,没有重载的话,则沿用基类的值。

  在类对象的内存布局中,首先是该类的vtbl指针,然后才是对象数据。在通过对象指针调用一个虚函数时,编译器生成的代码将先获取对象类的vtbl指针,然后调用vtbl中对应的项。对于通过对象指针调用的情况,在编译期间无法确定指针指向的是基类对象还是派生类对象,或者是哪个派生类的对象。但是在运行期间执行到调用语句时,这一点已经确定,编译后的调用代码能够根据具体对象获取正确的vtbl,调用正确的虚函数,从而实现多态性。分析一下这里的思想所在,问题的实质是这样,对于发出虚函数调用的这个对象指针,在编译期间缺乏更多的信息,而在运行期间具备足够的信息,但那时已不再进行绑定了,怎么在二者之间作一个过渡呢?把绑定所需的信息用一种通用的数据结构记录下来,该数据结构可以同对象指针相联系,在编译时只需要使用这个数据结构进行抽象的绑定,而在运行期间将会得到真正的绑定。这个数据结构就是vtbl。可以看到,实现用户所需的抽象和多态需要进行后绑定,而编译器又是通过抽象和多态而实现后绑定的。

  下面说一下多重继承。多重继承的两个基类如果继承了同一个类,则其派生类相当于继承了该类两次,vtbl也继承了两次。对象布局中,该类的数据有两份,vtbl指针有两个,分别指向两次被继承的vtbl。但派生类重载该类的虚函数时只能重载一次,那么重载后的函数地址将占据vtbl的哪个位置?通过写程序测试,我觉得应该是同时出现在所继承的两个vtbl的相应位置,有待进一步验证。

  说到虚函数机制,对象指针的类型转换也是要弄清的,这里就不说了。还有一个this指针的问题,提一下。虚函数调用的时候也是需要传递this指针的,这没什么奇怪,但是这时的this指针就隐含着一个问题,它要和实际调用的虚函数相一致,即this指针也要实现多态性。在多重继承的情况下,这个问题不是那么简单的,请参考[《C++语言的设计和演化》p203]。

  C++虚函数表深度分析

  昨天听完彭老师的C++的讲座,感觉很不错,但之后留了一个疑问,就是关于虚函数表的机制,课下和彭老师的讨论似乎也没能完全解惑,我的疑问主要就是:

  1:虚函数表到底是怎么工作的,for类,还是for对象

  2:如果for类,那么基类和派生类是共用一表,还是各有各的表(物理上)

  3:如果共用一表的话,总是后面的覆盖前面的函数地址,那不是很容易出现混乱吗?

  带着这三个疑问,趁着热呼劲,我搜了搜关于虚函数表的DASM的文章,当然了,能搜到的几篇都是for VC编译器的

  初步得出了以前结论:

  1:虚表(虚函数表)是for类的

  2:基类和派生类是各有各的表,也就是说他们的物理地址是分开的,基类和派生类的虚表的唯一关联是:当派生类没有实现

  基类虚函数的重载时,派生类会直接把自己表的该函数地址值写为基类的该函数地址值.

  3:任何一个有虚表的类,在实例化时不允许其虚表内有项为空->纯虚类不能初始化对象

  4:带虚表的类在对象构造函数中,会把一个指针指向该类虚表地址,我在这给它起个名字叫vp;

  5:仅对于VC和BC两种编译器论,如果该类带有虚表,那么该类的对象的首地址就是虚表地址,也是this指针指向虚表

  下面我就用IDE Borland C++ Builder 6.0 sp4,编译器版本Borland C++ 5.5,来验证一下:

  首先打开BCB6建立一个控制台程序,写上下面几个备用类

  #include

  #include

  #pragma hdrstop

  #pragma argsused

  class A

  {

  public:

  __stdcall A()

  {

  }

  virtual void __stdcall output()

  {

  printf("Class An");

  }

  virtual void __stdcall output2()

  {

  }

  };

  class B :public A

  {

  public:

  void __stdcall output()

  {

  printf("Class Bn");

  }

  };

  class C:public A

  {

  public :

  void __stdcall output()

  {

  printf("Class Cn");

  }

  };

  几个类很简单,B和C是A的派生

  下面先写一个引子主程序,用来验证虚表的存在:

  int main(int argc, char* argv[])

  {

  B b;

  printf("%d",sizeof(b));

  }

  结果是8

  我把A类的两个virtual都去掉后再运行一次

  结果是4

  这说明了有virtual比没virtual的对象多了32位,在win32中,32位正好是一个地址,那么这个地址就应该指向的是虚表

  看来虚表果然存在,那么虚表指针是在对象什么时候生成的呢?我改一下main函数

  int main(int argc, char* argv[])

  {

  A *pa;

  B b;

  C c;

  A a;

  pa=&b;

  pa->output();

  getch();

  return 0;

  }

  这应该是一个很经典的教科书上讲多态的例子,如果有virtual输出Class B,如果没virtual输出Class A

  现在看一下这段代码的反编译代码,我把BCB6的full debug模式打开,在 B b; 处设断点

  图片

  我们可以看到在b执行完基类的构造函数后,执行了

  mov edx,0x0040c114

  mov [ebp-0x0x],edx

  而这两句话经验证,在没有virtual关键字时是没有的,让我们记住0x0040c114这个地址先

  [ebp-0x0x]是this指针,我们目前猜测这段话就是把虚表的地址写入this指针

  我们再看C c;后的反编译代码

  mov eax,0x0040c0f8

  mov [ebp-0x14],eax

  看来不同的类具有不同的虚表地址,也就是不同的类的表从物理上是不同的

  我们现在来探讨虚表工作的原理

  我们对比一下pa->output()在有没有virtual修饰时候的区别

  mov eax,[ebp-0x04]

  push eax

  mov edx,[eax]

  call dword ptr [edx]

  这是有virtual的

  push dword ptr [ebp-0x04]

  call A::output();

  这是没有virtual的

  我们分析一下asm代码,可以得出虚表的过程,先把根据this地址得到虚表地址,然后由虚表项里存放的函数指针地址,访问

  相应的函数,如果有多个虚函数,且调用的是第N个虚函数,那么上句call指令就会被更改为这样的形式:call dword ptr

  [edx-4*(N-1)])

  一上是我们对dasm代码做的一些推测,一会儿我们还要进一步验证这些

  我们仔细看反编译的结果,发现在A a;的dasm结果中,好象没有vp初始化的一步,我查了其他文献针对VC编译器的dasm结

  果,发现VC编译器的dasm结果里是有初始化vp的一步的,类似

  004010E8 mov dword ptr [eax],offset Derive::`vftable' (0042201c)

  我现在就得出这样一个结论,在BC编译器中很可能对于基类的对象构造函数作出了这样的优化,就是默认把this指针指向

  虚表地址,所以我们看不到这样的dasm结果

  我还发现,对于类的构造函数处理,VC和BC的编译器也是不一样的

  如果我们在类里面没有写构造函数,VC会自动为我们加一个构造函数,比如

  class Base {

  public:

  void __stdcall Output() {

  printf("Class Basen");

  }

  };

  我们得到这样的dasm:

  004010D9 pop ecx

  004010DA mov dword ptr [ebp-4],ecx

  004010DD mov ecx,dword ptr [ebp-4]

  004010E0 call @ILT+30(Base::Base) (00401023)

  可以看到自动生成构造函数地址

  但在BC中,我们没有看到这样的代码

  当我们把上面的A类里面的构造函数删去后,这是得到的A a;的dasm

  mov edx, 0x0040c0f0

  mov [ebp-0x04],ecx

  完全找不到构造函数的影子,我猜测这也是编译器对构造函数所作出的优化

  我这里不评价两种编译器在这问题上的优次,我继续回到正题,验证我们的结论的正确性

  因为按照我们的推测,0x0040c114就是虚表地址

  那么按照此理,我们通过访问虚表地址的内容里的第一个函数地址,就能访问output函数,而虚表的地址就是this地址,是这样

  吗,我再编了个main函数

  int main(int argc, char* argv[])

  {

  A *pa;

  B b;

  C c;

  A a;

  //pa=&b;

  //pa->output();

  //printf("%d",sizeof(b));

  typedef void (__stdcall *PF)(void);

  void *pthis=&b;

  PF pf=(PF)(*(unsigned int*)pthis);

  printf("%x",pf);

  printf("n");

  pf=(PF)(*(unsigned int*)pf);

  pf();

  getch();

  return 0;

  }

  先来解释一下这段代码

  typedef void (__stdcall *PF)(void);

  声明了配搭output的函数指针

  void *pthis=&b;

  用来得到b的this地址,它是指向虚表地址的

  PF pf=(PF)(*(unsigned int*)pthis);

  用来得到this地址的内容,也就是虚表地址

  然后我们把虚表地址输出

  pf=(PF)(*(unsigned int*)pf);

  用来得到虚表里第一项的内容,也就是output的地址(表第一项目地址=表地址)

  pf(); 调用函数

  我们来看结果

  成功了!!!

  虽然我们没有在代码里写output();但执行结果就是输出了output的结果

  另外输出的虚表地址就是0x0040c114,也就是我们最早推测的虚表地址!!!

  我把代码改下一下,按照我们的推测,如果把表第一项地址偏移32位,应该就是表第二项地址,而第二项的内容就应该是

  output2的地址,验证一下:

  typedef void (__stdcall *PF)(void);

  void *pthis=&b;

  PF pf=(PF)(*(unsigned int*)pthis);

  printf("%x",pf);

  printf("n");

  pf=(PF)(*( (unsigned int*)pf-0x04 ) );

  pf();

  完全不出我们所料,输出就是Class A output2

  到这里,应该对虚表的机制很清楚了,每个类都有各的虚表,每个类生成的各对象分别把this指向类的虚表地址,如果本类没

  有重载基类的虚函数,那么虚表的该项会写为基类的该项的内容,在调用虚表的时候,会根据虚表地址做适当的偏移以得到

  相应的虚函数地址,再进行调用.

  先分析到这,以后我会就修改虚表地址,以及如何应用虚表做hook,继续分析