每个含有虚函数的类有一张虚函数表(vtbl),表中每一项指向一个虚函数的地址,实现上是一个函数指针的数组。
虚函数表既有继承性又有多态性。每个派生类的vtbl继承了它各个基类的vtbl,如果基类vtbl中包含某一项,则其派生类的vtbl中也将包含同样的一项,但是两项的值可能不同。如果派生类重载(override)了该项对应的虚函数,则派生类vtbl的该项指向重载后的虚函数,没有重载的话,则沿用基类的值。
在类对象的内存布局中,首先是该类的vtbl指针,然后才是对象数据。在通过对象指针调用一个虚函数时,编译器生成的代码将先获取对象类的vtbl指针,然后调用vtbl中对应的项。对于通过对象指针调用的情况,在编译期间无法确定指针指向的是基类对象还是派生类对象,或者是哪个派生类的对象。但是在运行期间执行到调用语句时,这一点已经确定,编译后的调用代码能够根据具体对象获取正确的vtbl,调用正确的虚函数,从而实现多态性。分析一下这里的思想所在,问题的实质是这样,对于发出虚函数调用的这个对象指针,在编译期间缺乏更多的信息,而在运行期间具备足够的信息,但那时已不再进行绑定了,怎么在二者之间作一个过渡呢?把绑定所需的信息用一种通用的数据结构记录下来,该数据结构可以同对象指针相联系,在编译时只需要使用这个数据结构进行抽象的绑定,而在运行期间将会得到真正的绑定。这个数据结构就是vtbl。可以看到,实现用户所需的抽象和多态需要进行后绑定,而编译器又是通过抽象和多态而实现后绑定的。
下面说一下多重继承。多重继承的两个基类如果继承了同一个类,则其派生类相当于继承了该类两次,vtbl也继承了两次。对象布局中,该类的数据有两份,vtbl指针有两个,分别指向两次被继承的vtbl。但派生类重载该类的虚函数时只能重载一次,那么重载后的函数地址将占据vtbl的哪个位置?通过写程序测试,我觉得应该是同时出现在所继承的两个vtbl的相应位置,有待进一步验证。
说到虚函数机制,对象指针的类型转换也是要弄清的,这里就不说了。还有一个this指针的问题,提一下。虚函数调用的时候也是需要传递this指针的,这没什么奇怪,但是这时的this指针就隐含着一个问题,它要和实际调用的虚函数相一致,即this指针也要实现多态性。在多重继承的情况下,这个问题不是那么简单的,请参考[《C++语言的设计和演化》p203]。
------------------------------
虚函数表深度分析- -
昨天听完彭老师的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 <conio.h>
#include <stdio.h>
#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,继续分析