C++对象内存布局
写这个文章完全是因为想要搞清楚 vc 怎么布局每个 c++ 对象,以及怎样完成指针的转换的过程.
先问一个问题,两个不同类型的指针相互转换以后,他们在数值上是一样的吗?比如:
int nValue = 10;
int *pInt = &nValue;
void *pVoid = pInt;
char *pChar = (char*)pInt;
这些指针的值(不是说指针指向的内存的内容)是一样的吗? 如果你的回答是
yes,那如果是一个类的继承体系呢?在继承类向基类转换的过程中,指针的数值还是不变化的么?如果你的回答是"不一定会变化,要看类的体系是怎么设计的
"的话,那恭喜你,不用看下去了.如果你还不确定究竟变还是不变,究竟哪些变,哪些不变,究竟为什么要变为什么不变的话,接着看下来.
c++ 标准不规定 c++ 实现的时候的对象的具体的内存布局,除了在某些方面有小的限制以外,c++
对象在内存里面的布局完全是由编译器自行决定,这里我也只是讨论 vc++ .net 2003 build 7.1.3091
的实现方式,我并没有在 vc5 vc6 vc.net 2002 以及其他的 2003 build
上面做过测试,结论也许不适合那些编译平台.这些属于编译器具体实现,ms 保留有在不通知你我的情况下作出更改的权利.废话这么多,马上开始.
对于 c 的内建指针的转换,结果是不用多讨论的,我们只是讨论 c++ 的对象.从最简单的开始.
class CBase
{
public:
int m_nBaseValue;
};
这样的一个类在内存里放置是非常简单的,他占有4个 bytes 的空间,不用多说,我们从他派生一个类出来.
class CDerive1 : public CBase
{
public:
int m_nDerive1Value;
};
CDerive1 的对象在内存里面是怎么放的呢? 也很简单,占有8个 bytes 的空间,前4个 bytes 属于 CBase 类,后四个
bytes 属于自己.一个CDerive1 的指针转换成一个 CBase 的指针,结果是一样的.下面我们加上多重继承看看.
class CFinal : public CDerive,public CBase // 这里的 CDerive 是一个和 CBase 差不多的基类
{
public:
int m_nFinalValue;
};
CFinal 的对象在内存里面的布局稍微复杂一点,但是也很容易想象,他占有 12 个 bytes 的空间,前4个属于
CDerive,中间4个属于 CBase,后面4个才是自己的.那一个 CFinal 的指针转换成一个 CDerive 指针,数值会变么?
转换成一个 CBase 指针呢?又会变化么?答案是,前一个不变,后一个要变化,道理非常的明显,CFinal 对象的开头刚好是一个
CDerive 对象,而 CBase 对象却在 CFinal 对象的中间,自然是要变化的了,具体怎么变化呢? 加 4 就
ok(自然要检查是否是空指针).
CBase *pBase = pFinal ? (CBase*)((char*)pFinal + sizeof(CDerive)) : 0;// 当你写下 pBase = pFinal 的时候,其实是这样的
这种不带 virtual 的继承就这么简单,只是加上一个 offset 而已.下面我们看看如果加上 virtual function 的时候是什么样子的呢?
还是从简单类开始.
class CBase
{
public:
virtual void VirtualBaseFunction(){}
int m_nBaseValue;
};
这里刻意没有使用 virtual destructor,因为这个函数稍微有些不同.还是同样的问题,CBase 类在内存上占多大的空间?还是
4 bytes 么? 答案是 no, 在我的编译器上面是 8 bytes,多出来的 4 bytes 是 __vfptr(watch
窗口看见的名字),他是一个指针,指向了类的 vtable,那什么是 vtable 呢,他是用来干什么的呢? vtable 是用来支援
virtual function
机制的,他其实是一个函数指针数组(并不等同于c/c++语言里面的指针数组,因为他们的类型并不一定是一样的.)他的每一个元素都指向了一个你定义的
virtual
function,这样通过一个中间层来到达动态连编的效果,这些指针是在程序运行的时候准备妥当的,而不是在编译的时候准备妥当的,这个就是动态联编的
目的,具体是由谁来设置这些指针的呢?constructor/destructor/copy constructor/assignment
operator他们完成的,不用奇怪,编译器会在你写的这些函数里面安插些必要的代码用来设置 vtable
的值,如果你没有写这些函数,编译器会在适当的时候帮你生成这些函数.明白一点, vtable 是用来支持 virtual function
机制的,而需要 virtual 机制的类基本上都会由一个 __vfptr 指向他自己的 vtable.在调用 virtual
function的时候,编译器这样完成:
pBase->VirtualBaseFunction(); => pBase->__vfptr[0]();// 0 是你的virtual function 在 vtable 中的 slot number,编译器决定
现在应该很想象 CBase 的大小了吧,那这个 __vfptr 是放到什么位置的呢? 在 m_nBaseValue 之前还是之后呢?
在我的编译器上看来,是在之前,为什么要放到之前,是因为在通过 指向类成员函数的指针调用 virtual function
的时候能少些代码(指汇编代码),这个原因这里就不深入讨论了,有兴趣的同学可以看看 inside the c++ object model 一书.
接下来,我们加上继承来看看.
class CDerive1 : public CBase
{
public:
virtual void VirtualDerive1Function();
};
这个时候你也许要说,内存布局跟没有 virtual 是一样的,只不过每个类多了一个 __vfptr
而已,呃...这个是不对的,在我的编译器上面 两个类共享同一个 __vfptr, vtable
里面放有两个指针,一个是两个类共享的,一个只属于 CDerive1 类,调用的时候如何呢?
pDerive1->VirtualDerive1Function() => pDerive1->__vfptr[1]();
pDerive1->VirtualBaseFunction() => pDerive1->__vfptr[0]();
至于指针的相互转换,数值还是没有变化的(也正是追求这种效果,所以把 __vfptr 放到类的开头,因为调整 this 指针也是要占有运行时的时间的).
现在加上多重继承瞧瞧,代码我不写上来了,就跟上面的 CFinal, CDerive, CBase
体系一样,只是每个类多一个VirtualxxxFunction出来,这个时候的指针调整还是没有什么变化,所以我们只是看看 vtable
的情况,你会说 CDerive 和 CFinal 共享一个 __vfptr,而 CBase 有一个自己的 __vfptr,而 CFinal 的
__vfptr 有 2 个slot,这个结论是正确的. 同时你也会说 通过 CFinal 类调用 CBase 的函数是要进行指针调整的,yes
you'r right,不仅仅是 this 指针调整(呃,this 指针会成为 function 的一个参数),还要调整 vtable 的值:
pFinal->VirtualBaseFunction() => (CBase*)((char*)pFinal + sizeof(CDerive))->__vfptr[0]();
转换成 asm 的代码大约是这样的:
mov eax,[pFinal] ; pFinal is a local object,pFinal will be epb - xx
add eax,8 ; 8 = sizeof(CDerive)
mov ecx,eax ; ecx is this pointer
mov edx,[eax] ; edx = vtable address
call [edx] ; call vtable[0]
写到这里也就明白this指针是怎么调整的.带 virtual function 的继承也不复杂,this指针调整也是很简单的,下面看最复杂的部分 virtual inheritance.
我的编译器支持虚拟继承的方式和虚函数的方式差不多,都是通过一个 table 完成,只是这个就看不到 vc 赋予的名字了,我们叫他
vbtable 吧,编译器同样在类里面加入一个指向 vbtable 的指针,我们叫他 __vbptr 吧,这个指针指向了 vbtable ,而
vbtable 里面的每一项对应了一个基类,vbtable
记录了每个基类的某一个偏移量,通过这个偏移量就能计算出具体类的指针的位置.看个简单的例子:
class CBase
{
public:
virtual ~CBase(){}
};
class CMid1 : public virtual CBase
{
public:
virtual ~CMid1(){}
int m_nMid1;
};
class CMid2 : public virtual CBase
{
public:
virtual ~CMid2(){}
int m_nMid2;
};
class CFinal : public CMid1,public CMid2
{
public:
virtual ~CFinal(){}
int m_nFinal;
};
CFinal final;
CFinal *pFinal = &final; // pFinal = 0x0012feb4;
CBase *pBase = pFinal; // pBase = 0x0012fec8 = pFinal + 0x14;
CMid1 *pMid1 = pFinal; // pMid1 = 0x0012feb4 = pFinal;
CMid2 *pMid2 = pFinal; // pMid2 = 0x004210b4 = pFinal;
结果让你吃惊吗? 最奇怪的地方居然是 CMid2 和 CMid1 的地址居然是一样的,这个是因为 vc 把 vbtable 放到了
CFinal 类的开头的原因,而CMid1 和 CMid2 也同样要使用这个 vbtable, 所以 这个三个的地址也就必须相同了.那
CBase 的地址是怎么出来的呢? 呃...刚刚我们说了 vbtable 放到了CFinal 的开头(vc
一定会放在开头吗?答案是不一定,这个稍后解释).在我的机器上面 final 对应内存的第一个 dword 是
0x00426030,查看这个地址,第一个dword 是 0 ,第二个就是 0x14,刚好和 pBase
的偏移相同,这个只是巧合,也许你换个类的继承体系就完全不同了,但是我只是想说明一点,基类的偏移计算是和 vbtable
的值相关联的.下面我们就来看看 vc 是怎么计算这些偏移的.
vc 在分析我们的代码的时候,生成了一份类的继承体系信息,其中有一个叫 thisDisplacement 的_PMD结构:
struct _PMD // total undocumented
{
int mdisp; // i think the meaning is Multiinheritance DISPlacement
int pdisp; // Pointer to vbtable DISPlacement
int vdisp; // Vbtable DISPlacement
};
结构的名字和成员变量的名字确确实实是 vc 的名字(在 watch 窗口输入 (_PMD*)0
就能看到这个结构的详细信息),每个字段的含义却是我自己猜测出来的.mdisp 大概用来表示多重继承(包括单一继承)的时候的偏移量,pdisp
表示 vbtable 的偏移量,而 vdisp 表示类在 vbtable
里面的下标.那么有了这个结构怎样才能完成指针的转换呢?假如我们有一个派生类指针
pFinal,要转换成一个特定的基础类,我们首先要知道和这个基类对应的 _PMD
结构的信息(这个信息的获取,我暂时没有找到一个非常方便的方法,现在我使用的方法下面会有描述),有了这个信息以后,转换就方便了.首先找到
vbtabel 的地址 *(pFinal + pdisp),然后找到基类的偏移 *(*(pFinal + pdisp) + vdisp)
这个偏移值是相对vbtable的,所以还要加上 vbtable的偏移,最后加上 mdisp的偏移,如下:
char *pFinal = xxx; // need a init value
char *pBase; // we must calc
pBase = pFinal + mdisp + *(int *)(*(int *)(pFinal + pdisp) + vdisp) + pdisp;
注意: 当 pdisp < 0 的时候就表示这个类没有 vbtable 直接使用 pFinal + mdisp 就得到结果了.
所以这个结构是一个通用的结构,专门用作类型转换,不管是有无虚继承都能使用这个结构进行类型转换.
通过这个结构,我们也能看到 vc 是怎样布局这个 object 的.
看到这里,也许你要大呼一口气,妈妈呀,一个类型转换要这么的麻烦吗?我直接写 pBase = pFinal 不就可以了吗? 恭喜你还没有被我忽悠得晕头转向,哈哈.其实你写下那行语句的时候,编译器在帮你做这个转换,大约生成下面的代码
mov eax,[pFinal] ;final address
mov ecx,[eax] ; vbtable address *(int *)(pFinal + pdisp)
mov edx,eax ; save to edx
add edx,[ecx + 4] ; ecx + 4 is (*(int *)(pFinal + pdisp) + vdisp)
mov [pBase],edx ; edx = pFinal + mdisp + *(int *)(*(int *)(pFinal + pdisp) + vdisp) + pdisp;
; here mdisp = 0, pdisp = 0, vdisp = 4
也许你要说了,我要这些东西来干什么?要转换的时候直接转换就好了,编译器会帮做,的确,大多数的时候确实是这样,但是,在某些时候却并不如此,现在你
要实现一个功能,输入一个指针,输入一个 _PMD 结构,你要实现一个AdjustPointer
的函数来生成另一个指针.这个时候你也只能这样完成了,因为我没有给你两个指针的名字,就算给了你字符串形式的名字也没有用,呃....你也许会说,办法
是有的,的确是有,模板就能实现这种功能,呵..这个我们暂时不讨论具体的实现细节.也许你要问了,究竟什么时候会去实现这种听都没有听过的功能,其实这
个函数是真正存在的,只不过不是由你来实现的,而是 ms 的人实现的,你只用写一个 带有 c++ 异常的程序,使用 ida
反汇编,然后查找函数,就能找到这个函数了,他用来在异常处理时创建 catch 所需要的
object.至于这个详细的信息,请期待.我会最快速度写出关于 vc 是怎样实现 c++ 异常的文章来.
最后了,说说那个
_PMD 结构的获取方式.看的时候不要吃惊,方法比较的麻烦,比如我想知道和 CFinal 类相关的 _PMD 信息,先新建工作,写下
throw pFinal 这样的语句,编译,在这个语句的地方设置断点,运行,转到反汇编,进入 __CxxThrowException@8
函数,这个时候不出意外你能看到一个叫 pThrowInfo 的东西(如果看不到,请打开"显示符号名"选项),在 watch
窗口里面输入pThrowInfo,展开他,看到一个pCatchableTypeArray,记录下他的
nCacthableTypes的值,然后在 watch 里面输入
pThrowInfo->pCatchableTypeArray->arrayOfCatchableTypes[0]
到 pThrowInfo->pCatchableTypeArray->arrayOfCatchableTypes[n], n
就是你刚刚记录的值减1,再展开他们,你就能看到一个 thisDisplacement 的数据,继续展开就是 mdisp
等等了,很是麻烦吧.哈..你已经猜到了,这个是和异常有关系的.
后记:
这段时间,我一直在读些反汇编之后的代码,也颇有些心得,所以才有想法写一些文章,探讨 vc
编译器鲜为人知(太过狂妄了)的秘密,这个方面的文章也有人写过,那些文章也给我不少的启发,我不认为自己是第一个发现这些秘密的人,但是至少我自己知道
的,我是第一个把这些东西写出来的人.文章里面作墨多的部分都是自己发现的.就这个文章里面的内容来说,inside the c++ object
model 是有比较详细的描写,但是他并不是转换针对 vc 这个编译器的实现,而 _PMD 这个结构我也没有在什么地方见有人描述过,只是在
windows develop network
的2002年12月的杂志上看有人提到过这个结构,可惜他却没有了解(至少他在他发表文章的时候是如是说的)这个结构的用处(正是因为这个原因,我才有写
这个文章以及后续文章的冲动).所以,这个文章也算是我自己的原创吧.这个文件虽然和游戏制造没有太大的关系,但是小 T
自视清高,不愿意自己的文章被一帮不懂的人评价来评价去的,所以也没有发到那些著名的 xxx 网站,只发 goldpoint.转载请注明出处(小 T 对自己的第一个原创文章比较珍惜,比较重视,谢谢).
阅读(979) | 评论(0) | 转发(0) |