一种实现Win32窗口过程函数(Window Procedure)的新方法
基于Thunk实现的类成员消息处理函数
JERKII.SHANG (JERKII@HOTMAIL.COM)
MAR.10th - 31st, 2006
Windows是一个消息驱动的操作系统,在系统中发生的所有消息均需要通过消息处理过程(或叫窗口过程)进行处理。由于C++给我们在程序设计中带来更多的灵活性(如继承、重载、多态等),所以我们都希望能够使用C++的类来封装Windows中的窗口过程函数,但是Windows规定了窗口过程函数必须定义为一个全局函数,也就是说需要使用面向过程的方法来实现,为了使用面向对象的技术来实现消息处理,我们必须另辟它径。目前我们在网络上见得比较多的方式是使用Thunk将即将传递给窗口过程的第一个参数(HWND hWnd)的值使用类对象的内存地址(即this指针)进行替换(ATL使用的也是这种方法)。这样,在相应的窗口过程中通过将hWnd强制转换成类对象的指针,这样就可以通过该指针调用给类中的成员函数了。但是该方法仍然需要将该消息处理函数定义成一个静态成员函数或者全局函数。本文将介绍一种完全使用(非静态)类成员函数实现Win32的窗口过程函数和窗口过程子类化的新方法。虽然也是基于Thunk,但是实现方法完全不同于之前所说的那种,我所采用的是方法是——通过对Thunk的调用,将类对象的this指针直接传递给在类中定义的窗口处理函数(通过ECX或栈进行传递),这样就能够使Windows直接成功地调用我们窗口过程函数了。另外,本文介绍一种使用C++模板进行消息处理函数的“重载”,这种方法直接避免了虚函数的使用,因此所有基类及其派生类中均无虚函数表指针以及相应的虚函数表(在虚函数较多的情况下,该数组的大小可是相当可观的)。从而为每个类的实例“节省”了不少内存空间(相对于使用传统的函数重载机制)。
关键字: C++ 模板,调用约定,Thunk,机器指令(编码),内嵌汇编
环境:VC7,VC8,32位Windows
内容
•前言
•传统的C++重载
•使用C++模板实现函数的“重载”
•C++对象中的属性和方法(成员函数)
•关于调用约定与this指针的传递
•通过其他途径调用类中的成员函数
•认识Thunk
•让Windows直接调用你在类中定义的(非静态)消息处理函数
•使用ECX传递this指针(__thiscall)
•使用栈传递this指针(__stdcall或__cdecl)
•实现我们的KWIN包
•使用类成员函数子类化窗口过程
•结束语
--------------------------------------------------------------------------------
前言
也许你是一位使用MFC或ATL进行编程的高手,并且能在很短的时间内写出功能齐全的程序。但是,你是否曾经花时间去想过“MFC或ATL是通过什么样的途径来调用我们的消息处理函数的呢?他们是怎样将Windows产生的消息事件传递给我们的呢?”在MFC中定义一个从CWnd继承而来的类,相应的消息事件就会发送到我们定义的类中来,你不觉得这背后所隐藏的一切很奇怪吗?如果你的感觉是这样,那么本文将使用一种简单并且高效的方法来揭开这个神秘的面纱以看个究竟,同时我将非常详细地介绍需要使用到的各种知识,以便能让更多初学者更容易掌握这些知识。
在Windows中,所有的消息均通过窗口过程函数进行处理,窗口过程函数是我们和Windows操作系统建立联系的唯一途径,窗口过程函数的声明均为:
LRESULT __stdcall WndProc
(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);MSDN有对该函数中的每个参数的详细描述,如果你现在仍然对该函数存在疑问,那么请先参照MSDN中的相关内容后,再继续阅读本文。通常,Windows均要求我们将消息处理函数定义为一个全局函数,或者是一个类中的静态成员函数。并且该函数必须采用__stdcall的调用约定(Calling Convention),因为__stdcall的函数在返回前会自己修正ESP的值(由于参数传递所导致的ESP的改变),从而使ESP的值恢复到函数调用之前的状态。
全局函数或静态成员函数的使用使得我们很难发挥C++的优势(除非你一直想使用C进行Windows程序开发,如果是这样,那么你也就没有必要再继续阅读本文了),因为在这种结构下(即面向过程),我们不能很方便地在消息处理函数中使用我们的C++对象,因为在这样的消息处理函数中,我们很难得到我们对象的指针,从而导致我们不能很方便的操作C++对象中的属性。为了解决对Win32的封装,Microsoft先后推出了MFC和ATL,可以说两者都是非常优秀的解决方案,并且一直为多数用户所使用,为他们在Windows下的程序开发提供了很大的便利。
但是,MFC在我们大家的眼里都是一种比较笨重的方法,使用MFC开发出来的程序都必须要在MFC相关动态库的支持下才能运行,并且这些动态库的大小可不一般(VS2005中的mfc80.dll就有1.04M),更为甚者,CWnd中包含大量的虚函数,所以每个从他继承下来的子类都有一个数量相当可观的虚函数表(虽然通过在存在虚函数的类上使用sizeof得到的结果是该类对象的大小只增长了4个字节,即虚函数表指针,但是该指针所指向的虚函数数组同样需要内存空间来存储。一个虚函数在虚函数表中需占用4个字节,如果在我们的程序中用到较多的CWnd的话,定会消耗不少内存(sizeof(CWnd)= 84,sizeof(CDialog)= 116)。ATL与MFC完全不同,ATL采用模板来对Win32中的所有内容进行封装,使用ATL开发出来的程序不需要任何其他动态库的支持(当然,除基本的Windows库外),ATL使用Thunk将C++对象的指针通过消息处理函数的第一个参数(即hWnd)传入,这样,在消息处理函数中,我们就可以很方便地通过该指针来访问C++对象中的属性和成员函数了,但这种方法也必须要借助几个静态成员函数来实现。
本文将采用这几种技术实现一种完全由C++类成员函数(非静态)实现的Windows消息处理机制,并最终开发一个封装Windows消息处理的工具包(KWIN)。首先让我们来看看怎样使用KWIN来开发的一个简单程序:
创建一个简单的窗口程序:
#include "kwin.h"
class MyKWinApp : publicKWindowImpl<MyKWinApp>
{
public:
MyKWinApp () : KWindowImpl<MyKWinApp>("MyKWinAppClassName")
{}
/* Overriede the window procdure */
LRESULT KCALLBACK KWndProc (
HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam)
{ /* Do anthing you want here */
return __super::KWndProc (hWnd, msg, wParam, lParam);
}
BOOL OnDestroy () { PostQuitMessage (0); return TRUE; }
/* Override other message handler */
};
INT __stdcall WinMain (
HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, intnCmdShow)
{
MyKWinApp kapp;
kapp.CreateOverlappedWindow ("This is my first KWinApp");
MSG msg;
while (GetMessage (&msg, 0, 0, 0))
{ TranslateMessage (&msg);
DispatchMessage(&msg);
}
return msg.wParam;
}
创建一个简单的对话框程序:
#include "kwin.h"
class MyKDlgApp : publicKDialogImpl<MyKDlgApp>
{
public:
enum {IDD = IDD_DLG_MYFIRSTDIALOG };
BOOL OnCommand(WORD wNotifyCode, WORD wId, HWND hWndCtrl)
{ if (wId == IDOK) EndDialog(m_hWnd, wId); return TRUE; }
};
INT __stdcall WinMain (
HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, intnCmdShow)
{
MyKDlgApp kapp;
kapp.DoModal ();
return 0;
}怎么样?使用KWIN开发包后,你的程序结构是不是变得更加清晰了呢?你可以在你的类中(如MyKWinApp或MyKDlgApp)“重载”更多的消息处理函数,你甚至可以“重载”窗口过程函数(如MyKWinApp)。这里的重载跟通常意义上的重载可不是一个意思,这里的“重载”是使用C++模板机制实现的,而传统意义上的重载需要通过虚函数(更准确地说应该是虚函数表)来实现。好,你现在是不是很想知道,这个KWIN的内部到底是怎样实现的了吧?好,让我来一步一步带你步入KWIN的内部中去吧,现在你唯一需要的就是耐心,因为这篇文章写得似乎太长了些^_^ ...
传统的C++重载
通常,如果我们要重载父类中的方法,那么我们必须在父类中将该方法声明为虚函数,这样每个拥有虚函数的类对象将会拥有一个自己的虚函数表(指针),也就是说,该虚函数表(数组)就成了该类对象的“‘静态’成员变量(之所以说是‘静态’的,是因为该虚函数数组及其指向该数组的指针在该类的所有实例中均为同一份数据)”了,C++(更确切地说应该是编译器)就是通过虚函数表来实现函数的重载机制的,因为有了虚函数表后,对虚函数的调用就是通过该虚函数表来完成的,因为编译器在生成代码的时候会根据每个虚函数在类中的位置而对其进行编号,并且通过该序号来对虚函数进行调用,所以你通常会在反汇编中看到如下代码:
mov edx, this pointer ; Load EAX with the value of 'thispointer'(or vptr)
mov edx, dword ptr [edx + 4] ; Get theaddress of the second virtual function in vtable
push ... ; Pass argument 1
push ... ; Pass other argumentshere ...
call edx ; Call virtual function当子类从有虚函数的父类中派生时,他将拥有一份独立的虚函数表指针以及虚函数数组,并且“开始时”该数组中的内容和基类一模一样。但是,当编译器在编译时检测到子类重载了父类中的虚函数时,编译器就会修改子类的虚函数数组表,将该表中被重载的虚函数的地址改为子类中的函数地址,对于那些没有被重载的虚函数,该表中的函数地址和父类的虚函数表中的地址一样!当一个子类从多个带有虚函数的父类中继承时,该子类就拥有多个虚函数表指针了(也就是将拥有多个虚函数指针数组),当我们在这种情况下进行指针的转换的时(通常为将子类指针转换成父类指针),所进行最终操作的就是虚函数数组指针的转换。如:
class A :
public VBase1,
public VBase2,
public VBase3
/* VBase1, VBase2, VBase3 均为存在虚函数的父类 */
{ ... };
A a;
VBase1* p1;
VBase2* p2;
VBase3* p3;
p1 = &a;
p2 = &a;
p3 = &a;
// 假定这里a的地址为0x0012f564,那么(按字节为单位)
p1 = &a + 0 = 0x0012f564,
p2 = &a + 4 = 0x0012f568,
p3 = &a + 8 = 0x0012f56C
因为在类对象的内存布局中,编译器总是将虚函数数组指针放在偏移为0的地方。
好了,似乎我们已经跑题了,关于这方面的知识在网上也可以找到很多,如果你有兴趣,可以参见我的另一篇文章:略谈虚函数的调用机制,至此,我相信你已经对C++中的重载机制有一定的认识了吧,现在再让我们来看看怎样在C++中使用模板来实现函数的“重载”。
使用C++模板实现函数的“重载”
通过我们对周围事物(或数据)的抽象,我们得到类。如果我们再对类进行抽象,得到就是一个模板。模板是一种完全基于代码级的重用机制,同时也为我们编写一些结构良好,灵活的程序提供了手段,所有的这一切都得归功于编译器的帮助,因为编译器最终会将我们所有的这些使用这些高级技术编写的代码转换成汇编代码(或者应该说是机器码),好,废话少说为好!^_^。
通常我们会使用下面的方式来实现函数的“重载”,这里似乎没有很复杂的“理论”要阐述,我就简单的把大致实现过程描述一下吧:
template <class T> class TBase
{
public:
void foo1 () { printf ("This is foo1 in TBase\n"); }
void foo2 () { printf ("This is foo2 in TBase\n"); }
void callback ()
{ /* 如果子类重载了TBase中的函数,通过pThis将会直接调用子类中的函数 */
T* pThis = static_cast<T *> (this);
pThis->foo1 ();
pThis->foo2 ();
}
};
class TDerive : public TBase<TDerive>
{
public:
void foo1 () { printf ("This is foo1 in TDerive\n"); } /* “重载”父类中的foo1 */
};
TDerive d;
d.callback ();
输出结果为:
This is foo1 in TDerive
This is foo2 in TBase虽然上面的代码看起来很奇怪,因为子类将自己作为参数传递给父类。并且子类中定义的“重载”函数只能通过“回调”的方式才能被调用,但这对Windows中的消息处理函数来说无疑是一大福音,因为Windows中的消息函数均是回调函数,似乎这样的“重载”就是为Windows中的消息处理函数而定制的。虽然有些奇怪,但是他真的很管用,尤其是在实现我们的消息处理函数的时候。不是吗?
C++对象中的属性和方法(成员函数)
我们通常会将一些相关的属性以及操作这些属性的方法“封装”到一个类中,从而实现所谓的信息(属性)隐藏,各类对象(或类的实例)之间的交互都得通过成员函数来进行,这些属性的值在另一方面也表明了该类对象在特定时刻的状态。由于每个类对象都拥有各自的属性(或状态),所以系统需要为每个类对象分配相应的属性(内存)存储空间。但是类中的成员函数却不是这样,他们不占用类对象的任何内存空间。这就是为什么使用sizeof (your class)总是返回该类中的成员变量的字节大小(如果该类中存在虚函数,则还会多出4个字节的虚函数数组指针来)。因为成员函数所要操作的只是类对象中的属性,他们所关心的不是这些属性的值。
那么,这些类成员函数又是怎么知道他要去操作哪个类对象中的属性呢?答案就是通过this指针。this指针说白了就是一个指向该类对象在创建之后位于内存中的内存地址。当我们调用类中的成员函数时,编译器会“悄悄地”将相应类对象的内存地址(也就是this指针)传给我们的类成员函数,有了这个指针,我们就可以在类成员函数中对该类对象中的属性进行访问了。this指针被“传入”成员函数的方式主要取决于你的类成员函数在声明时所使用的调用约定,如果使用__thiscall调用约定,那么this指针将通过寄存器ECX进行传递,该方式通常是编译器(VC)在缺省情况下使用的调用约定。如果是__stdcall或__cdecl调用约定,this指针将通过栈进行传递,并且this指针将是最后一个被压入栈的参数,虽然我们在声明成员函数时,并没有声明有这个参数。
关于调用约定与this指针的传递
简单说来,调用约定(CallingConvention)主要是用来指定相应的函数在被调用时的参数传递顺序,以及在调用完成后由谁(调用者还是被调用者)来修正ESP寄存器的值(因为调用者向被调用者通过栈来传递参数时,ESP的值会被修改,系统必须要能够保证被调用函数返回后,ESP的值要能够恢复到调用之前的值,这样调用者才能正确的运行下去,对于其他寄存器,编译器通常会自动为我们生成相应的保存与恢复代码,通常是在函数一开始将相关寄存器的值PUSH到栈中,函数返回之前再依次pop出来)。
通常对ESP的修正有两种方式,一种是直接使用ADD ESP, 4*n,一种是RET 4*n(其中n为调用者向被调用者所传递的参数个数,乘4是因为在栈中的每个参数需要占用4个字节,因为编译器为了提高寻址效率,会将所有参数转换成32位,即即使你传递一个字节,那么同样会导致ESP的值减少4)。通常我们使用的调用约定主要有__stdcall,__cdecl,__thiscall,__fastcall。有关这些调用约定的详细说明,请参见MSDN(节Argument Passing and NamingConventions )。这里只简略地描述他们的用途:几乎所有的Windows API均使用__stdcall调用约定,ESP由被调用者函数自行修正,通常在被调用函数返回之前使用RET 4 * n的形式。__cdecl调用约定就不一样,它是由调用者来对ESP进行修正,即在被调用函数返回后,调用者采用ADD ESP, 4 *n的形式进行栈清除,通常你会看到这样的代码:
push argument1
push argument2
call _cdecl_function
add esp, 8
另外一个__cdecl不得不说的功能是,使用__cdecl调用约定的函数可以接受可变数量的参数,我们见得最多的__cdecl函数恐怕要算printf了(int __cdecl printf (char *format, ...)),瞧!是不是很cool啊。因为__cdecl是由调用者来完成栈的清除操作,并且他自己知道自己向被调用函数传递了多少参数,此次他自己也知道该怎样去修正ESP。跟__stdcall比起来,唯一的“缺点”恐怕就是他生成的可执行代码要比__stdcall稍长些(即在每个CALL之后,都需要添加一条ADD ESP, X的指令)。但跟他提供给我们的灵活性来说,这点“缺点”又算什么呢?
__thiscall主要用于类成员函数的调用约定,在VC中它是成员函数的缺省调用约定。他跟__stdcall一样,由被调用者清除调用栈。唯一不同的恐怕就是他们对于this指针的传递方式了吧!在__stdcall和__cdecl中,this指针通过栈传到类成员函数中,并且被最后一个压入栈。而在__thiscall中,this指针通过ECX进行传递,直接通过寄存器进行参数传递当然会得到更好的运行效率。另外一种,__fastcall,之所以叫fast,是因为使用这种调用约定的函数,调用者会“尽可能”的将参数通过寄存器的方式进行传递。另外,编译器将为每种调用约定的函数产生不同的命名修饰(即Name-decoration convention),当然这些只是编译器所关心的东西,我们就不要再深入了吧!。
通过其他途径调用类中的成员函数
对于给定的一个类:
class MemberCallDemo
{
public:
void __stdcall foo (int a) { printf ("In MemberCallDemo::foo, a =%d\n", a); };
};
通常我们会通过下面的方式进行调用该类中的成员方法foo:
MemberCallDemo mcd;
mcd.foo (9);
或者通过函数指针的方式:
void (__stdcallMemberCallDemo::*foo_ptr)(int) = &MemberCallDemo::foo;
(mcd.*foo_ptr) (9);
我总是认为这中使用成员函数指针的调用方式(或是语法)感到很奇怪,不过它毕竟是标准的并且能够为C++编译器认可的调用方式。几乎在所有编译器都不允许将成员函数的地址直接赋给其他类型的变量(如DWORD等,即使使用reinterpret_cast也无济于事)例如:而只能将其赋给给与该成员函数类型声明(包括所使用的调用约定,返回值,函数参数)完全相同的变量。因为成员函数的声明中都有一个隐藏的this指针,该指针会通过ECX或栈的方式传递到成员函数中,为了成员函数被安全调用,编译器禁止此类型的转换也在情理当中。但有时我们为了实现特殊的目的需要将成员函数的地址直接赋给一个DWORD变量(或其他任何能够保存一个指针值的变量,通常只要是一个32位的变量即可),我们可以通过下面两种方法来实现:下面的这几种试图将一个成员函数的地址保存到一个DWORD中都将被编译器认为是语法错误: 我总是认为这中使用成员函数指针的调用方式(或是语法)感到很奇怪,不过它毕竟是标准的并且能够为C++编译器认可的调用方式。几乎在所有编译器都不允许将成员函数的地址直接赋给其他类型的变量(如DWORD等,即使使用reinterpret_cast也无济于事)。下面的这几种试图将一个成员函数的地址保存到一个DWORD中都将被编译器认为是语法错误:
DWORD dwFooAddrPtr= 0;
dwFooAddrPtr = (DWORD)&MemberCallDemo::foo; /* Error C2440 */
dwFooAddrPtr =reinterpret_cast<DWORD> (&MemberCallDemo::foo); /* Error C2440 */因为成员函数的声明中都有一个隐藏的this指针,该指针会通过ECX或栈的方式传递到成员函数中,为了成员函数被安全调用,编译器禁止此类型的转换也在情理当中。但有时我们为了实现特殊的目的需要将成员函数的地址直接赋给一个DWORD变量(或其他任何能够保存一个指针值的变量,通常只要是一个32位的变量即可)。
我们只能将成员函数的地址赋给给与该成员函数类型声明(包括所使用的调用约定,返回值,函数参数)完全相同的变量(如前面的void (__stdcall MemberCallDemo::*foo_ptr)(int) =&MemberCallDemo::foo)。因为成员函数的声明中都有一个隐藏的this指针,该指针会通过ECX或栈的方式传递到成员函数中,为了成员函数被安全调用,编译器禁止此类型的转换也在情理当中。但有时我们为了实现特殊的目的需要将成员函数的地址直接赋给一个DWORD变量(或其他任何能够保存一个指针值的变量,通常只要是一个32位的变量即可),就像我们即将介绍的情况。
通过前面几节的分析我们知道,成员函数的调用和一个普通的非成员函数(如全局函数,或静态成员函数等)唯一不同的是,编译器会在背后“悄悄地”将类对象的内存地址(即this指针)传到类成员函数中,具体的传递方式以该类成员函数所采用的调用约定而定。所以,是不是只要我们能够手动地将这个this指针传递给一个成员函数(这是应该是一个函数地址),是不是就可以使该成员函数被正确调用呢?答案是肯定的,但是我们迫在眉睫需要解决的是怎样才能得到这个成员函数的地址呢?通常,我们有两种方法可以达到此目的:
1。使用内嵌汇编(在VC6及以前的版本中将不能编译通过)
DWORD dwFooAddrPtr = 0;
__asm
{
/*得到MemberCallDemo::foo偏移地址,事实上就是该成员函数的内存地址(起始地址) */
MOV EAX, OFFSET MemberCallDemo::foo
MOV DWORD PTR [dwFooAddrPtr], EAX
}
这种方法虽然看起来甚是奇怪,但是他却能够解决我们所面临的问题。虽然在目前的应用程序开发中,很少甚至几乎没有人使用汇编语言去开发,但是,往往有时一段小小的汇编代码居然能够解决我们使用其他方法不能解决的问题,这在上面的例子和下面即将介绍的Thunk中大家就会看到他那强有力的问题解决能力。所以说我们不能将汇编仍在一边,我们需要了解她,并且能够在适当的时候使用她。毕竟她始终是一个最漂亮,最具征服力的编程语言。^_^
2。通过使用union来“欺骗”编译器
或使用一种更为巧妙的方法,通过使用一个union数据结构进行转换(Stroustrup在其《The C++ Programming Language》中讲到类似方法),由于在union数据结构中,所有的数据成员均享用同一内存区域,只是我们为这一块内存区域赋予了不同的类型及名称,并且我们修改该结构中的任何“变量”都会导致其他所有“变量”的值均被修改。所以我们可以使用这种方法来“欺骗”编译器,从而让他认为我们所进行的“转换”是合法的。
template <class ToType, classFromType>
ToType union_cast (FromType f)
{
union
{ FromType _f;
ToType _t;
}ut;
ut._f = f;
return ut._t;
}
DWORD dwAddrPtr = union_cast<DWORD>(&YourClass::MemberFunction);
怎么样,这样的类型转换是不是很酷啊?就像使用reinterpret_cast和static_cast等之类的转换操作符一样。通过巧妙地使用union的特点轻松“逃”过编译器的类型安全检查这一关,从而达到我们的数据转换目的。当然,我们通常不会这样做,因为这样毕竟是类型不安全的转换,他只适用于特定的非常规的(函数调用)场合。
好,我们现在已经得到了该成员函数的内存地址了,下买面我们通过一个“更为奇怪的”方式来调用成员函数MemberCallDemo::foo(使用这种方式,该成员函数foo将不能使用缺省的__thiscall调用约定,而必须使用__stdcall或__cdecl):
void (__stdcall *fnFooPtr) (void*/* pThis*/,int/* a*/) =
(void (__stdcall *) (void*, int)) dwFooAddrPtr;
fnFooPtr (&mcd, 9);执行上面的调用后,屏幕上依然会输出“InMemberCallDemo::foo, a = 9”。这说明我们成功地调用了成员函数foo。当然,使用这种方式使我们完全冲破了C++的封装原则,打坏了正常的调用规则,即使你将foo声明为private函数,我们的调用同样能够成功,因为我们是通过函数地址来进行调用的。
你不禁会这样问,在实际开发中谁会这样做呢?没错,我估计也没有人会这样来调用成员函数,除非在一些特定的应用下,比如我们接下来所要做的事情。
认识Thunk
Thunk!Thunk?这是什么意思?翻开《OxfordAdvanced Learner's Dictionary, Sixth-Edition》...查不到!再使用Kingsoft'sPowerWord 2006,其曰“铮,铛,锵?”显然是个象声词。顿晕,这跟我们所要描述的简直不挨边啊!不知道人们为什么要把这种技术称之为Thunk。不管了,暂时放置一边吧!
通常,我们所编写的程序最终将被编译器的转换成计算机能够识别的指令码(即机器码),比如:
C/C++代码 等价的汇编代码 编译器产生的机器指令
============================================== =====================
int k1, k2, k;
k1 = 1; mov dword ptr [k1], 1 C7 45 E0 01 00 00 00
k2 = 2; mov dword ptr [k2], 2 C7 45 D4 02 00 00 00
k = k1 + k2; mov eax, dword ptr [k1] 8B 45 E0
add eax, dword ptr [k2] 03 45 D4
mov dword ptr [k], eax 89 45 C8
最终,CPU执行完指令序列“C7 45 E0 01 00 00 00 C7 45D4 02 00 00 00 8B 45 E0 03 45 D4 89 45 C8”后,就完成了上面的简单加法操作。从这里我们似乎能够得到启发,既然CPU只能够认识机器码,那么我们可以直接将机器码送给CPU去执行吗?答案是:当然可以,而且还非常高效!那么,怎么做到这一点呢?——定义一个机器码数组,然后跳转到该数组的起始地址处开始执行:
unsigned char machine_code[] = {
0xC7, 0x45, 0xE0, 0x01, 0x00, 0x00, 0x00,
0xC7, 0x45, 0xD4, 0x02, 0x00, 0x00, 0x00,
0x8B, 0x45, 0xE0,
0x03, 0x45, 0xD4,
0x89, 0x45, 0xC8};
void* paddr = machine_code;
使用内嵌汇编调用该机器码:
__asm
{ MOV EAX, dword ptr [paddr] ; or mov eax, dword ptr paddr ; or moveax, paddr
CALL EAX
}
如果使用C调用该机器码,则为:
void (*fn_ptr) (void) = (void (*) (void))paddr;
fn_ptr ();怎么样?当上面的CALL EAX执行完后,变量k的值同样等于3。但是,当machine_code中的指令执行完后,CPU将无法再回到CALL指令的下一条指令了!为什么啊?是的,因为machine_code中没有返回指令的机器码!要让CPU能够返回到正确的位置,我们必须将返回指令RET的机器码(0xC3)追加到machine_code的末尾,即:unsigned char machine_code[] ={0xC7, 0x45, ..., 0x89, 0x45, 0xC8, 0xC3};。
这就是Thunk!一种能让CPU直接执行我们的机器码的技术,也有人称其为自修改代码(Self-ModifyingCode)。但这有什么用呢?同样,在通常的开发中,我们不可能通过这么复杂的代码来完成上面的简单加法操作!谁这样做了,那他/她一定是个疯子!^_^。目前所了解的最有用的也是用得最多的就是使用Thunk来更改栈中的参数,甚至可以是栈中的返回地址,或者向栈中压入额外的参数(就像我们的KWIN那样),从而达到一些特殊目的。当然,在你熟知Thunk的原理后,你可能会想出更多的用途来,当然,如果你想使用Thunk来随意破坏当前线程的栈数据,从而直接导致程序或系统崩溃,那也不是不可能的,只要你喜欢,谁又在乎呢?只要你不把这个程序拿给别人运行就行!
通常我们会使用Thunk来“截获”对指定函数的调用,并在真正调用该函数之前修改调用者传递给他的参数或其他任何你想要做的事情,当我们做完我们想要做的时候,我们再“跳转到”真正需要被调用的函数中去。既然要跳转,就势必要用到JMP指令。由于在Thunk中的代码必须为机器指令,所以我们必须按照编译器的工作方式将我们所需要Thunk完成的代码转换成机器指令,因此我们需要知道我们在Thunk所用到的指令的机器指令的编码规则(通常,我们在Thunk中不可能做太多事情,了解所需的指令的编码规则也不是件难事)。大家都知道,JMP为无条件转移指令,并且有short、near、far转移,通常编译器会根据目标地址距当前JMP指令的下一条指令之间的距离来决定跳转类型。在生成机器码时,并且编译器会优先考虑short转移(如果目标地址距当前JMP指令的下一条指令的距离在-128和127之间),此时,JMP对应的机器码为0xEB。如果超出这个范围,JMP对应的机器码通常为0xE9。当然,JMP还存在其他类型的跳转,如绝对地址跳转等,相应地也有其他形式的机器码,如0xFF,0xEA。我们常用到的只有0xEB和0xE9两种形式。另外,需要注意的是,在机器码中,JMP指令后紧跟的是一个目标地址到该条JMP指令的下一条指令之间的距离(当然,以字节为单位),所以,如果我们在Thunk中需要用到JMP指令,我们就必须手动计算该距离(这也是编译器所需要做的一件事)。如果你已经很了解JMP指令的细节,那么你应该知道了下面Thunk的将是什么样的结果了吧:
unsigned char machine_code [] = {0xEB,0xFE};啊,没错,这将是一个死循环。这一定很有趣吧!事实上他跟JMP $是等价的。由于这里的机器码是0xEB,它告诉CPU这是一个short跳转,并且,0xFE的最高位为1(即负数),所以CPU直到它是一个向后跳转的JMP指令。由于向后跳转的,所以此时该JMP所能跳转到的范围为-128至-1(即0x80至0xFF),但是由于这时的JMP指令为2个字节,所以向后跳转(从该条指令的下一条指令开始)2个字节后,就又回到了该条JMP指令的开始位置。
当发生Short JMP指令时,其所能跳转的范围如下:
偏移量 机器码
=========== ======
(-128) 0x80 ??
(-127) 0x81 ??
:
:
(-3) 0xFD ??
(-2)0xFE EB <- Short JMP指令
(-1) 0xFF XX <- XX为跳转偏移量,其取值范围可为[0x80 - 0x7F]
(0) 0x00 ?? <- JMP下一条指令的开始位置
(+1) 0x01 ??
(+2) 0x02 ??
:
:
(+125) 0x7D ??
(+126) 0x7E ??
(+127) 0x7F ??
好,让我们在来看一个例子,来说明Thunk到底是怎样修改栈中的参数的:
void foo(int a)
{ printf ("In foo, a = %d\n", a);}
unsigned char code[9];
* ((DWORD *) &code[0]) = 0x042444FF; /*inc dword ptr [esp+4] */
code[4] = 0xe9; /* JMP */
* ((DWORD *) &code[5]) = (DWORD) &foo- (DWORD) &code[0] - 9; /* 跳转偏移量 */
void (*pf)(int/* a*/) = (void (*)(int))&code[0];
pf (6);当执行完pf (6)调用后,就会得到下面的输出:
“In foo, a = 7”(明明传入的是6,为什么到了foo中就变成了7了呢?)。怎么样?我们在Thunk中通过强制CPU执行机器码0xFF,0x44,0x24,0x04来将栈中的传入参数a(位于ESP + 4处)增加1,从而修改了调用者传递过来的参数。在执行完INC DWORD PTR [ESP+4]后,再通过一个跳转指令跳转到真正的函数入口处。当然我们同样不可能在实际的 开发中使用这种方法进行函数调用,之所以这样做是为了能够更加容易的弄清楚Thunk到底是怎么工作的!
让Windows直接调用你在类中定义的(非静态)消息处理函数
好了,写了这么久,似乎我们到这里才真正进入我们的正题。上面几节所描述的都是这一节所需的基本知识,有了以上知识,我们就能够很容易的实现我们的最终目的——让Windows来直接调用我们的类消息处理成员函数,在这里无须使用任何静态成员函数或全局函数,所有的事情都将由我们定义的类成员函数来完成。由于Windows中所有的消息处理均为回调函数,即它们是由操作系统在特定的消息发生时被系统调用的函数,我们需要做的仅仅是定义该消息函数,并将该消息函数的函数地址“告诉”Windows。既然我们能够使用在通过其他途径调用类中的成员函数中所描述的方法得到类成员函数(消息处理函数)的地址,那么,我们能够直接将该成员函数地址作为一个回调函数的地址传给操作系统吗?很显然,这是不可能的。但是为什么呢?我想聪明的你已经猜到,因为我们的成员函数需要类对象的this指针去访问类对象中的属性,但是Windows是无法将相应的类对象的this指针传给我们的成员函数的!这就是我们所面临的问题的关键所在!如果我们能够解决这个类对象的this指针传递问题,即将类对象的this指针手动传递到我们的类成员函数中,那么我们的问题岂不是就解决了吗?没错!
Thunk可以为们解决这个难题!这里Thunk需要解决的是将消息处理函数所在的类的实例的this指针“传递”到消息处理函数中,从前面的描述我们已经知道,this指针的传递有两种方式,一种是通过ECX寄存器进行传递,一种是使用栈进行传递。
1。使用ECX传递this指针(__thiscall)
这是一种最简单的方式,它只需我们简单地在Thunk中执行下面的指令即可:
LEA ECX, this pointer
JMP member function-based message handler
使用这种方式传递this指针时,类中的消息处理函数必须使用__thiscall调用约定!在关于调用约定与this指针的传递中我们对调用约定有较为详细的讨论。
2。使用栈传递this指针(__stdcall或__cdecl)
这是一种稍复杂的方式,使用栈传递this指针时必须确保类中的消息处理函数使用__stdcall调用约定,这跟通常的消息处理函数(静态成员函数或全局函数)使用的是同一种条用约定,唯一不同的是现在我们使用的是类成员函数(非静态)。之所以说他稍复杂,是因为我们要在Thunk中要做稍多的工作。前面我们已经说过,我们已经将我们定义的Thunk的地址作为“消息处理回调函数”地址传给了Windows,那么,当有消息需要处理时,Windows就会调用我们的消息处理函数,不过这时它调用的是Thunk中的代码,并不是真正的我们在类中定义的消息处理函数。这时,要将this指针送入当前栈中可不是件“容易”的事情。让我们来看看Windows在调用我们的Thunk代码时的栈的参数内容:
this指针被压入栈之前 this指针被压入栈之后
: ... : : ... :
|---------------| |----------------|
| LPARAM | | LPARAM |
|---------------| |----------------|
| WPARAM | | WPARAM |
|---------------| |----------------|
| UINT (msg) | | UINT (msg) |
|---------------| |----------------|
| HWND | | HWND |
|---------------| |----------------|
| (Return Addr) | <- ESP | <this pointer> | <- New iteminserted by this thunk code
|---------------| |----------------|
: ... : | (Return Addr) | <- ESP
|----------------|
: ... :
图1 图2
从图1可以看出,为了将this指针送入栈中,我们可不能简单地使用PUSH this pointer的方式将this指针“压入”栈中!但是为什么呢?想想看,如果直接将this指针压入栈中,那么原来的返回地址将不能再起效。也就是说我们将不能在我们的消息处理函数执行结束后“返回”到正确的地方,这势必会导致系统的崩溃。另一方面,我们的成员函数要求this指针必须是最后一个被送入栈的参数,所以,我们必须将this指针“插入”到HWND参数和返回地址(Return Addr)之间。如图2所示。所以,在这种情况下,我们须在Thunk中完成以下工作:
PUSH DWORD PTR [ESP] ; 保存(复制)返回地址到当前栈中
MOV DWORD PTR [ESP + 4], pThis ; 将this指针送入栈中,即原来的返回地址处
JMP member function-based message handler ; 跳转至目标消息处理函数(类成员函数)
实现我们的KWIN包
好了,有了以上知识后,现在就只剩下我们的KWIN包的开发了,当然,如果你掌握以上知识后,你可以运用这些知识,甚至找出新的方法来实现你自己的消息处理包。我想,那一定时间非常令人激动的事情!如果你想到更好的方法,千万别忘了告诉我一声哦。
首先来看看我们在KWIN中需要使用到的一个比较重要的宏:
#define __DO_DEFAULT (LRESULT) -2
#define _USE_THISCALL_CALLINGCONVENTION
#ifdef _USE_THISCALL_CALLINGCONVENTION
#define THUNK_CODE_LENGTH 10 /* For __thiscall calling conventionONLY */
#define KCALLBACK __thiscall
#else
#define THUNK_CODE_LENGTH 16 /* For __stdcall or __cdecl callingconvention ONLY */
#define KCALLBACK __stdcall
#endif在KWIN中同时实现了__thiscall和__stdcall两种调用约定,如果定义了_USE_THISCALL_CALLINGCONVENTION宏,那么就使用__thiscall调用约定,否则将使用__stdcall调用约定。宏KCALLBACK在定义了_USE_THISCALL_CALLINGCONVENTION宏后将被替换成__thiscall,否则为__stdcall。THUNK_CODE_LENGTH定义了在不同的调用约定下所需的机器指令码的长度,如果使用__thiscall,我们只需要10个字节的机器指令码,而在__stdcall下,我们需要使用16字节的机器指令码。
我们将实现对话框和一般窗口程序的消息处理函数进行封装的包(KWIN),我们力求使用KWIN能为我们的程序带来更好的灵活性和结构“良好”性,就像我们在本文开始时向大家展示的一小部分代码那样。首先我们将定义一个对话框和窗口程序都需要的数据结构_K_THUNKED_DATA,该结构封装了所有所需的Thunk代码(机器指令码)。整个KWIN的结构大致如下:
图3
在_K_THUNKED_DATA中有一个非常重要的函数—Init,它的原型如下:
void Init (
DWORD_PTR pThis, /* 消息处理类对象的内存地址(this指针) */
DWORD_PTR dwProcPtr /* 消息处理函数(类成员函数)的地址 */
)
{
DWORD dwDistance = (DWORD) dwProcPtr - (DWORD) &pThunkCode[0] -THUNK_CODE_LENGTH;
#ifdef _USE_THISCALL_CALLINGCONVENTION
/*
Encoded machine instruction Equivalent assembly languate notation
--------------------------- -------------------------------------
B9 ?? ?? ?? ?? mov ecx, pThis ; Load ecx withthis pointer
E9 ?? ?? ?? ?? jmp dwProcPtr ; Jump to target message handler
*/
pThunkCode[0] = 0xB9;
pThunkCode[5] = 0xE9;
*((DWORD *) &pThunkCode[1]) = (DWORD) pThis;
*((DWORD *) &pThunkCode[6]) = dwDistance;
#else
/*
Encoded machine instruction Equivalent assembly languate notation
--------------------------- -------------------------------------
FF 34 24 push dword ptr [esp] ; Save (or duplicate) the Return Addrinto stack
C7 44 24 04 ?? ?? ?? ?? mov dword ptr [esp+4], pThis ;Overwite the old Return Addr with 'this pointer'
E9 ?? ?? ?? ?? jmp dwProcPtr ; Jump to target messagehandler
*/
pThunkCode[11] = 0xE9;
*((DWORD *) &pThunkCode[ 0]) = 0x002434FF;
*((DWORD *) &pThunkCode[ 3]) = 0x042444C7;
*((DWORD *) &pThunkCode[ 7]) = (DWORD) pThis;
*((DWORD *) &pThunkCode[12]) = dwDistance;
#endif
}看见了吧,该函数的实现异常简单,但是在KWIN中的作用非凡。我们的KWIN的成败就依赖于该函数是否正确的“生成”了我们所需要的机器指令码。Init通过将类对象的this指针和消息处理函数的地址“硬编码”到数组_machine_code数组中,这样该数组中就拥有了可以让Windows正确调用我们在类中定义的成员函数所需的指令了。接下来所需要做的事情就全在我们创建的类(KWindowImpl和KDialogImpl或你自己创建的从这两个类派生出来的类)中了。
_K_WINDOW_ROOT_IMPL中提供了一些对话框和窗口应用程序都需要处理的公共消息的缺省处理(如WM_CREATE,WM_DESTROY,WM_COMMAND,WM_NOTIFY等等),和一些与窗口句柄(HWND)相关的函数(如GetWindowRect等等)。并且在_K_WINDOW_ROOT_IMPL的缺省构造函数中完成了Thunk的初始化。这里主要说一下该类中的ProcessBaseMessage方法:
template <class T>
class __declspec(novtable)_K_WINDOW_ROOT_IMPL
{
_K_THUNKED_DATA _thunk;
public:
_K_WINDOW_ROOT_IMPL () : m_hWnd (NULL)
{
T* pThis = static_cast<T *>(this);
_thunk.Init ((DWORD_PTR) pThis, pThis->GetMessageProcPtr());
/* The above GetMessageProcPtr was defined in derived class KDialogImpland KWindowImpl */
}
...
LRESULT ProcessBaseMessage (UINT msg, WPARAM wParam, LPARAM lParam)
{
T* pThis = static_cast<T *>(this); /* 'Override' support */
LRESULT r = __DO_DEFAULT;
switch (msg)
{
case WM_COMMAND:
r = pThis->OnCommand (HIWORD(wParam), LOWORD(wParam), (HWND) lParam);break;
case WM_NOTIFY:
r = pThis->OnNotify ((int) wParam, (LPNMHDR) lParam); break;
/* Other window message can be handled here*/
}
return r == __DO_DEFAULT ? pThis->DoDefault (msg, wParam, lParam) :r;
}
LRESULT OnCommand (WORD wNotifyCode, WORD wId, HWND hWndCtrl) { return__DO_DEFAULT; }
LRESULT OnNotify (int idCtrl, LPNMHDR pnmh) { return __DO_DEFAULT; }
LRESULT OnXXXXXX (...) { return__DO_DEFAULT; } /* 其他消息处理函数的定义 */
...
};
需要说明的是, 在该类中定义的大量(缺省)消息处理函数,是为了能够在其子类中“重载”这些消息处理函数。这里为了实现的缺省消息处理的简单性使用了一种比较勉强的方法,即假定没有任何消息处理函数会返回-2。比如,当你在你的对话框类中“重载”OnCommand方法时(当然,你的对话框类必须要继承KDialogImpl),该方法就能够在WM_COMMAND消息发生时被系统自动调用,关于基于模板的重载,请参见前面的章节:使用C++模板实现函数的“重载”。
好,再让我们来看看KDialogImpl的实现:
template <class T>
class KDialogImpl
:public _K_WINDOW_ROOT_IMPL <T>
{
public:
KDialogImpl () : _K_WINDOW_ROOT_IMPL<T> () {}
inline DWORD GetMessageProcPtr ()
{
DWORD dwProcAddr = 0;
__asm
{ /* Use the prefix 'T::' toenable the overload of KDialogProc in derived class */
mov eax, offset T::KDialogProc
mov dword ptr [dwProcAddr], eax
}
return dwProcAddr;
}
INT_PTR OnInitDialog (HWND hWndFocus, LPARAM lParam) { return__DO_DEFAULT; }
/* Other dialog-based message hanlder can be declared here */
LRESULT DoDefault (UINT msg, WPARAM wParam, LPARAM lParam) { return 0; }
INT_PTR DoModal (
HWND hWndParent = ::GetActiveWindow( ),
LPARAM lpInitParam = NULL)
{
return DialogBoxParam (
m_hInstance, MAKEINTRESOURCE(T::IDD),
hWndParent,
(DLGPROC) GetThunkedProcPtr(), lpInitParam);
}
INT_PTR KCALLBACK KDialogProc (HWND hWnd, UINT msg, WPARAM wParam,LPARAM lParam)
{
/* Cache the window handle when KDialogProc was called at first time */
if (m_hWnd == NULL)
m_hWnd = hWnd;
__ASSERT(m_hWnd == hWnd);
T* pThis = static_cast<T *> (this); /* 'Override' support */
INT_PTR fReturn = 0;
switch (msg)
{
case WM_INITDIALOG:
fReturn = pThis->OnInitDialog ((HWND) wParam, lParam);
break;
case WM_GETDLGCODE:
fReturn = pThis->OnGetDlgCode ((LPMSG) lParam);
break;
/* Other dialog-based message can be handled here */
default:
fReturn = __super::ProcessBaseMessage (msg, wParam, lParam);
}
if (fReturn == __DO_DEFAULT) return DoDefault (msg, wParam, lParam);
if (fReturn)
{
switch (msg)
{
case WM_COMPAREITEM :
case WM_VKEYTOITEM :
case WM_CHARTOITEM :
case WM_INITDIALOG :
case WM_QUERYDRAGICON :
case WM_CTLCOLORMSGBOX :
case WM_CTLCOLOREDIT :
case WM_CTLCOLORLISTBOX :
case WM_CTLCOLORBTN :
case WM_CTLCOLORDLG :
case WM_CTLCOLORSCROLLBAR :
case WM_CTLCOLORSTATIC :
break;
default:
::SetWindowLongPtr(m_hWnd,DWLP_MSGRESULT, (LONG) fReturn);
break;
}
}
return fReturn;
}
...
inline BOOL SetDlgItemInt (int nIDDlgItem, UINT uValue, BOOL bSigned =TRUE)
{ return ::SetDlgItemInt (m_hWnd, nIDDlgItem, uValue, bSigned); }
inline UINT IsDlgButtonChecked (int nIDButton)
{ return ::IsDlgButtonChecked (m_hWnd, nIDButton); }
/* 其他对话框常用函数 */
};
最后我们来看看KWindowImpl的实现,KWindowImpl的实现较KDialogImpl要复杂些,在KWindowImpl中,我们需要注册特定的窗口类,另外,对于同一类型的窗口,在该类窗口的多实例下,我们需要保证每个实例的this指针能够被正确传递到消息处理函数中,所以我们需在每次创建该类型的窗口时将该类窗口的窗口过程(Window Procedure)地址改为当前实例的窗口过程。所以我们需要使用SetClassLongPtr来修改窗口类的消息处理函数地址,因此我们需要一个窗口句柄,这也就是为什么我要在KWindowImpl中定义类型为HWND的静态变量_hWndLastCreated,我们需要通过它来调用函数SetClassLongPtr(该方法觉得很勉强。也许有更好的方法,但是目前没有发现)。
template <class T>
class KWindowImpl
:public _K_WINDOW_ROOT_IMPL <T>
{
LPTSTR m_lpszClassName;
static HWND _hWndLastCreated; /* work as sentinel */
static CRITICAL_SECTION _cs; /* Enable multi-thread safe support */
static BOOL _fInitilized;
public:
inline DWORD GetMessageProcPtr ()
{
DWORD dwProcAddr = 0;
__asm
{ /* Use the prefix 'T::' toenable the overload of KWndProc in derived class */
mov eax, offset T::KWndProc
mov dword ptr [dwProcAddr], eax
}
return dwProcAddr;
}
/* Disable the default constructor, without specifying the name ofwindow class */
KWindowImpl () { __ASSERT (FALSE); }
KWindowImpl (LPCTSTR lpszClassName) : _K_WINDOW_ROOT_IMPL ()
{
m_lpszClassName = new TCHAR [_tcslen(lpszClassName) +_tcslen(_T("KWindowImpl")) + 8];
_stprintf (m_lpszClassName, _T("%s:%s"),_T("JKTL::KWindowImpl"), lpszClassName);
if (!_fInitilized)
{ ::InitializeCriticalSection(&_cs);
_fInitilized = TRUE;
}
}
~KWindowImpl ()
{ if (m_lpszClassName) delete[]m_lpszClassName; }
LRESULT DoDefault (UINT msg, WPARAM wParam, LPARAM lParam)
{ return DefWindowProc (m_hWnd, msg,wParam, lParam); }
LRESULT KCALLBACK KWndProc (HWND hWnd, UINT msg, WPARAM wParam, LPARAMlParam)
{
if (m_hWnd == NULL)
{ m_hWnd = hWnd;
_hWndLastCreated = m_hWnd;
/* Leaving the critical section, when window was created successfully */
::LeaveCriticalSection (&_cs);
}
__ASSERT (m_hWnd == hWnd);
/* Do some default message process */
return __super::ProcessBaseMessage (msg, wParam, lParam);
}
BOOL KRegisterClass ()
{
WNDCLASSEX wcx = {sizeof WNDCLASSEX};
/* Enable multi-thread safe, for SetClassLongPtr use ONLY, call here forconvenience */
::EnterCriticalSection (&_cs);
if (GetClassInfoEx (m_hInstance, m_lpszClassName, &wcx))
{ /* Ensure that windowsubsquently created use it's thunked window procedure,
SetClassLongPtr will not effectthose windows had been created before */
SetClassLongPtr (_hWndLastCreated, GCL_WNDPROC, (LONG) GetThunkedProcPtr());
return TRUE;
}
wcx.cbClsExtra = 0;
wcx.cbWndExtra = 0;
wcx.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);;
wcx.hCursor = LoadCursor(NULL, IDC_ARROW);
wcx.hIcon = LoadIcon (NULL, IDI_APPLICATION);
wcx.hInstance =m_hInstance;
wcx.lpfnWndProc = (WNDPROC)GetThunkedProcPtr ();
wcx.lpszClassName =m_lpszClassName;
wcx.lpszMenuName = NULL;
wcx.style = CS_HREDRAW | CS_VREDRAW |CS_DBLCLKS;
return RegisterClassEx (&wcx);
}
HWND Create (HWND hWndParent, DWORD dwExStyle, DWORD dwStyle, LPCTSTRlpszWndName,
RECT& rc, int nCtrlId = 0,LPVOID lpParam = NULL)
{
__ASSERT (m_hWnd == NULL);
if (!KRegisterClass ()) return NULL;
m_hWnd = ::CreateWindowEx (
dwExStyle, m_lpszClassName,
lpszWndName, dwStyle,
rc.left, rc.top, _RECT_W(rc), _RECT_H(rc),
hWndParent, (HMENU) nCtrlId, m_hInstance, lpParam);
return m_hWnd;
}
};
对于_K_WINDOW_ROOT_IMPL中的m_hWnd成员的赋值,我们是在我们的窗口过程第一次被调用是设定的(见代码)。虽然我并不是很喜欢这种写法,因为我不希望在窗口处理过程出现于消息处理无关的代码。事实上,我们仍然可以在Thunk代码中完成对m_hWnd的赋值,因为当系统调用我们的“窗口处理过程”(即Thunk代码)时,系统已经为我们的窗体分配好了窗口句柄,并位于栈中的ESP+4位置(见图1),我们完全可以在Thunk中将位于ESP+4处的值保存到m_hWnd中,但是这将增加我们在Thunk中的机器指令的长度,使用下面的代码就要增加16字节的机器指令(__stdcall调用约定下,在__thiscall调用约定下,只需增加10个字节左右,因为可以直接使用ECX来进行数据交换),在执行Thunk代码之前,我们需要计算出类对象中m_hWnd成员在该类对象中的偏移位置,这通常可以在类的构造函数中完成(下面的代码假定m_hWnd在类对象中的偏移位置为8):
PUSH DWORD PTR [ESP]
MOV DWORD PTR[ESP + 4], pThis
PUSH EAX ; Save EAX
PUSH EBX ; Save EBX
MOV EAX, pThis
MOV EBX, DWORD PTR [ESP +0Ch] ; Load HWND from ESP + 0Ch
MOV DWORD PTR [EAX + 08h],EBX ; Set m_hWnd with value of EBX
POP EBX ; Restore EAX
POP EAX ; Restore EBX
JMP member function-based message handler在__thiscall调用约定下为:
MOV ECX, pThis
PUSH EAX
MOV EAX, DWORD PTR [ESP +8] ; Load HWND from ESP + 8
MOV DWORD PTR [EAX + 08h],EAX ; Save HWND to m_hWnd
POP EAX
JMP member function-based message handler
使用类成员函数子类化窗口过程
在_K_WINDOW_ROOT_IMPL中实现了SubclassWindow和SubclassDialog方法,你可以使用这两个方法来轻松的实现窗体的子类化操作。下面的代码演示了怎样使用SubclassWindow来子类化输入框(Edit Control):
class SubclassDemo
:public KDialogImpl <SubclassDemo>
{
_K_THUNKED_SUBCLASS_DATA* m_lpTsdEdit;
public:
INT_PTR OnInitDialog (HWND hWndFocus, LPARAM lParam)
{
HWND hEdit = GetDlgItem (IDC_EDIT0);
m_lpTsdEdit = SubclassWindow (hEdit, EditWndProcHook);
return TRUE;
}
LRESULT KCALLBACK EditWndProcHook (HWND hEdit, UINT msg, WPARAM wParam,LPARAM lParam)
{
/* Do anything here you like */
return CallWindowProc (m_lpTsdEdit->fnOldWndProc, hEdit, msg, wParam,lParam);
}
}
结束语
非常高兴你能够读到这篇文章的末尾,同时也希望这篇文章能对你理解Windows中的消息处理机制提供一些帮助。欢迎你将你的看法和建议反馈给我(JERKII@HOTMAIL.COM),以弥补由于我当前的知识限制而导致的一些错误。
KWIN的源码可以从这里下载。
本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/JerKii/archive/2006/04/07/654188.aspx
摘要:介绍了 thunk 技术中如何避免直接写机器码。
关键字:Thunk 机器码 this指针
Thunk技术,一般认为是在程序中直接构造出可执行代码的技术(在正常情况下,这是编译器的任务)。《深度探索C++对象模型》中对这个词的来源有过考证(在中文版的162页),说thunk是knuth的倒拼字。knuth就是大名鼎鼎的计算机经典名著《TheArt of Computer Programming》的作者,该书被程序员们称为“编程圣经”,与牛顿的“自然哲学的数学原理”等一起,被评为“世界历史上最伟大的十种科学著作”之一(也不知是谁评的,我没查到,不过反正这本书很牛就是了)。
一般情况下,使用thunk技术都是事先查好指令的机器码,然后将数组或结构体赋值为这些机器码的二进制值,最后再跳转到数组或结构体的首地址。比如在参考文献[1]中的代码:
void foo(int a)
{ printf ("In foo, a = %d\n", a); }
unsigned char code[9];
* ((DWORD *) &code[0]) = 0x042444FF; /* inc dword ptr [esp+4] */
code[4] = 0xe9; /* JMP */
* ((DWORD *) &code[5]) = (DWORD) &foo - (DWORD) &code[0] - 9; /* 跳转偏移量 */
void (*pf)(int/* a*/) = (void (*)(int)) &code[0];
pf (6);
这是一段典型的thunk代码,其执行结果是“In foo, a = 7”。
可以看到,它定义了一个数组code[9],然后将事先查好的各汇编指令的机器码直接赋值给数组。然后定义一个函数指针等于数组的首地址,最后通过该函数指针调用thunk代码。这里使用了函数指针完成调用,好处是代码比较清晰易读。也可以使用汇编代码jmp或call来完成,这样就不必额外定义一个函数指针。
网络上的thunk代码,基本上都是这个思路。如果你实际写一段这样的代码,一定会发现很麻烦。对着教科书查找每一个汇编指令的机器码,相信不会是一件愉快的事情。其实我们回过头来想想,这件事计算机来做不是最合适吗,编译器不就是做这个事情的吗?
以上面的代码为例,让我们重新考虑一下整个过程。我们的目的是在调用函数foo之前将参数增加1。一般而言,这样做肯定是没有foo函数的源代码或者不允许修改源代码,否则直接改foo函数的代码就好了,何必这么麻烦。为了调用时候的简单化,定义一个函数指针是比较合适的,否则每次调用都写汇编代码jmp或call太麻烦。这样一来,函数指针必须指向一个代码段的地址。但是这个代码段必须用机器码来构造吗,直接写汇编代码也同样可以做到。
当然,这里有一个问题。我们写汇编指令的时候,必须是一条指令一条指令的写,不能说指令写一半,然后让汇编程序去处理。上面的代码中,第一条指令inc直接写汇编语句当然没问题。但下面的jmp语句,就不能直接写。因为我们写汇编语句的时候,jmp跳转偏移量是未知的,必须编译后才知道。并且我们不能只写jmp而不写偏移量,那是通不过编译的。
这个问题可以这样解决,写jmp语句的时候,我们写一个占位的DWORD,其值设为一个特殊的值,比如0xffff(原理是这样,实际处理还要迂回一下,后面有说明)。只要在这段thunk代码中不出现这个值就好。然后执行的时候,在第一次调用之前,在thunk代码中查找该值,将其替换为计算出来的动态值。经过这样的处理,就可以彻底在thunk代码中消除机器码的直接操作。
更一般化,为了生成正确的机器码,我们用两个函数。一个用于生成机器码的模板,另一个函数用于在机器码的模板中填入需要动态计算产生的值。下面是一个例子:
void ThunkTemplate(DWORD& addr1,DWORD& addr2)//生成机器码
{
int flag = 0;
DWORD x1,x2;
if(flag)
{
//注意,这个括号中的代码无法直接执行,因为其中可能含有无意义的占位数。
__asm
{
thunk_begin:
;//这里写thunk代码的汇编语句.
...
thunk_end: ;
}
}
__asm
{
mov x1,offset thunk_begin; //取 Thunk代码段 的地址范围.
mov x2,offset thunk_end;
}
addr1 = x1;
addr2 = x2;
}
上面的函数用于生成thunk的机器码模板,之所以称为模板,是因为其中包含了无意义的占位数,必须将这些占位数替换为有意义的值之后,才可以执行这些代码。因此,在函数中thunk代码模板放在一个if(0)语句中,就是避免调用该函数的时候执行thunk代码。另外,为了能方便的得到thunk代码模板的地址,这里采用一个函数传出thunk代码的首尾地址。
至于替换占位数的功能是很简单的,直接替换就好。
void ReplaceCodeBuf(BYTE *code,int len, DWORD old,DWORD x)//完成动态值的替换.
{
int i=0;
for(i=0;i<len-4;++i)
{
if(*((DWORD *)&code[i])==old)
{
*((DWORD *)&code[i]) = x;
return ;
}
}
}
这样使用两个函数:
DWORD addr1,addr2;
ThunkTemplate(addr1,addr2);
memset(m_thunk,0,100);//m_thunk是一个数组: char m_thunk[100];
memcpy(m_thunk,(void*)addr1,addr2-addr1);//将代码拷贝到m_thunk中。
ReplaceCodeBuf(m_thunk,addr2-addr1,-1,(DWORD)((void*)this));//将m_thunk中的-1替换为this指针的值。
原理部分到此为止。下面举一个完整的,有实际意义的例子。在windows中,回调函数的使用是很常见的。比如窗口过程,又比如定时器回调函数。这些函数,你写好代码,但是却从不直接调用。相反,你把函数地址传递给系统,当系统检测到某些事件发生的时候,系统来调用这些函数。这样当然很好,不过如果你想做一个封装,将所有相关部分写成一个类,那问题就来了。
问题是,这些回调函数的形式事先已经定义好了,你无法让一个类的成员函数成为一个回调函数,因为类型不可能匹配。这不能怪微软,微软不可能将回调函数定义为一个类成员函数(该定义为什么类?),而只能将回调函数定义为一个全局的函数。并且微软其实很多时候也提供了补救措施,在回调函数中增加了一个void*的参数。这个参数一般都用来传递类的this指针。这样一来,可以这样解决:给系统提供一个全局函数作为回调函数,在该函数中通过额外的那个void*参数访问到类的对象,从而直接调用到类成员函数。如此,你的封装一样可以完成,不过多了一次函数调用而已。
但是,不是所有的回调函数都这么幸运,微软都给它们提供了一个额外的参数。比如,定时器的回调函数就没有。
VOID CALLBACK TimerProc(
HWND hwnd, // handle to window
UINT uMsg, // WM_TIMER message
UINT_PTR idEvent, // timer identifier
DWORD dwTime // current system time
);
四个参数,个个都有用途。没有地方可以让你传递那个this指针。当然了,你实在要传也可以做到,比如将hwnd设置为一个结构体的指针,其中包含原来的hwnd和一个this指针。在定时器回调函数中取出hwnd后强制转化为结构体指针,取出原来的hwnd,取出this指针。现在就可以通过this指针自由的调用类成员函数了。不过这种方法不是我想要的,我要的是一个通用,统一的解决方法。通过在参数里面加塞夹带的方法,一般也是没有问题的,不过如果碰到一个回调函数没有参数怎么办?另外,本来是封装为一个类的,结果还是要带着一个全局函数,你难道不觉得有些不爽吗?
这正是thunk技术大显身手的地方了。我们知道,所谓类成员函数,和对应的全局函数,其实就差一个this指针。如果我们在系统调用函数之前正确处理好this指针,那系统就可以正确的调用类成员函数。
具体的思路是这样的:当系统需要一个回调函数地址的时候,我们传递一个thunk代码段的地址。这个代码段做两件事:
1、准备好this指针
2、调用成员函数
关键的代码如下(完整的工程在附件中):
void ThunkTemplate(DWORD& addr1,DWORD& addr2,int calltype=0)
{
int flag = 0;
DWORD x1,x2;
if(flag)
{
__asm //__thiscall
{
thiscall_1: mov ecx,-1; //-1占位符,运行时将被替换为this指针.
mov eax,-2; //-2占位符,运行时将被替换为CTimer::CallBcak的地址.
jmp eax;
thiscall_2: ;
}
__asm //__stdcall
{
stdcall_1: push dword ptr [esp] ; //保存(复制)返回地址到当前栈中
mov dword ptr [esp+4], -1 ; //将this指针送入栈中,即原来的返回地址处
mov eax, -2;
jmp eax ; //跳转至目标消息处理函数(类成员函数)
stdcall_2: ;
}
}
if(calltype==0)//this_call
{
__asm
{
mov x1,offset thiscall_1; //取 Thunk代码段 的地址范围.
mov x2,offset thiscall_2 ;
}
}
else
{
__asm
{
mov x1,offset stdcall_1;
mov x2,offset stdcall_2 ;
}
}
addr1 = x1;
addr2 = x2;
}
上面的函数有几个地方需要说明:
1、为了能适应两种不同的成员函数调用约定,这里写了两份代码。通过参数calltype决定拷贝哪一份代码到缓冲区。
2、本来一条jmpxxxx;指令这里分解为两条指令:
mov eax,-2;
jmp eax;
这是由汇编语言的特点决定的。直接写jmp -2是通不过的(根据地址的不同,jmp汇编后可能出现好几种形式。这里必须出现一个真实的地址以便汇编器决定jmp类型)。
3、如果对this指针的知识不清楚,请参考我在vc知识库的另外一篇文章《直接调用类成员函数地址》。
设置thunk代码的完整代码如下:
DWORD FuncAddr;
GetMemberFuncAddr_VC6(FuncAddr,&CTimer::CallBcak);
DWORD addr1,addr2;
ThunkTemplate(addr1,addr2,0);
memset(m_thunk,0,100);
memcpy(m_thunk,(void*)addr1,addr2-addr1);
ReplaceCodeBuf(m_thunk,addr2-addr1,-1,(DWORD)((void*)this)); //将-1替换为this指针.
ReplaceCodeBuf(m_thunk,addr2-addr1,-2,FuncAddr); //将-2替换为成员函数的指针.
如果你还想和以前一样直接在数组中赋值机器码(毕竟这样看起来很酷,我完全理解)。那也可以这样,调用ThunkTemplate生成m_thunk后,打印出该数组的值,而后在程序中直接给m_thunk数组赋值,就象网上大部分thunk代码那样,当然在调用前要多一个步骤就是替换掉占位数。不过无论如何,调用这两个函数生成机器码应该比手工查找方便多了,如果你也这样认为,那就算我这篇文章没白写。
通用Thunk
作者:OwnWaterloo
本文同时发表在 codeproject 网站:参见:Generic Thunk with 5 combinations ofCalling Conventions
介绍
这篇文章提出了一种基于Thunk技术,让一个成员函数成为一个回调函数的通用方法。文章主要讨论原理,同时也提供了一份实现和示例。
背景
许多库需要我们提供一个函数作为回调,这使得使用 “面向对象编程”(OOP) 出现了麻烦。因为普通的C函数没有成员函数需要的this指针。Thunk技术是一种快速但是平台相关的解决此问题的方法。我最近研究过许多有关thunk技术的文章,我认为许多解决方案都是针对于特定问题的。我设计了一组类,来提供一种通用的解决方案。
环境
开发环境 : IA32,Windows Xp SP2,Visual Studio2005
用法
源代码提供了5(实际上4)个类(全都在 Thunk 名字空间中)。它们的每一个对象都有2个属性,对象和方法。它们可以动态的创建一些机器码。执行这些机器码将在逻辑上和调用Obj.Method(…); 举例来说,如果我们想要设计一个类来进行窗口子类化的工作,我们可以按下面5个步骤使用通用Thunk
class CSubClassing {
private:
Thunk::ThisToStd m_thunk;
//1.选择一个合适的Thunk类
// ThisToStd 类使一个使用__thiscall 约定的成员函数 (LRESULT SubProc(…) )
//成为一个使用_stdcall 约定的回调函数WNDPROC)
//2.实例化一个对象.
public:
CSubClassing() {
m_thunk.Attach(this);
//3.附加到想要回调的对象上
m_thunk.AttachMethod(&CSubClassing::SubProc);
// 4.附加成员函数
// to do
}
void Attach(HWND hWnd) {
m_oldProc = (WNDPROC)SetWindowLong(hWnd,GWL_PROC
,m_thunk.MakeCallback<LONG>());
// 5.转化到回调函数指针
//SetWindowLong函数使用一个LONG值来表示WNDPROC
// to do
}
private:
//这个非静态成员函数将被Windows回调
LRESULT SubProc(HWND hWnd,UINT msg,WPARAM wParam,LPARAM lParam) {
if (msg!=WE_NEEDED)
return CallWndProc(m_oldProc,hWnd,msg,wParam,lParam);
// to do
}
WNDPROC m_oldProc;
}
这5个类(class)都有相同的界面和使用方式。一旦你依据成员函数与回调函数的调用约定选定好了一个Thunk类,就可以按照上面的步骤做一些有用的事情 : 如WNDPROC,THREADPROC,hooking,等等
更多详细信息 见Thunk.h和 示例(sample)工程(project)
示例工程包含5个程序的源代码,但是没有可执行文件,否则会太庞大。工程可以在VisualStudio 2005上顺利编译,只要工程的目录结构维持原样。5个程序使用一份相同的测试代码——TestClass.hTestClass.cpp main.cpp。不同之处在预处理器的定义。这样,它们分别测试了ThisToStd,ThisToCdecl,StdToStd,StdToCdecl和CdeclToCdecl的功能。除了这些,你还可以从中得知使用一个Thunk类,需要包含和加入到工程中的最少文件。(只包含Thunk.h并把Thunk.cpp加入工程中也能工作,但不是最好方法)
原理
原理中最重要的是函数的调用约定(Calling Convention) ,调用者和被调者之间的约定。普通C函数通常使用3种调用约定 : “__cdecl”“__stdcall” “__fastcall” 成员函数通常使用 “__thiscall””__stdcall” “__cdecl”
我们需要着重关注以下3点:
1. 调用者(普通C函数)怎么准备参数和返回地址?
2. 被调用者(成员函数)希望并且要求的参数和返回地址是什么?它如何取得它们?
3. 平衡堆栈是谁的责任?
调用者准备的参数和返回地址总不是被调用者所期待的那样,因为被调用者还需要一个this指针。平衡堆栈的方式也许也会不同。我们的工作就是以被调用者期望的方式,准备好this指针,同时弥补2者在平衡堆栈上的差异。
为了简单起见,我们以 “void func(int); void C::func(int); ”为例,首先,我们来看看当使用__stdcall 约定的func被调用的时候,会发生什么。
func(1212); 编译器会像这样准备参数和返回地址 :
PUSH 1212 ; 使得堆栈增加4
CALL func; 使得堆栈也增加4(因为返回地址也被压入堆栈)
0x50000:...;被调用者返回这里,我们假设这里的地址是0x50000
调用者希望被调用者使用 RET 4 (使得堆栈减少8:参数1212使用4,返回地址0x50000也使用4)来平衡堆栈,所以在这之后没有多余的机器码。所以,在这之后,堆栈是这个样子:
...
1212
0x50000 <- ESP
然后,我们来看看使用__thiscall 的被调用者所希望的参数和返回地址。一个真正的成员函数被调用时。
C obj;
obj.func(1212);
编译器以这样的方式准备参数:
PUSH 1212;
MOV ECX,obj;
CALL C::func
所以,在这之后,堆栈是这个样子:
…
1212
0x50000 <- ESP
ECX 保存着 this 指针。
这也就是被调用者(void__thiscall C::func(int); ) 需要的形式。
第3,我们看看被调用者如何返回。
事实上,它使用 RET4 来返回到0x50000
所以,我们唯一需要做的就是准备好this指针,然后跳转到成员函数。(不需要更多的工作,参数和返回值已在正确位置,堆栈也将被正确的平衡。)
设计ThisToStd
在我们设计第1个,也是最简单的类ThisToStd 之前,我们还需要3种信息。
1、我们需要一种得到函数地址的方法。
对于数据指针,我们可以转化(cast)它到一个 int 值
void *p = &someValue;
int address = reinterpret_cast<int>(p);
/* 如果检查对64位机的可移植性,将会得到一个警告。不过可以忽略它,因为这个thunk只用在32位机上^_^*/
不同于数据指针,函数指针有更多的限制。
void __stdcall fun(int) { … }
void C::fun(int) {}
//int address = (int)fun; // 不允许
//int address = (int)&C::fun; // 同样错误
有2种方法来进行一个强力的转化
template<typename dst_type,typename src_type>
dst_type pointer_cast(src_type src) {
return *static_cast<dst_type*>( static_cast<void*>(&src) );
}
template<typename dst_type,typename src_type>
dst_type union_cast(src_type src) {
union {
src_type src;
dst_type dst;
} u = {src};
return u.dst;
}
所以,我们可以实现一个方法
template<typename Pointer>
int PointerToInt32(Pointer pointer)
{
return pointer_cast<int>(pointer); // or union_cast<int>(pointer);
}
int address = PointerToInt32(&fun); // 可以
int address = (int)&C::fun; // 也可以
更多详细信息见ThunkBase.h
2.转移指令的目的地
许多转移指令的目的地使用“到源的偏移量”来表示
比如:当CPU 执行到0xFF000000处的指令时, 指令像这个样子:
0xFF000000 : 0xE9 0x33 0x55 0x77 0x99
0xFF000005 : ...
0xE9 是一个 JMP 指令,紧接着的4字节将被解释为偏移
offset = 0x99775533 (在Intel x86 机器上,低字节存储在低地址上) = -1720232653
源(src) = 0xFF000000 (JMP指令的地址) = 4278190080
目的地(dst) = src+offset+5 (JMP占1字节,偏移占4字节) = 4278190080 – 1720232653 +5 = 2557957432 = 0x98775538
所以在指令 “JMP -1720232653 “ 之后,下一条被执行的指令将在
0x98775538 : ...
基于这点,我们可以实现2个方法:
void SetTransterDST(
void *src /* the address of transfer instruction*/
,int dst /* the destination*/ ) {
unsigned char *op = static_cast<unsigned char *>(src);
switch (*op++) {
case 0xE8: // CALL offset (dword)
case 0xE9: // JMP offset (dword)
{
int *offset = reinterpret<int*>(op);
*offset = dst – reinterpret<int>(src) - sizeof(*op)*1 – sizeof(int);
}
break;
case 0xEB: // JMP offset (byte)
...
break;
case ...:
...
break;
default :
assert(!”not complete!”);
}
}
int GetTransnferDST(const void *src) {
const unsigned char *op = static_cast< const unsigned char *>(src);
switch (*op++) {
case 0xE8: //CALL offset (dword)
case 0xE9: //JMP offset (dword)
{
const int *offset = reinterpret_cast<const int*>(op);
return *offset + PointerToInt32(src) + sizeof(*op) +sizeof(int);
}
break;
case 0xEB: // JMP offset(byte)
...
break;
case ...:
...
break;
default:
assert(!”not complete!”);
break;
}
return 0;
}
更多详细信息 见ThunkBase.cpp 3.栈的生长在Win32平台下,栈朝着低地址生长。也就是说,当栈增加N ESP就减少N,反之亦然。我们来设计这个类
class ThisToStd
{
public:
ThisToStd(const void *Obj = 0,int memFunc = 0);
const void *Attach(const void *newObj);
int Attach(int newMemFunc);
private:
#pragma pack( push , 1) // 强制编译器使用1字节长度对齐结构
unsigned char MOV_ECX;
const void *m_this;
unsigned char JMP;
const int m_memFunc;
#pragma pack( pop ) // 恢复对齐
};
ThisToStd:: ThisToStd(const void *Obj,int memFunc)
: MOV_ECX(0xB9),JMP(0xE9) {
Attach(Obj); // 设置this指针
Attach(memFunc); // 设置成员函数地址(使用偏移)
}
const void* ThisToStd::Attach(const void *newObj) {
const void *oldObj = m_this;
m_this = newObj;
return oldObj;
}
int ThisToStd::Attach(int newMemFunc) {
int oldMemFunc = GetTransferDST(&JMP);
SetTransferDST(&JMP,newMemFunc);
return oldMemFunc;
}
我们以如下方式使用这个类 :
typedef void ( __stdcall * fun1)(int);
class C { public : void __thiscall fun1(int){} };
C obj;
ThisToStd thunk;
thunk.Attach(&obj); // 假设 &obj = OBJ_ADD
int memFunc = PointerToInt32(&C::fun1); //假设memFunc = MF_ADD
thunk.Attach(memFunc); // thunk.m_memFunc 将被设置为MF_ADD – (&t.JMP)-5
fun1 fun = reinterpret_cast<fun1>(&thunk); //假设 &thunk = T_ADD
fun(1212); // 与 obj.fun(1212) 有同样效果
它是如何工作的,当CPU执行到fun(1212); 机器码如下:
PUSH 1212;
CALL DWORD PTR [fun];
0x50000 : … ; 假设 RET_ADD = 0x50000
// CALL DOWRD PTR [fun] 与CALL(0xE8) offset(dword) 不同
//我们只需要知道: 它将RET_ADD压栈,然后跳转到T_ADD
执行完这2条指令后,栈是这个样子 :
…
1212
RET_ADD <- ESP
下一条被执行的指令,是在thunk 的地址处 (T_ADD)
thunk的第1字节是 “const unsignedchar MOV_ECX” –被初始化为0xB9.
紧接着的4字节是 “const void*m_this”
在thunk.Attach(&obj); 后,m_this = OBJ_ADD
这5字节组成一条合法的指令
T_ADD : MOV ECX,OBJ_ADD
thunk的第6字节是 “const unsignedchar JMP” –被初始化为0xE9.
紧接着的4字节是 “const intm_memFunc”
将被thunk.Attach(memFunc) 修改
这5字节又组成一条合法指令
T_ADD+5 : JMP offset
offset = MF_ADD - &thunk.JMP – 5 ( 由 thunk.Attach() 和SetTransferDST 设置)
所以,这条指令执行后,下一条被执行指令将在这里:
MF_ADD : …
现在,this指正已经准备好,(参数和返回地址也由fun(1212)准备好,而且 C::fun1 将会使用RET 4 返回到 RET_ADD,并正确的平衡堆栈。
所以,它成功了!
设计StdToStd
让我们由以下3步分析:
1. 调用者如何准备参数和返回地址?
一般的说,一个使用__stdcall的普通C函数会将参数从右向左依次压栈。我们假设它使得栈增长了 N。注意:N并不总等于参数数目×4!
CALL 指令将返回地址压栈,使得栈再增长4
参数 m<-ESP +4 +N
参数 m-1
…
参数 1<- ESP + 4
返回地址<- ESP
它将平衡堆栈的工作交给被调用者。(使用RET N)
2. 被调用者如何得到参数与返回地址?(它希望何种方式?)
一个和上述普通C函数具有相同参数列表,使用__stdcall的成员函数,希望参数,返回地址和this指针像这样准备 :
参数 m<- ESP + 8 + N
参数 m-1
…
参数 1< -ESP + 8
this < -ESP +4
返回地址<-ESP
3. 被调用者如何返回?
它使用 RETN+4 返回。
所以我们的工作是在参数1和返回地址之间插入this指针,然后跳转到成员函数。
(我们插入了一个this指针使得栈增加了4,所以被调用者使用 RETN+4 是正确的)
在设计StdToStd 之前,让我们定义一些有用的宏。
相信我,这将使得源代码更加容易阅读和改进。
MachineCodeMacro.h
#undef CONST
#undef CODE
#undef CODE_FIRST
#ifndef THUNK_MACHINE_CODE_IMPLEMENT
#define CONST const
#define CODE(type,name,value) type name;
#define CODE_FIRST(type,name,value) type name;
#else
#define CONST
#define CODE(type,name,value) ,name(value)
#define CODE_FIRST(type,name,value) :name(value)
#endif
ThunkBase.h
#include “MachineCodeMacro.h”
namespace Thunk {
typedef unsigned char byte;
typedef unsigend short word;
typedef int dword;
typedef const void* dword_ptr;
}
StdToStd.h
#include <ThunkBase.h>
#define STD_TO_STD_CODES() \
/* POP EAX */ \
CONST CODE_FIRST(byte,POP_EAX,0x58) \
/* PUSH m_this */ \
CONST CODE(byte,PUSH,0x68) \
CODE(dword_ptr,m_this,0) \
/* PUSH EAX */ \
CONST CODE(byte,PUSH_EAX,0x50) \
/* JMP m_memFunc(offset) */ \
CONST CODE(byte,JMP,0xE9) \
CONST CODE(dword,m_memFunc,0)
namespace Thunk {
class StdToStd {
public:
StdToStd(const void *Obj = 0,int memFunc = 0);
StdToStd(const StdToStd &src);
const void* Attach(const void *newObj);
int Attach(int newMemFunc);
private:
#pragma pack( push ,1 )
STD_TO_STD_CODES()
#pragma pack( pop )
};
StdToStd.cpp
#include <StdToStd.h>
#define THUNK_MACHINE_CODE_IMPLEMENT
#include <MachineCodeMacro.h>
namespace Thunk {
StdToStd::StdToStd(dword_ptr Obj,dword memFunc)
STD_TO_STD_CODES()
{
Attach(Obj);
Attach(memFunc);
}
StdToStd::StdToStd(const StdToStd &src)
STD_TO_STD_CODES()
{
Attach(src.m_this);
Attach( GetTransferDST(&src.JMP) );
}
dwrod_ptr StdToStd::Attach(dword_ptr newObj) {
dword_ptr oldObj = m_this;
m_this = newObj;
return oldObj;
}
dword StdToStd::Attach(dword newMemFunc) {
dword oldMemFunc = GetTransferDST(&JMP);
SetTransferDST(&JMP,newMemFunc);
return oldMemFunc;
}
}
宏 CONSTCODE_FIRST(byte,POP_EAX,0x58) 在StdToStd.h 中,将被替换成:“const byte POP_EAX;”
(宏THUNK_MACHINE_CODE_IMPLEMENT没有定义)
在StdToStd.cpp中,将被替换成:“:POP_EAX(0x58)”
(宏THUNK_MACHINE_CODE_IMPLEMENT被定义)
在StdToStd.cpp中,宏CODE_FIRST 于CODE 的不同之处在于 CODE 被替换为 “, 某某” 而不是 “: 某某” .使得初始化列表合法。
宏(macro)STD_TO_STD_CODES() 的注释(comment) 详细说明了这个类是如何工作的。
设计ThisToCdecl
让我们还是依照那3个步骤分析:
1、当一个使用__cdecl的普通C函数调用时,编译器从右向左压入参数,我们假设这使得栈增加N。CALL指令将返回地址压栈,使得栈再增加4。
堆栈就像这样:
…
参数 m<- ESP + 4 + N
参数 m-1
…
参数 1<- ESP + 4
返回地址<- ESP
它使用 ADDESP,N 平衡堆栈。
2、当一个和上述C普通函数有同样参数列表,使用__thiscall的成员函数将要被调用时,它希望参数已经被从右向左压入,而且ECX保存着this指针。
…
参数 m<- ESP + 4 + N
参数 m-1
…
参数 1<- ESP + 4
返回地址<- ESP
ECX : this
3、当被调用者返回
它使用 RETN !
所以,我们的工作如下:
1. 在调用成员函数之前,将this指针放入ECX
2. 在成员函数返回后,将ESP设置成一个正确的值
3. 返回到调用者。所以,这个正确的值应该是当调用者执行完ADDESP,N之后,ESP刚好是被调用者调用前的值。
因为参数数量×4不总是等于N,所以我们不能使用SUB ESP,N来设置ESP(比如参数列表含有double)
我们也不能修改返回地址,使它跨过“ADDESP,N”的指令,因为这条指令并不总是紧接着CALL指令(调用caller 的CALL指令)
(比如 返回类型是double的情况)
一个可能的实现是在某个地方保存ESP,在被调用者返回后将它传送回ESP。
让我们来看看第1个实现:
ThisToCdecl 36.h
#define __THIS_TO__CDECL_CODES() \
/* MOV DWORD PTR [old_esp],ESP */ \
CONST CODE_FIRST(word,MOV_ESP_TO,0x2589) \
CONST CODE(dword_ptr,pold_esp,&old_esp) \
\
/* POP ECX */ \
CONST CODE(byte,POP_ECX,0x59) \
\
/* MOV DWORD PTR [old_return],ECX */ \
CONST CODE(word,MOV_POLD_R,0x0D89) \
CONST CODE(dword_ptr,p_old_return,&old_return) \
\
/* MOV ECX,this */ \
CONST CODE(byte,MOV_ECX,0xB9) \
CODE(dword_ptr,m_this,0) \
\
/* CALL memFunc */ \
CONST CODE(byte,CALL,0xE8) \
CODE(dword,m_memFunc,0) \
\
/* MOV ESP,old_esp */ \
CONST CODE(byte,MOV_ESP,0xBC) \
CONST CODE(dword,old_esp,0) \
/* MOV DWORD PTR [ESP],old_retrun */ \
CONST CODE(word,MOV_P,0x04C7) \
CONST CODE(byte,_ESP,0x24) \
CONST CODE(dword,old_return,0) \
/* RET */ \
CONST CODE(byte,RET,0xC3)
1、我们将ESP保存到old_esp中。
2、然后,弹出返回地址(返回到调用者的地址),并将其保存到old_return中,
3、在ECX中准备好this指针。
4、调用成员函数(我们弹出调用者的返回地址,而CALL指令会压入一个新的返回地址——栈现在适合被调用者。被调用者将返回到thunk代码的剩下部分。)
5、恢复ESP和返回地址,然后返回调用者
优化
sizeof(ThisToCdecl)==36 , 我认为这是不可接受的。
如果我们使用PUSHold_return 来代替 MOV DWORD PTR[ESP],old_return,可以节省2字节(因此,我们必须在保存old_esp之前弹栈),于此同时,也增加了一个额外的堆栈操作。(见ThisToCdecl 34.h)
在这种情况下,相对于时间上的优化,我更加倾向空间上的优化。所以第3个实现如下:
我们可以使用一个叫做Hook的函数来准备this指针,保存old_esp和返回地址,设置被调用者的返回地址,然后跳转到被调用者。这样,thunk对象将包含更少的指令,而变的更小。(23字节)
ThisToCdecl.h
#define THIS_TO_CDECL_CODES() \
/* CALL Hook */ \
CONST CODE_FIRST(byte,CALL,0xE8) \
CONST CODE(dword,HOOK,0) \
\
/* this and member function */ \
CODE(dword,m_memFunc,0) \
CODE(dword_ptr,m_this,0) \
\
/* member function return here! */ \
/* MOV ESP,oldESP */ \
CONST CODE(byte,MOV_ESP,0xBC) \
CONST CODE(dword,oldESP,0) \
\
/* JMP oldRet */ \
CONST CODE(byte,JMP,0xE9) \
CONST CODE(dword,oldRet,0)
这些机器码首先调用“Hook”函数,这个函数做如下工作:
1. 保存 the oldESP 和 oldRet。
2. 将被调用者的返回地址设置到 “member function return here!”。
3. 将ECX设置为this指针。
4. 跳转到成员函数
当成员函数返回后,剩下的thunk代码将修改ESP然后返回到调用者。
Hook函数被实现为:
void __declspec( naked ) ThisToCdecl::Hook() {
_asm {
POP EAX //1
// p=&m_memFunc; &m_this=p+4; &oldESP=p+9; &oldRet=p+14
// Save ESP
MOV DWORD PTR [EAX+9],ESP //3
ADD DWORD PTR [EAX+9],4 //4
// Save CallerReturn(by offset)
//src=&JMP=p+13,dst=CallerReturn,offset=CallerReturn-p-13-5
MOV ECX,DWORD PTR [ESP] //3
SUB ECX,EAX //2
SUB ECX,18 //3
MOV DWORD PTR [EAX+14],ECX //3
// Set CalleeReturn
MOV DWORD PTR [ESP],EAX //3
ADD DWORD PTR [ESP],8 //4
// Set m_this
MOV ECX,DWORD PTR [EAX+4] //3
// Jump to m_memFunc
JMP DWORD PTR[EAX ] //2
}
}
我们使用 CALLoffset(dword) 跳转到Hook,这个指令会将返回地址压栈。所以,CALL HOOK之后,堆栈如下 :
…
参数 m
参数m-1
…
参数1
调用者返回地址
Hook返回地址 <- ESP
Hook 返回地址刚好是紧接着“CALL HOOK”的指令,——&m_memFunc
Hook 使用 __declspec( naked ) 强制编译器不生成额外指令。(兼容性:VC8支持。VC6,7不确定,g++不支持)
第1条指令POPEAX 将使堆栈减少4并且得到thunk对象的地址。
…
参数1
调用者返回地址<- ESP
EAX : p //p=&m_method; &m_this=p+4; &oldESP=p+9; &oldRet=p+14
现在,还有3件事情值得我们注意:
1. thunk对象使用 CALL(0xE8)转移到 Hook。这是一个相对转移
2. thunk对象使用 JMP offset 跳转到调用者,offset将被Hook计算。
3. Hook 使用 JMP DWORD PTR[EAX],这是一个绝对跳转,所以m_memFunc不能使用SetTransferDST,m_memFunc = PointerToInt32(&C::Fun); 才是正确的。
更详细实现见ThisToCdecl.h 和 ThisToCdecl.cpp
设计CdeclToCdecl
1、使用__cdecl的普通C函数前面已经讨论过
2、一个使用__cdecl的成员函数希望栈像这个样子:
…
参数 m<-ESP + 8 + N
参数m-1
…
参数1<-ESP + 8
this <-ESP + 4
返回地址<- ESP
3、使用__cdecl的成员函数使用 RET 返回
CdeclToCdecl类与ThisToCdecl十分相似:
thunk对象调用一个 Hook函数来准备this指针,保存old_esp,返回地址,然后跳转到被调用者。
被调用者返回之后,thunk代码修改ESP,然后跳转到调用者。
不同之处在Hook函数,它将this指针插入到参数1与返回值之间,而不是将它传送到ECX。
更详细的实现见CdeclToCdecl.h 和CdeclToCdecl.cpp
设计StdToCdecl
让我们拿它和CdeclToCdecl做比较。
唯一不同的是,成员函数使用RETN+4而不是 RET。
当被调用者返回后,不管是RETN+4,还是RET,ESP都将被恢复。
因此,CdeclToCdecl可以胜任StdToCdecl
所以,StdToCdecl只是一个typedef “typedef CdeclToCdecl StdToCdecl;” ^_^
设计CdeclToStd
使用__stdcall的调用者将堆栈平衡工作交给被调用者。
使用__cdecl的被调用者使用RET返回到调用者。
而关于ESP的信息在这之中丢失了!
非常不幸,我没办法设计出一个通用的thunk类。 -_-
关于__fastcall 和更进一步的工作
__fastcall调用约定将小于或等于dword的头2个参数用ECX和EDX传递。
所以设计出一个通用的thunk类似乎是不可能的。(因为和参数相关)
但是特殊的解决方案是存在的。
我认为Thunk的理论比实现更重要。
在你打算解决一个特定的问题 (比如为了特定参数的__fastcall 和 CdeclToStd ),在另一平台上实现,或者想继续优化这份实现的时候,如果这篇文章能对你有所帮助,我非常高兴 ^_^
源代码可以任意使用,作者不会为此承担任何责任 ^_^。
关于FlushInstructionCache
这些类通常是按如下方式被使用:
class CNeedCallback {
private:
CThunk m_thunk;
public:
CNeedCallback() :m_thunk(this,Thunk::Helper::PointerToInt32(&CNeedCallback::Callback)) {}
private:
returnType Callback(….) {}
}
所以,每个thunk对象的Obj和Method属性在构造后就不再改变。我不知道在这种情况下FlushInstructionCache是否有必要。如果你认为有,请在ThunkBase.cpp中定义THUNK_FLUSHINSTRUCTIONCACHE ,或者简单的去掉第4行注释。
特别感谢
Illidan_Ne 和SeanEwington ^_^.
“传递/转发”可变参数并通过printf记录程序日志,彻底告别vsnprintf!:) 收藏
通常我们需要在程序中输出部分日志信息,并把它记录到文件中。在这种情况下,使用printf可以为我们带了很大方便。因为printf却省情况下是向stdout即控制台屏幕输出信息,在GUI程序中,我们看不到printf的输出结果,但是我们可以将该输出重定向到指定的文件中。即使用freopen(“c:\\yourlog.log”, “a+”,stdout)或通过yourapp.exe > c:\yourlog.log完成输出重定向操作。
但是通常我们需要在记录日志的时候记录更多的信息,比如说运行时间等,所以我们不能使用一条简单的printf来完成该操作,另外,为防止日志信息以外丢失,我们最好是在每次printf后立即调用fflush。所以我们通常会使用下面的方法来完成日志记录操作:
void __cdecl log0 (const char* _Format,...)
{
char buff[10240], tm[80];
va_list vl;
va_start (vl, _Format);
_strtime (tm);
vsnprintf (buff, 10240, _Format, vl);
printf ("%s - %s", tm, buff);
fflush (stdout);
va_end (vl);
}从该代码可以看出,我们必须事先定义好一个我们认为足够大的缓存已存储所有可能的数据,这就是使用该方法带来的inflexibility,究竟多大才算足够大啊?10240?102400?甚至1024000?恐怕你的栈也没这么大吧!即使你在堆中分配存储空间也一样!
接下来我就介绍一种不用预先分配缓存并且能够接受并输出任意长度的信息至日至文件中(当然,只要不超过你的系统允许的大小),试想,只要我们在log0中完成了我们想做的任何事(比如输出日志前缀信息等),并且如果能够将调用者传递给log0的参数“原封不动”的传递给printf的话,即将所有的可变参数按照printf所要求的格式传递给它,由它来完成剩下的操作。这是不是就克服了使用预先分配缓存的问题呢?没错,接下来所要解决的就是怎样将这些可变参数“传递”给printf。
由于在log0内部,我们不知道调用者究竟传递了多少个参数进来,所以我们不能按照通常所用的按照参数名的方式将参数传递给printf。但是,先别急,看看log0的函数声明,他是不是和printf的声明完全一致呢(事实上只要是log0中的参数和printf中的部分一致也可,如void log1(char* filename,int len, char* _Format, ...)也可。)?
也就是说拥有相同声明的函数,在被调用时,他们所拥有的参数栈(即Stack Frame)的结构是一样的。所以,只要我们能够从一个函数A“突然”跳转到另外一个函数B中,那么B所拥有的参数栈和A将是同一份数据,即他们“共享”了同一份参数栈数据。需要注意的是,这里的跳转不能使用通常的函数掉用来实现,因为函数被调用时,编译器会在背后做很多事情,如给我们设置新的ESP指针等等,因此这样势必不能达到共享参数栈数据的目的。为了不让编译器在函数调用时在背后做任何事情,我们需要使用一个naked函数,在这样的函数中我们就可以自己利用栈资源,自己控制所有一切。有了这样的函数后,就可以很轻松,而且很高效的达到我们的目的了。
void mkprefix ()
{ char buff[80];
_strtime (buff);
printf("%s - ", buff);
}
__declspec (naked)
void __cdecl
xprintf (const char* _Format, ...)
{
__asm
{ call dword ptr [mkprefix]
pop ebx /* 将函数返回地址保存到EBX中 */
call dword ptr [printf]
sub esp, 4 /* 1 */
/* 调用fflush将数据立即保存到文件中 */
call dword ptr [__iob_func]
add eax, 0x20
push eax
call dword ptr [fflush]
add esp, 4 /* 2 */
mov dword ptr [esp], ebx /* 恢复函数返回地址 */
ret
}
}代码中在1和2处分别对ESP减4后又加4,所以这两处的代码完全可以忽略,在这里加上是为了更好的理解函数调用的机制(即在函数调用后需要修正ESP,即所谓的Stackclean-up)。你可以将mkprefix作的足够复杂已记录更多的信息,甚至我们可以通过log0将参数传递到mkprefix中,向log1那样。不过这样处理起来就稍复杂点,为简单起见,就不再讲述这种方法了。
当然,这只是这种所谓的“栈共享”技术的一个应用而已了,掌握了这种技术后,我想你肯定会把它应用到其他更适合的地方。
其实,在VC8中,由于提供了可变参数的宏,所以我们可以通过下面一条简单的调用来完成日至记录操作,而且信息还比较完全:
#define TRACE(fmt, ...) printf ("%s(%s:%d) - "##fmt, mkprefix(), __FILE__, __LINE__, __VA_ARGS__)
TRACE ("This is a debug information, a= %d, b = %s. ", 234, "xxxxx");
本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/JerKii/archive/2006/04/20/670423.aspx
C++ 虚函数表解析
陈皓
http://blog.csdn.net/haoel
前言
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。
关于虚函数的使用方法,我在这里不做过多的阐述。大家可以看看相关的C++的书籍。在这篇文章中,我只想从虚函数的实现机制上面为大家 一个清晰的剖析。
当然,相同的文章在网上也出现过一些了,但我总感觉这些文章不是很容易阅读,大段大段的代码,没有图片,没有详细的说明,没有比较,没有举一反三。不利于学习和阅读,所以这是我想写下这篇文章的原因。也希望大家多给我提意见。
言归正传,让我们一起进入虚函数的世界。
虚函数表
对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
这里我们着重看一下这张虚函数表。C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
听我扯了那么多,我可以感觉出来你现在可能比以前更加晕头转向了。没关系,下面就是实际的例子,相信聪明的你一看就明白了。
假设我们有这样的一个类:
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
};
按照上面的说法,我们可以通过Base的实例来得到虚函数表。 下面是实际例程:
typedef void(*Fun)(void);
Base b;
Fun pFun = NULL;
cout << "虚函数表地址:" << (int*)(&b) << endl;
cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;
// Invoke the first virtual function
pFun = (Fun)*((int*)*(int*)(&b));
pFun();
实际运行经果如下:(WindowsXP+VS2003, Linux 2.6.22+ GCC 4.1.3)
虚函数表地址:0012FED4
虚函数表 —第一个函数地址:0044F148
Base::f
通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()和Base::h(),其代码如下:
(Fun)*((int*)*(int*)(&b)+0); // Base::f()
(Fun)*((int*)*(int*)(&b)+1); // Base::g()
(Fun)*((int*)*(int*)(&b)+2); // Base::h()
这个时候你应该懂了吧。什么?还是有点晕。也是,这样的代码看着太乱了。没问题,让我画个图解释一下。如下所示:
注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“\0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在WinXP+VS2003下,这个值是NULL。而在Ubuntu 7.10+ Linux 2.6.22 + GCC 4.1.3下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。
下面,我将分别说明“无覆盖”和“有覆盖”时的虚函数表的样子。没有覆盖父类的虚函数是毫无意义的。我之所以要讲述没有覆盖的情况,主要目的是为了给一个对比。在比较之下,我们可以更加清楚地知道其内部的具体实现。
一般继承(无虚函数覆盖)
下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:
请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
对于实例:Derive d;的虚函数表如下:
我们可以看到下面几点:
1)虚函数按照其声明顺序放于表中。
2)父类的虚函数在子类的虚函数前面。
我相信聪明的你一定可以参考前面的那个程序,来编写一段程序来验证。
一般继承(有虚函数覆盖)
覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。
为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:
我们从表中可以看到下面几点,
1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
2)没有被覆盖的函数依旧。
这样,我们就可以看到对于下面这样的程序,
Base *b = new Derive();
b->f();
由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。
多重继承(无虚函数覆盖)
下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。
对于子类实例中的虚函数表,是下面这个样子:
我们可以看到:
1) 每个父类都有自己的虚表。
2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)
这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。
多重继承(有虚函数覆盖)
下面我们再来看看,如果发生虚函数覆盖的情况。
下图中,我们在子类中覆盖了父类的f()函数。
下面是对于子类实例中的虚函数表的图:
我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
安全性
每次写C++的文章,总免不了要批判一下C++。这篇文章也不例外。通过上面的讲述,相信我们对虚函数表有一个比较细致的了解了。水可载舟,亦可覆舟。下面,让我们来看看我们可以用虚函数表来干点什么坏事吧。
一、通过父类型的指针访问子类自己的虚函数
我们知道,子类没有重载父类的虚函数是一件毫无意义的事情。因为多态也是要基于函数重载的。虽然在上面的图中我们可以看到Base1的虚表中有Derive的虚函数,但我们根本不可能使用下面的语句来调用子类的自有虚函数:
Base1 *b1 = new Derive();
b1->f1(); //编译出错
任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点)
二、访问non-public的虚函数
另外,如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。
如:
class Base {
private:
virtual void f() { cout << "Base::f" << endl; }
};
class Derive : public Base{
};
typedef void(*Fun)(void);
void main() {
Derive d;
Fun pFun =(Fun)*((int*)*(int*)(&d)+0);
pFun();
}
结束语
C++这门语言是一门Magic的语言,对于程序员来说,我们似乎永远摸不清楚这门语言背着我们在干了什么。需要熟悉这门语言,我们就必需要了解C++里面的那些东西,需要去了解C++中那些危险的东西。不然,这是一种搬起石头砸自己脚的编程语言。
在文章束之前还是介绍一下自己吧。我从事软件研发有十个年头了,目前是软件开发技术主管,技术方面,主攻Unix/C/C++,比较喜欢网络上的技术,比如分布式计算,网格计算,P2P,Ajax等一切和互联网相关的东西。管理方面比较擅长于团队建设,技术趋势分析,项目管理。欢迎大家和我交流,我的MSN和Email是:haoel@hotmail.com
附录一:VC中查看虚函数表
我们可以在VC的IDE环境中的Debug状态下展开类的实例就可以看到虚函数表了(并不是很完整的)
附录 二:例程
下面是一个关于多重继承的虚函数表访问的例程:
#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()
{
Fun pFun = NULL;
Derive d;
int** pVtab = (int**)&d;
//Base1's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);
pFun = (Fun)pVtab[0][0];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);
pFun = (Fun)pVtab[0][1];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+2);
pFun = (Fun)pVtab[0][2];
pFun();
//Derive's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+0)+3);
pFun = (Fun)pVtab[0][3];
pFun();
//The tail of the vtable
pFun = (Fun)pVtab[0][4];
cout<<pFun<<endl;
//Base2's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);
pFun = (Fun)pVtab[1][0];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);
pFun = (Fun)pVtab[1][1];
pFun();
pFun = (Fun)pVtab[1][2];
pFun();
//The tail of the vtable
pFun = (Fun)pVtab[1][3];
cout<<pFun<<endl;
//Base3's vtable
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);
pFun = (Fun)pVtab[2][0];
pFun();
//pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);
pFun = (Fun)pVtab[2][1];
pFun();
pFun = (Fun)pVtab[2][2];
pFun();
//The tail of the vtable
pFun = (Fun)pVtab[2][3];
cout<<pFun<<endl;
return 0;
}
本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/haoel/archive/2007/12/18/1948051.aspx
C++ 对象的内存布局(上)
陈皓
http://blog.csdn.net/haoel
点击这里查看下篇>>>
前言
07年12月,我写了一篇《C++虚函数表解析》的文章,引起了大家的兴趣。有很多朋友对我的文章留了言,有鼓励我的,有批评我的,还有很多问问题的。我在这里一并对大家的留言表示感谢。这也是我为什么再写一篇续言的原因。因为,在上一篇文章中,我用了的示例都是非常简单的,主要是为了说明一些机理上的问题,也是为了图一些表达上方便和简单。不想,这篇文章成为了打开C++对象模型内存布局的一个引子,引发了大家对C++对象的更深层次的讨论。当然,我之前的文章还有很多方面没有涉及,从我个人感觉下来,在谈论虚函数表里,至少有以下这些内容没有涉及:
1)有成员变量的情况。
2)有重复继承的情况。
3)有虚拟继承的情况。
4)有钻石型虚拟继承的情况。
这些都是我本篇文章需要向大家说明的东西。所以,这篇文章将会是《C++虚函数表解析》的一个续篇,也是一篇高级进阶的文章。我希望大家在读这篇文章之前对C++有一定的基础和了解,并能先读我的上一篇文章。因为这篇文章的深度可能会比较深,而且会比较杂乱,我希望你在读本篇文章时不会有大脑思维紊乱导致大脑死机的情况。;-)
对象的影响因素
简而言之,我们一个类可能会有如下的影响因素:
1)成员变量
2)虚函数(产生虚函数表)
3)单一继承(只继承于一个类)
4)多重继承(继承多个类)
5)重复继承(继承的多个父类中其父类有相同的超类)
6)虚拟继承(使用virtual方式继承,为了保证继承后父类的内存布局只会存在一份)
上述的东西通常是C++这门语言在语义方面对对象内部的影响因素,当然,还会有编译器的影响(比如优化),还有字节对齐的影响。在这里我们都不讨论,我们只讨论C++语言上的影响。
本篇文章着重讨论下述几个情况下的C++对象的内存布局情况。
1)单一的一般继承(带成员变量、虚函数、虚函数覆盖)
2)单一的虚拟继承(带成员变量、虚函数、虚函数覆盖)
3)多重继承(带成员变量、虚函数、虚函数覆盖)
4)重复多重继承(带成员变量、虚函数、虚函数覆盖)
5)钻石型的虚拟多重继承(带成员变量、虚函数、虚函数覆盖)
我们的目标就是,让事情越来越复杂。
知识复习
我们简单地复习一下,我们可以通过对象的地址来取得虚函数表的地址,如:
typedef void(*Fun)(void);
Base b;
Fun pFun = NULL;
cout << "虚函数表地址:" << (int*)(&b) << endl;
cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;
// Invoke the first virtual function
pFun = (Fun)*((int*)*(int*)(&b));
pFun();
我们同样可以用这种方式来取得整个对象实例的内存布局。因为这些东西在内存中都是连续分布的,我们只需要使用适当的地址偏移量,我们就可以获得整个内存对象的布局。
本篇文章中的例程或内存布局主要使用如下编译器和系统:
1)Windows XP 和 VC++ 2003
2)Cygwin 和 G++ 3.4.4
单一的一般继承
下面,我们假设有如下所示的一个继承关系:
请注意,在这个继承关系中,父类,子类,孙子类都有自己的一个成员变量。而了类覆盖了父类的f()方法,孙子类覆盖了子类的g_child()及其超类的f()。
我们的源程序如下所示:
class Parent {
public:
int iparent;
Parent ():iparent (10) {}
virtual void f() { cout << " Parent::f()" << endl;}
virtual void g() { cout << " Parent::g()" << endl;}
virtual void h() { cout << " Parent::h()" << endl;}
};
class Child : public Parent {
public:
int ichild;
Child():ichild(100) {}
virtual void f() { cout << "Child::f()" << endl; }
virtual void g_child() { cout << "Child::g_child()"<< endl; }
virtual void h_child() { cout << "Child::h_child()"<< endl; }
};
class GrandChild : public Child{
public:
int igrandchild;
GrandChild():igrandchild(1000) {}
virtual void f() { cout << "GrandChild::f()" <<endl; }
virtual void g_child() { cout << "GrandChild::g_child()"<< endl; }
virtual void h_grandchild() { cout <<"GrandChild::h_grandchild()" << endl; }
};
我们使用以下程序作为测试程序:(下面程序中,我使用了一个int** pVtab 来作为遍历对象内存布局的指针,这样,我就可以方便地像使用数组一样来遍历所有的成员包括其虚函数表了,在后面的程序中,我也是用这样的方法的,请不必感到奇怪,)
typedef void(*Fun)(void);
GrandChild gc;
int** pVtab = (int**)&gc;
cout << "[0] GrandChild::_vptr->" << endl;
for (int i=0; (Fun)pVtab[0][i]!=NULL; i++){
pFun = (Fun)pVtab[0][i];
cout << " ["<<i<<"] ";
pFun();
}
cout << "[1] Parent.iparent = " << (int)pVtab[1]<< endl;
cout << "[2] Child.ichild = " << (int)pVtab[2]<< endl;
cout << "[3] GrandChild.igrandchild = " <<(int)pVtab[3] << endl;
其运行结果如下所示:(在VC++2003和G++ 3.4.4下)
[0] GrandChild::_vptr->
[0] GrandChild::f()
[1] Parent::g()
[2] Parent::h()
[3] GrandChild::g_child()
[4] Child::h1()
[5] GrandChild::h_grandchild()
[1] Parent.iparent = 10
[2] Child.ichild = 100
[3] GrandChild.igrandchild = 1000
使用图片表示如下:
可见以下几个方面:
1)虚函数表在最前面的位置。
2)成员变量根据其继承和声明顺序依次放在后面。
3)在单一的继承中,被overwrite的虚函数在虚函数表中得到了更新。
多重继承
下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类只overwrite了父类的f()函数,而还有一个是自己的函数(我们这样做的目的是为了用g1()作为一个标记来标明子类的虚函数表)。而且每个类中都有一个自己的成员变量:
我们的类继承的源代码如下所示:父类的成员初始为10,20,30,子类的为100
class Base1 {
public:
int ibase1;
Base1():ibase1(10) {}
virtual void f() { cout << "Base1::f()" << endl; }
virtual void g() { cout << "Base1::g()" << endl; }
virtual void h() { cout << "Base1::h()" << endl; }
};
class Base2 {
public:
int ibase2;
Base2():ibase2(20) {}
virtual void f() { cout << "Base2::f()" << endl; }
virtual void g() { cout << "Base2::g()" << endl; }
virtual void h() { cout << "Base2::h()" << endl; }
};
class Base3 {
public:
int ibase3;
Base3():ibase3(30) {}
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:
int iderive;
Derive():iderive(100) {}
virtual void f() { cout << "Derive::f()" << endl;}
virtual void g1() { cout << "Derive::g1()" <<endl; }
};
我们通过下面的程序来查看子类实例的内存布局:下面程序中,注意我使用了一个s变量,其中用到了sizof(Base)来找下一个类的偏移量。(因为我声明的是int成员,所以是4个字节,所以没有对齐问题。关于内存的对齐问题,大家可以自行试验,我在这里就不多说了)
typedef void(*Fun)(void);
Derive d;
int** pVtab =(int**)&d;
cout << "[0]Base1::_vptr->" << endl;
pFun = (Fun)pVtab[0][0];
cout << " [0] ";
pFun();
pFun = (Fun)pVtab[0][1];
cout << " [1] ";pFun();
pFun = (Fun)pVtab[0][2];
cout << " [2] ";pFun();
pFun = (Fun)pVtab[0][3];
cout << " [3] "; pFun();
pFun = (Fun)pVtab[0][4];
cout << " [4] "; cout<<pFun<<endl;
cout << "[1]Base1.ibase1 = " << (int)pVtab[1] << endl;
int s = sizeof(Base1)/4;
cout << "["<< s << "] Base2::_vptr->"<<endl;
pFun = (Fun)pVtab[s][0];
cout << " [0] "; pFun();
Fun = (Fun)pVtab[s][1];
cout << " [1] "; pFun();
pFun = (Fun)pVtab[s][2];
cout << " [2] "; pFun();
pFun = (Fun)pVtab[s][3];
out << " [3] ";
cout<<pFun<<endl;
cout <<"["<< s+1 <<"] Base2.ibase2 = " <<(int)pVtab[s+1] << endl;
s = s + sizeof(Base2)/4;
cout << "["<< s << "] Base3::_vptr->"<<endl;
pFun = (Fun)pVtab[s][0];
cout << " [0] "; pFun();
pFun = (Fun)pVtab[s][1];
cout << " [1] "; pFun();
pFun = (Fun)pVtab[s][2];
cout << " [2] "; pFun();
pFun = (Fun)pVtab[s][3];
cout << " [3] ";
cout<<pFun<<endl;
s++;
cout <<"["<< s <<"] Base3.ibase3 = " <<(int)pVtab[s] << endl;
s++;
cout <<"["<< s <<"] Derive.iderive = " <<(int)pVtab[s] << endl;
其运行结果如下所示:(在VC++2003和G++ 3.4.4下)
[0] Base1::_vptr->
[0] Derive::f()
[1] Base1::g()
[2] Base1::h()
[3] Driver::g1()
[4] 00000000 ç 注意:在GCC下,这里是1
[1] Base1.ibase1 = 10
[2] Base2::_vptr->
[0] Derive::f()
[1] Base2::g()
[2] Base2::h()
[3] 00000000 ç 注意:在GCC下,这里是1
[3] Base2.ibase2 = 20
[4] Base3::_vptr->
[0] Derive::f()
[1] Base3::g()
[2] Base3::h()
[3] 00000000
[5] Base3.ibase3 = 30
[6] Derive.iderive = 100
使用图片表示是下面这个样子:
我们可以看到:
1) 每个父类都有自己的虚表。
2) 子类的成员函数被放到了第一个父类的表中。
3) 内存布局中,其父类布局依次按声明顺序排列。
4) 每个父类的虚表中的f()函数都被overwrite成了子类的f()。这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数
本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/haoel/archive/2008/10/15/3081328.aspx
重复继承
下面我们再来看看,发生重复继承的情况。所谓重复继承,也就是某个基类被间接地重复继承了多次。
下图是一个继承图,我们重载了父类的f()函数。
其类继承的源代码如下所示。其中,每个类都有两个变量,一个是整形(4字节),一个是字符(1字节),而且还有自己的虚函数,自己overwrite父类的虚函数。如子类D中,f()覆盖了超类的函数, f1() 和f2() 覆盖了其父类的虚函数,Df()为自己的虚函数。
class B
{
public:
int ib;
char cb;
public:
B():ib(0),cb('B') {}
virtual void f() { cout << "B::f()" << endl;}
virtual void Bf() { cout << "B::Bf()" << endl;}
};
class B1 : public B
{
public:
int ib1;
char cb1;
public:
B1():ib1(11),cb1('1') {}
virtual void f() { cout << "B1::f()" << endl;}
virtual void f1() { cout << "B1::f1()" << endl;}
virtual void Bf1() { cout << "B1::Bf1()" << endl;}
};
class B2: public B
{
public:
int ib2;
char cb2;
public:
B2():ib2(12),cb2('2') {}
virtual void f() { cout << "B2::f()" << endl;}
virtual void f2() { cout << "B2::f2()" << endl;}
virtual void Bf2() { cout << "B2::Bf2()" << endl;}
};
class D : public B1, public B2
{
public:
int id;
char cd;
public:
D():id(100),cd('D') {}
virtual void f() { cout << "D::f()" << endl;}
virtual void f1() { cout << "D::f1()" << endl;}
virtual void f2() { cout << "D::f2()" << endl;}
virtual void Df() { cout << "D::Df()" << endl;}
};
我们用来存取子类内存布局的代码如下所示:(在VC++ 2003和G++ 3.4.4下)
typedef void(*Fun)(void);
int** pVtab = NULL;
Fun pFun = NULL;
Dd;
pVtab = (int**)&d;
cout << "[0] D::B1::_vptr->" << endl;
pFun = (Fun)pVtab[0][0];
cout << " [0]"; pFun();
pFun = (Fun)pVtab[0][1];
cout << " [1]"; pFun();
pFun = (Fun)pVtab[0][2];
cout << " [2]"; pFun();
pFun = (Fun)pVtab[0][3];
cout << " [3]"; pFun();
pFun = (Fun)pVtab[0][4];
cout << " [4]"; pFun();
pFun = (Fun)pVtab[0][5];
cout << " [5]0x" << pFun << endl;
cout << "[1] B::ib = " << (int)pVtab[1] <<endl;
cout << "[2] B::cb = " << (char)pVtab[2] <<endl;
cout << "[3] B1::ib1 = " << (int)pVtab[3] <<endl;
cout << "[4] B1::cb1 = " << (char)pVtab[4]<< endl;
cout << "[5] D::B2::_vptr->" << endl;
pFun = (Fun)pVtab[5][0];
cout << " [0]"; pFun();
pFun = (Fun)pVtab[5][1];
cout << " [1]"; pFun();
pFun = (Fun)pVtab[5][2];
cout << " [2]"; pFun();
pFun = (Fun)pVtab[5][3];
cout << " [3]"; pFun();
pFun = (Fun)pVtab[5][4];
cout << " [4]0x" << pFun << endl;
cout<< "[6] B::ib = " << (int)pVtab[6] << endl;
cout << "[7] B::cb = " << (char)pVtab[7] <<endl;
cout << "[8] B2::ib2 = " << (int)pVtab[8] <<endl;
cout << "[9] B2::cb2 = " << (char)pVtab[9]<< endl;
cout << "[10] D::id = " << (int)pVtab[10] <<endl;
cout << "[11] D::cd = " << (char)pVtab[11]<< endl;
程序运行结果如下:
GCC 3.4.4
VC++2003
[0] D::B1::_vptr->
[0] D::f()
[1] B::Bf()
[2] D::f1()
[3] B1::Bf1()
[4] D::f2()
[5] 0x1
[1] B::ib = 0
[2] B::cb = B
[3] B1::ib1 = 11
[4] B1::cb1 = 1
[5] D::B2::_vptr->
[0] D::f()
[1] B::Bf()
[2] D::f2()
[3] B2::Bf2()
[4] 0x0
[6] B::ib = 0
[7] B::cb = B
[8] B2::ib2 = 12
[9] B2::cb2 = 2
[10] D::id = 100
[11] D::cd = D
[0]D::B1::_vptr->
[0] D::f()
[1] B::Bf()
[2] D::f1()
[3] B1::Bf1()
[4] D::Df()
[5] 0x00000000
[1] B::ib = 0
[2] B::cb = B
[3] B1::ib1 = 11
[4] B1::cb1 = 1
[5] D::B2::_vptr->
[0] D::f()
[1] B::Bf()
[2] D::f2()
[3] B2::Bf2()
[4] 0x00000000
[6] B::ib = 0
[7] B::cb = B
[8] B2::ib2 = 12
[9] B2::cb2 = 2
[10] D::id = 100
[11] D::cd = D
下面是对于子类实例中的虚函数表的图:
我们可以看见,最顶端的父类B其成员变量存在于B1和B2中,并被D给继承下去了。而在D中,其有B1和B2的实例,于是B的成员在D的实例中存在两份,一份是B1继承而来的,另一份是B2继承而来的。所以,如果我们使用以下语句,则会产生二义性编译错误:
D d;
d.ib = 0; //二义性错误
d.B1::ib = 1; //正确
d.B2::ib = 2; //正确
注意,上面例程中的最后两条语句存取的是两个变量。虽然我们消除了二义性的编译错误,但B类在D中还是有两个实例,这种继承造成了数据的重复,我们叫这种继承为重复继承。重复的基类数据成员可能并不是我们想要的。所以,C++引入了虚基类的概念。
钻石型多重虚拟继承
虚拟继承的出现就是为了解决重复继承中多个间接父类的问题的。钻石型的结构是其最经典的结构。也是我们在这里要讨论的结构:
上述的“重复继承”只需要把B1和B2继承B的语法中加上virtual 关键,就成了虚拟继承,其继承图如下所示:
上图和前面的“重复继承”中的类的内部数据和接口都是完全一样的,只是我们采用了虚拟继承:其省略后的源码如下所示:
class B {……};
class B1 : virtual public B{……};
class B2: virtual public B{……};
class D : public B1, public B2{ …… };
在查看D之前,我们先看一看单一虚拟继承的情况。下面是一段在VC++2003下的测试程序:(因为VC++和GCC的内存而局上有一些细节上的不同,所以这里只给出VC++的程序,GCC下的程序大家可以根据我给出的程序自己仿照着写一个去试一试):
int** pVtab = NULL;
Fun pFun = NULL;
B1 bb1;
pVtab = (int**)&bb1;
cout << "[0] B1::_vptr->" << endl;
pFun = (Fun)pVtab[0][0];
cout << " [0]";
pFun(); //B1::f1();
cout << " [1]";
pFun = (Fun)pVtab[0][1];
pFun(); //B1::bf1();
cout << " [2]";
cout << pVtab[0][2] << endl;
cout << "[1] = 0x";
cout << (int*)*((int*)(&bb1)+1) <<endl; //B1::ib1
cout << "[2] B1::ib1 = ";
cout << (int)*((int*)(&bb1)+2) <<endl; //B1::ib1
cout << "[3] B1::cb1 = ";
cout << (char)*((int*)(&bb1)+3) << endl; //B1::cb1
cout << "[4] = 0x";
cout << (int*)*((int*)(&bb1)+4) << endl; //NULL
cout << "[5] B::_vptr->" << endl;
pFun = (Fun)pVtab[5][0];
cout << " [0]";
pFun(); //B1::f();
pFun = (Fun)pVtab[5][1];
cout << " [1]";
pFun(); //B::Bf();
cout << " [2]";
cout << "0x" << (Fun)pVtab[5][2] << endl;
cout << "[6] B::ib = ";
cout << (int)*((int*)(&bb1)+6) <<endl; //B::ib
cout << "[7] B::cb = ";
其运行结果如下(我结出了GCC的和VC++2003的对比):
GCC 3.4.4
VC++2003
[0] B1::_vptr ->
[0] : B1::f()
[1] : B1::f1()
[2] : B1::Bf1()
[3] : 0
[1] B1::ib1 : 11
[2] B1::cb1 : 1
[3] B::_vptr ->
[0] : B1::f()
[1] : B::Bf()
[2] : 0
[4] B::ib : 0
[5] B::cb : B
[6] NULL : 0
[0]B1::_vptr->
[0] B1::f1()
[1] B1::Bf1()
[2] 0
[1] = 0x00454310 ç该地址取值后是-4
[2] B1::ib1 = 11
[3] B1::cb1 = 1
[4] = 0x00000000
[5] B::_vptr->
[0] B1::f()
[1] B::Bf()
[2] 0x00000000
[6] B::ib = 0
[7] B::cb = B
这里,大家可以自己对比一下。关于细节上,我会在后面一并再说。
下面的测试程序是看子类D的内存布局,同样是VC++ 2003的(因为VC++和GCC的内存布局上有一些细节上的不同,而VC++的相对要清楚很多,所以这里只给出VC++的程序,GCC下的程序大家可以根据我给出的程序自己仿照着写一个去试一试):
Dd;
pVtab = (int**)&d;
cout << "[0] D::B1::_vptr->" << endl;
pFun = (Fun)pVtab[0][0];
cout << " [0]"; pFun(); //D::f1();
pFun = (Fun)pVtab[0][1];
cout << " [1]"; pFun(); //B1::Bf1();
pFun = (Fun)pVtab[0][2];
cout << " [2]"; pFun(); //D::Df();
pFun = (Fun)pVtab[0][3];
cout << " [3]";
cout << pFun << endl;
//cout << pVtab[4][2] << endl;
cout << "[1] = 0x";
cout << (int*)((&dd)+1)<<endl; //????
cout << "[2] B1::ib1 = ";
cout << *((int*)(&dd)+2) <<endl; //B1::ib1
cout << "[3] B1::cb1 = ";
cout << (char)*((int*)(&dd)+3) << endl; //B1::cb1
//---------------------
cout << "[4] D::B2::_vptr->" << endl;
pFun = (Fun)pVtab[4][0];
cout << " [0]"; pFun(); //D::f2();
pFun = (Fun)pVtab[4][1];
cout << " [1]"; pFun(); //B2::Bf2();
pFun = (Fun)pVtab[4][2];
cout << " [2]";
cout << pFun << endl;
cout << "[5] = 0x";
cout << *((int*)(&dd)+5) << endl; // ???
cout << "[6] B2::ib2 = ";
cout << (int)*((int*)(&dd)+6) <<endl; //B2::ib2
cout << "[7] B2::cb2 = ";
cout<< (char)*((int*)(&dd)+7) << endl; //B2::cb2
cout << "[8] D::id = ";
cout << *((int*)(&dd)+8) << endl; //D::id
cout << "[9] D::cd = ";
cout << (char)*((int*)(&dd)+9) << endl;//D::cd
cout << "[10] =0x";
cout << (int*)*((int*)(&dd)+10) << endl;
//---------------------
cout << "[11] D::B::_vptr->" << endl;
pFun = (Fun)pVtab[11][0];
cout << " [0]"; pFun(); //D::f();
pFun = (Fun)pVtab[11][1];
cout << " [1]"; pFun(); //B::Bf();
pFun = (Fun)pVtab[11][2];
cout << " [2]";
cout << pFun << endl;
cout << "[12] B::ib = ";
cout << *((int*)(&dd)+12) << endl; //B::ib
cout << "[13] B::cb = ";
cout << (char)*((int*)(&dd)+13) <<endl;//B::cb
下面给出运行后的结果(分VC++和GCC两部份)
GCC 3.4.4
VC++2003
[0] B1::_vptr ->
[0] : D::f()
[1] : D::f1()
[2] : B1::Bf1()
[3] : D::f2()
[4] : D::Df()
[5] : 1
[1] B1::ib1 : 11
[2] B1::cb1 : 1
[3] B2::_vptr ->
[0] : D::f()
[1] : D::f2()
[2] : B2::Bf2()
[3] : 0
[4] B2::ib2 : 12
[5] B2::cb2 : 2
[6] D::id : 100
[7] D::cd : D
[8] B::_vptr ->
[0] : D::f()
[1] : B::Bf()
[2] : 0
[9] B::ib : 0
[10] B::cb : B
[11] NULL : 0
[0]D::B1::_vptr->
[0] D::f1()
[1] B1::Bf1()
[2] D::Df()
[3] 00000000
[1] = 0x0013FDC4 ç 该地址取值后是-4
[2] B1::ib1 = 11
[3] B1::cb1 = 1
[4] D::B2::_vptr->
[0] D::f2()
[1] B2::Bf2()
[2] 00000000
[5] = 0x4539260 ç 该地址取值后是-4
[6] B2::ib2 = 12
[7] B2::cb2 = 2
[8] D::id = 100
[9] D::cd = D
[10] = 0x00000000
[11] D::B::_vptr->
[0] D::f()
[1] B::Bf()
[2] 00000000
[12] B::ib = 0
[13] B::cb = B
关于虚拟继承的运行结果我就不画图了(前面的作图已经让我产生了很严重的厌倦感,所以就偷个懒了,大家见谅了)
在上面的输出结果中,我用不同的颜色做了一些标明。我们可以看到如下的几点:
1)无论是GCC还是VC++,除了一些细节上的不同,其大体上的对象布局是一样的。也就是说,先是B1(黄色),然后是B2(绿色),接着是D(灰色),而B这个超类(青蓝色)的实例都放在最后的位置。
2)关于虚函数表,尤其是第一个虚表,GCC和VC++有很重大的不一样。但仔细看下来,还是VC++的虚表比较清晰和有逻辑性。
3)VC++和GCC都把B这个超类放到了最后,而VC++有一个NULL分隔符把B和B1和B2的布局分开。GCC则没有。
4)VC++中的内存布局有两个地址我有些不是很明白,在其中我用红色标出了。取其内容是-4。接道理来说,这个指针应该是指向B类实例的内存地址(这个做法就是为了保证重复的父类只有一个实例的技术)。但取值后却不是。这点我目前还并不太清楚,还向大家请教。
5)GCC的内存布局中在B1和B2中则没有指向B的指针。这点可以理解,编译器可以通过计算B1和B2的size而得出B的偏移量。
结束语
C++这门语言是一门比较复杂的语言,对于程序员来说,我们似乎永远摸不清楚这门语言背着我们在干了什么。需要熟悉这门语言,我们就必需要了解C++里面的那些东西,需要我们去了解他后面的内存对象。这样我们才能真正的了解C++,从而能够更好的使用C++这门最难的编程语言。
在文章束之前还是介绍一下自己吧。我从事软件研发有十个年头了,目前是软件开发技术主管,技术方面,主攻Unix/C/C++,比较喜欢网络上的技术,比如分布式计算,网格计算,P2P,Ajax等一切和互联网相关的东西。管理方面比较擅长于团队建设,技术趋势分析,项目管理。欢迎大家和我交流,我的MSN和Email是:haoel@hotmail.com
本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/HelloWorld_GHH/archive/2008/10/19/3090067.aspx
打造自己的MFC:thunk技术实现窗口类的封装收藏
MFC功能已经非常强大,自己做界面库也许没什么意思,但是这个过程中却能学到很多东西。比如说:
窗口类的封装,从全局窗口消息处理到窗口对象消息处理的映射方法:
对界面进行封装,一般都是一个窗口一个类,比如实现一个最基本的窗口类CMyWnd,你一定会把窗口过程作为这个类的成员函数,但是使用WINAPI创建窗口时必须注册类WNDCLASS,里面有个成员数据lpfnWndProc需要WNDPROC的函数指针,一般想法就是把窗口类的消息处理函数指针传过去,但是类成员函数除非是静态的,否则无法转换到WNDPROC,而全局的消息处理函数又无法得到窗口类对象的指针。这里有几种解决办法:
一种解决方法是用窗口列表,开一个结构数组,窗口类对象创建窗口的时候把窗口HWND和this指针放入数组,全局消息处理函数遍历数组,利用HWND找出this指针,然后定位到对象内部的消息处理函数。这种方法查找对象的时间会随着窗口个数的增多而增长。
另一种方法比较聪明一点,WNDCLASS里面有个成员数据cbWndExtra一般是不用的,利用这点,注册类时给该成员数据赋值,这样窗口创建时系统会根据该值开辟一块内存与窗口绑定,这时把创建的窗口类的指针放到该块内存,那么在静态的窗口消息循环函数就能利用GetWindowLong(hWnd,GWL_USERDATA)取出该指针,return (CMyWnd*)->WindowProc(...),这样就不用遍历窗口了。但是这样一来就有个致命弱点,对窗口不能调用SetWindowLong(hWnd,GWL_USERDATA,数据),否则就会导致程序崩溃。幸好这个函数(特定这几个参数)是调用几率极低的,对于窗口,由于创建窗口都是调用窗口类的Create函数,不用手工注册WNDCLASS类,也就不会调用SetWindowLong函数。但是毕竟缺乏安全性,而且当一秒钟内处理的窗口消息很多时,这种查找速度也可能不够快。
还有一种就是比较完美的解决办法,称之为thunk技术。thunk是一组动态生成的ASM指令,它记录了窗口类对象的this指针,并且这组指令可以当作函数,既也可以是窗口过程来使用。thunk先把窗口对象this指针记录下来,然后转向到静态stdProc回调函数,转向之前先记录HWND,然后把堆栈里HWND的内容替换为this指针,这样在stdProc里就可以从HWND取回对象指针,定位到WindowProc了。
我们先来看看窗口过程函数定义:
LRESULT WINAPI WindowProc(HWND hWnd,UINT uMsg,WPARAM wParam,LPARAMlParam)
其实当我们的窗口类CMyWnd创建窗口的时候,窗口句柄是可以得到并且作为成员数据保存,如此一来,第一个参数hWnd是可以不要的,因为可以通过this->m_hWnd得到,我们可以在这里做手脚,hWnd其实质是一个指针,如果把这个参数替换为窗口类对象的this指针,那么我们不就可以通过(CMyWnd*)hWnd->WindowProc转到窗口类内部的窗口过程了吗?但是窗口过程是系统调用的,怎么能把hWnd替换掉呢?我们先来看看系统调用这个函数时的堆栈情况:
系统调用m_thunk时的堆栈:
ret HWND MSG WPARAM LPARAM
-------------------------------------------
栈顶 栈底
系统把参数从右到左依次压栈,最后把返回地址压栈,我们只要在系统调用窗口过程时修改堆栈,把其中的hWnd参数替换掉就行了。这时thunk技术就有用武之地了,我们先定义一个结构:
#pragma pack(push,1) //该结构必须以字节对齐
struct Thunk {
BYTE Call;
int Offset;
WNDPROC Proc;
BYTE Code[5];
CMyWnd* Window;
BYTE Jmp;
BYTE ECX;
};
#pragma pack(pop)
类定义:
class CMyWnd
{
public:
BOOL Create(...);
LRESULT WINAPI WindowProc(UINT,WPARAM,LPARAM);
static LRESULT WINAPI InitProc(HWND,UINT,WPARAM,LPARAM);
static LRESULT WINAPI stdProc(HWND,UINT,WPARAM,LPARAM);
WNDPROC CreateThunk();
WNDPROC GetThunk(){return m_thunk}
...
private:
WNDPROC m_thunk
}
在创建窗口的时候把窗口过程设定为this->m_thunk,m_thunk的类型是WNDPROC,因此是完全合法的,当然这个m_thunk还没有初始化,在创建窗口前必须初始化:
WNDPROC CMyWnd::CreateThunk()
{
Thunk* thunk = new Thunk;
///
//
//系统调用m_thunk时的堆栈:
//ret HWND MSG WPARAM LPARAM
//-------------------------------------------
//栈顶 栈底
///
//call Offset
//调用code[0],call执行时会把下一条指令压栈,即把Proc压栈
thunk->Call = 0xE8; // call [rel]32
thunk->Offset = (size_t)&(((Thunk*)0)->Code)-(size_t)&(((Thunk*)0)->Proc); // 偏移量,跳过Proc到Code[0]
thunk->Proc = CMyWnd::stdProc; //静态窗口过程
//pop ecx,Proc已压栈,弹出Proc到ecx
thunk->Code[0] = 0x59; //pop ecx
//mov dword ptr [esp+0x4],this
//Proc已弹出,栈顶是返回地址,紧接着就是HWND了。
//[esp+0x4]就是HWND
thunk->Code[1] = 0xC7; // mov
thunk->Code[2] = 0x44; // dword ptr
thunk->Code[3] = 0x24; // disp8[esp]
thunk->Code[4] = 0x04; // +4
thunk->Window = this;
//偷梁换柱成功!跳转到Proc
//jmp [ecx]
thunk->Jmp = 0xFF; // jmp [r/m]32
thunk->ECX = 0x21; // [ecx]
m_thunk = (WNDPROC)thunk;
return m_thunk;
}
这样m_thunk虽然是一个结构,但其数据是一段可执行的代码,而其类型又是WNDPROC,系统就会忠实地按窗口过程规则调用这段代码,m_thunk就把Window字段里记录的this指针替换掉堆栈中的hWnd参数,然后跳转到静态的stdProc:
//本回调函数的HWND调用之前已由m_thunk替换为对象指针
LRESULT WINAPI CMyWnd::stdProc(HWNDhWnd,UINT uMsg,UINT wParam,LONG lParam)
{
CMyWnd* w = (CMyWnd*)hWnd;
return w->WindowProc(uMsg,wParam,lParam);
}
这样就把窗口过程转向到了类成员函数WindowProc,当然这样还有一个问题,就是窗口句柄hWnd还没来得及记录,因此一开始的窗口过程应该先定位到静态的InitProc,CreateWindow的时候给最后一个参数,即初始化参数赋值为this指针:
CreateWindowEx(
dwExStyle,
szClass,
szTitle,
dwStyle,
x,
y,
width,
height,
hParentWnd,
hMenu,
hInst,
this //初始化参数
);,
在InitProc里面取出该指针:
LRESULT WINAPI CMyWnd::InitProc(HWNDhWnd,UINT uMsg,UINT wParam,LONG lParam)
{
if(uMsg == WM_NCCREATE)
{
CMyWnd *w = NULL;
w =(CMyWnd*)((LPCREATESTRUCT)lParam)->lpCreateParams;
if(w)
{
//记录hWnd
w->m_hWnd = hWnd;
//改变窗口过程为m_thunk
SetWindowLong(hWnd,GWL_WNDPROC,(LONG)w-CreateThunk());
return (*(WNDPROC)(w->GetThunk()))(hWnd,uMsg,wParam,lParam);
}
}
returnDefWindowProc(hWnd,uMsg,wParam,lParam);
}
这样就大功告成。
窗口过程转发流程:
假设已建立CMyWnd类的窗口对象 CMyWnd *window,初始化完毕后调用window->Create,这时Create的窗口其窗口过程函数是静态CMyWnd::InitWndProc
InitWndProc 实现功能:window->Create创建窗口时已把对象this指针放入窗口初始化参数中,在此过程的WM_NCCREATE消息中把this指针取出来:CMyWnd*w = (CMyWnd*)((LPCREATESTRUCT)lParam)->lpCreateParams;记录HWND:w->m_hWnd = hWnd,然后设置窗口过程为w->m_thunk(thunk是一个WNDPROC类型的成员数据,所以可以设置)
└→window->m_thunk 实现功能:跳转到静态CMyWnd::stdProc,在此之前替换系统的调用参数HWND为this指针
└→ stdProc 实现功能:把HWND转换为窗口类指针:
CMyWnd *w = (CMyWnd*)hWnd;
return w->WindowProc(uMsg,wParam,lParam)
└→ window->WindowProc 实现功能:执行实际的消息处理,窗口句柄已保存在m_hWnd
题外话:thunk的汇编代码全部写在注释里了,把这段汇编转成数据可费了不少劲,当时手头没有合适的工具,只有一本《8086/8088汇编语言程序设计》,根据附录中的指令码汇总表转成机器码数据,那里面根本没有EAX,ECX,ESP等的概念,只能连蒙带猜加调试,非法操作了n(n>10)回才得到那些数据,当时真是长出了一口气:TNND,终于搞定了!:-)
本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/ringphone/archive/2004/09/28/118883.aspx