最近工作上的项目需要 hook COM 组件,顺便把 C++ 类实例的内存布局重温和巩固了一下,特别是有 virtual function 的 class。近年来记忆力衰退:D,所以顺便记一笔吧。
用下面两个有 virtual function 和继承关系的 class 来做例子:
- class base
- {
- public:
- virtual int func1( int foo ) { return( foo % 3 ); }
- virtual float func2( float foo ) { return( foo / 1.2f ); }
- };
-
- class derived : public base
- {
- public:
- derived() { m = 0xf0f0f0f0; }
-
- virtual int func1( int foo ) { return( foo + m ); }
- bool func3( bool bar ) { return( !bar ); }
-
- private:
- int m;
- };
C++ 标准里并没有对虚函数的实现做说明,但是几乎所有的编译器都使用虚表来实现 virtual function。下面的这段小程序围绕上面两个类做了一些事情:
- typedef struct
- {
- int *pVtbl;
- } class_with_vtbl;
-
- int main( int argc, char *argv[] )
- {
- base *pBase = new base;
- derived *pDerived = new derived;
-
- printf( "pBase = %x\n", pBase );
- printf( "pDerived = %x\n", pDerived );
-
- printf( "sizeof( base ) = %x\n", sizeof( base ) );
- printf( "sizeof( derived ) = %x\n", sizeof( derived ) );
-
- printf( "[pBase] = %x\n", ((int *)pBase)[0] );
- printf( "[pDerived] = %x\n", ((int *)pDerived)[0] );
-
- printf( "[pBase + 4] = %x\n", ((int *)pBase)[1] );
- printf( "[pDerived + 4] = %x\n", ((int *)pDerived)[1] );
-
- printf( "pBase->pVtbl[0] = %x\n", ((class_with_vtbl *)pBase)->pVtbl[0] );
- printf( "pBase->pVtbl[1] = %x\n", ((class_with_vtbl *)pBase)->pVtbl[1] );
-
- printf( "pDerived->pVtbl[0] = %x\n", ((class_with_vtbl *)pDerived)->pVtbl[0] );
- printf( "pDerived->pVtbl[1] = %x\n", ((class_with_vtbl *)pDerived)->pVtbl[1] );
-
- printf( "pBase->func1 = %x\n", pBase->func1 );
- printf( "pBase->func2 = %x\n", pBase->func2 );
-
- printf( "pDerived->func1 = %x\n", pDerived->func1 );
- printf( "pDerived->func2 = %x\n", pDerived->func2 );
- printf( "pDerived->func3 = %x\n", pDerived->func3 );
-
- pDerived->func1( 0 );
-
- typedef int (base::* func1_type)( int );
- func1_type pFunc1 = pBase->func1;
- (pBase->*pFunc1)( 0 );
-
- return 0;
- }
程序在 VC6 下编译,某次的执行结果如下:
- pBase = 431a20
- pDerived = 4319e0
- sizeof( base ) = 4
- sizeof( derived ) = 8
- [pBase] = 42720c
- [pDerived] = 4271b4
- [pBase + 4] = fdfdfdfd
- [pDerived + 4] = f0f0f0f0
- pBase->pVtbl[0] = 401023
- pBase->pVtbl[1] = 401005
- pDerived->pVtbl[0] = 40100a
- pDerived->pVtbl[1] = 401005
- pBase->func1 = 401028
- pBase->func2 = 40102d
- pDerived->func1 = 401028
- pDerived->func2 = 40102d
- pDerived->func3 = 401037
接下来通过对程序运行结果的解读来分析一下带有虚函数的类实例在内存中的布局(程序用 VC 6 编译)。结果中的前两行是两个类实例的内存地址。
接下来的第 3、4 行显示的是两个类实例占用的内存大小,base 没有成员变量,但是占用了 4 个 byte,而它是有 2 个虚函数的,所以这 4 个字节是一个指向虚表的指针;derived 有一个 int 型的成员变量,它同样也有虚函数,所以占用 8 个字节。
第 5、6 行是两个类实例所在内存的第一个 dword 的内容,没什么特别;但是看看第 7、8 行(显示两个类实例所在内存的第二个 dword 内容),第 8 行表明了虚表总是位于任何成员变量之前的,也就是说虚表指针总是在类实例内存布局中的第一项。
在上面程序里,我们用一个名叫 class_with_vtbl 的结构来强转类实例指针,从而可以方便的展示虚表的内容。9~12 行就是 base 和 derived 的虚表内容,可以看到,它们虚表的第二项内容相同,这是因为 derived 没有改写基类的 func2。
接下来 13~16 行的结果看上去比较令人费解:
1) pBase->func1 跟 pBase->pVtbl[0] 的内容不符,这么说来,虚表里的内容不是指向虚函数的吗?其实如果说这是一个 surprise 的话,你在调试时在 watch 窗口里输入 base::func1 看到的东西会更让你惊讶,这样看到的值居然是 0×401350!
2) 如果说 base::func2() 和 derived::func2() 的地址相同还可以理解的话,则么居然 base::func1() 和 derived::func1() 的值竟然也一样!
下面先来解决第 2 个疑问,代码和实际数据说明一切,在调试器里看一下 401028 及其周边的内容:
- @ILT+0(?func2@base@@UAEMM@Z):
- 00401005 jmp base::func2 (00401250)
- @ILT+5(?func1@derived@@UAEHH@Z):
- 0040100A jmp derived::func1 (00401290)
- @ILT+10(??0base@@QAE@XZ):
- 0040100F jmp base::base (00401310)
- @ILT+15(??0derived@@QAE@XZ):
- 00401014 jmp derived::derived (004011f0)
- @ILT+20(_main):
- 00401019 jmp main (00410130)
- @ILT+25(?func3@derived@@UAE_N_N@Z):
- 0040101E jmp derived::func3 (004012d0)
- @ILT+30(?func1@base@@UAEHH@Z):
- 00401023 jmp base::func1 (00401350)
- 00401028 jmp `vcall' (00401240)
- 0040102D jmp `vcall' (00401280)
- 00401032 jmp `vcall' (00401340)
- 00401037 jmp derived::func3 (004012d0)
可以看到 401028 地址处有一条 jmp 指令,它其实是个 vcall,可以理解为专门用来调用虚函数(vfunction)的 call。401028 处的 vcall 要跳转到 401240 处,所以我们来看一下该地址处的代码:
- `vcall':
- 00401240 mov eax,dword ptr [ecx]
- 00401242 jmp dword ptr [eax]
在做 vcall 之前,caller 要把类实例指针放在 ecx 里面,这个 vcall 事实上是调用所有对象第一个虚函数的通用代码,它把虚表的第一项取出来放在 eax 里,接着就跳转到 eax 所含的那个地址去继续执行了。看看 401280 处(也是 pBase->func2 和 pBase->func2 共同指向的内容 )另外一个 vcall 的实现会更清楚一些:
- `vcall':
- 00401280 mov eax,dword ptr [ecx]
- 00401282 jmp dword ptr [eax+4]
由于是第二个虚函数,所以在把虚表入口地址放入 eax 之后,程序是取出了虚表的第二项([eax+4]),接着才跳转过去的。
这就解释了刚才的第 2 个疑问,其实在程序里用 pBase->func1 拿到的并不是 func1 的真实入口地址,而只是一个通用的 vcall thunk 地址。
下面来解决第 1 个疑问,通过上面的描述,现在可以知道 pBase->func1 指向的其实是 vcall thunk,那么 pBase->pVtbl[0] 指向的又是什么?还是来看代码,其实上面已经列出来了:
- @ILT+30(?func1@base@@UAEHH@Z):
- 00401023 jmp base::func1 (00401350)
居然也是一条 jmp 指令,不过注意一下它 jump 到的地址——401350,这个值跟我们在 VC watch 窗口里看到 base::func1 的值是一样的,其实这个才是真正 base::func1 的地址!问题解决。
这里稍总结一下:
a. 用代码取 pBase->func1 取到的地址其实指向的是 vcall thunk,vcall thunk 最终也是借助虚表里的内容找到真正虚函数地址的
b. 虚表表项也并非直接指向相应的虚函数地址,而是指向一个跳转指令(这貌似有点兜圈子),这个跳转指令才真正的把执行绪引到真实函数体
c. 用 VC 调试器 watch 到的地址就直接是真正的函数入口地址
接下来的篇幅,我们来实际看看用虚表和 vcall 两种方法调用虚函数的过程。看上面实例代码的最后两坨:
这句代码编译后变成:
- 0041031D mov esi,esp
- 0041031F push 0
- 00410321 mov edx,dword ptr [ebp-14h]
- 00410324 mov eax,dword ptr [edx]
- 00410326 mov ecx,dword ptr [ebp-14h]
- 00410329 call dword ptr [eax]
[ebp-14h] 处的 pDerived 对象指针被放入 edx,接着虚表里的第一项被放入 eax,call [eax] 导致代码流程跑到虚表项所指的那个地址继续执行(我们已经知道了,那个地方又是一个 jmp)。可以看到在调用虚函数之前,代码把对象指针(this 指针)存入 ecx,这是 __thiscall 调用习惯(其它调用习惯中,this 指针通过堆栈传递)。
接下来看看 vcall:
- typedef int (base::* func1_type)( int );
- func1_type pFunc1 = pBase->func1;
- (pBase->*pFunc1)( 0 );
成员函数类型无法强转成其它普通指针类型,所以要定义一个“::*”类型的指针来存放它,调用的时候也要用 ->*(或 .*,如果左边是非指针类型的对象)。编译后的汇编代码如下:
- 00410332 mov dword ptr [ebp-18h],offset @ILT+35(`vcall') (00401028)
- 00410339 mov ecx,dword ptr [ebp-18h]
- 0041033C mov dword ptr [ebp-18h],ecx
- 0041033F mov esi,esp
- 00410341 push 0
- 00410343 mov ecx,dword ptr [ebp-10h]
- 00410346 call dword ptr [ebp-18h]
调用类实例第一个虚函数的 vcall thunk 地址被放入 [ebp-18h],接着 pBase 的指针(this 指针)被放入 ecx,call [ebp-18h] 导致程序跳转到 vcall thunk 处执行,最终导致 pBase 的第一个虚函数被调用。