从普通函数到对象方法 ------Windows窗口过程的面向对象封装
开始,由VirtualAlloc想起
我在查看VirtualAlloc这个API的时候,思绪竟然跳到另一个地方去了。那是以前阅读VCL源码时遗留下来的问题,Classes单元的MakeObjectInstance函数调用了VirtualAlloc,我甚是不解,为什么Delphi提供了那么多内存分配函数,而MakeObjectInstance偏偏要用系统提供的API,更令我不解的是,之后再也不见有VirtualFree的调用,也就是说,VCL其实存在内存泄漏?这个问题我在网上也看到相关的讨论,有人认为这的确是VCL的Bug,有人甚至修改了Classes单元,在单元的结束节处调用VirtualFree以释放以前分配的内存。
不过我对这个问题始终持保留态度,MakeObjectInstance是一个非常重要的函数,担负着窗口过程到对象方法的转换,Borland没有理由留着这个“Bug”不理。
于是我重新阅读了MakeObjectInstance这个倍受李维赞誉的函数,我想我这次是读懂了,为什么不调用VirtualFree,因为没有必要,进程在结束的时候会毫无保留的回收所有的内存,而经由VirtualAlloc分配到的内存就保留在由TInstanceBlock记录所组成的链表中,这个链表组成的内存并不是使用一次即弃掉的,它是可重用的,调用一次FreeObjectInstance,那张链表便空余出TObjectInstance大小的内存,以供下一次使用。所以,这其实是一个内存池,提供了更上一层的内存分配机制。而在结束的时候调用VirtualFree就显得没有任何必要了。
回到上面提出的第一个问题,为什么要调用VirtualAlloc,而不用Delphi提供的内存分配函数,如果没有看到System单元的这两个变量,我想永远也不可能找到答案:
var
AllocMemCount: Integer; { Number of allocated memory blocks }
AllocMemSize: Integer; { Total size of allocated memory blocks }
这两个变量的确是记录内存使用的总量,前提是你调用Delphi提供的内存管理函数,如果调用Windows原生的API,则VCL是没有办法感应到的。写到这里,再看看上面的描述,也许一切都了然了。
然而,这只是我写这篇文章的导火线,真正原因是我读懂了MakeObjectInstance,以前的许多疑惑已经拨云见日,窗口过程到对象方法的脉络在我的脑中从未有过这么清晰,因此欲罢不能,作此文记之。
使用,将窗口过程转成对象方法的步骤
从SDK的角度来讲,设置窗口过程有两种方法(我所能想到的),一是调用RegisterClass,另一个是调用SetWindowLong,第一种用在创建窗口的时候,另一种用在改变窗口过程的时候。在Delphi中,假设你写了一个自定义窗口类,那么你可以重载WndProc,这个方法就相当于窗口过程。可以确定,VCL在开始时肯定也是用上面所说的方法,设置窗口过程,只是后来经过一些转换,最终使窗口过程调用到对象实例的WndProc,所以WndProc可以当成窗口过程来使用。
这个转换的步骤从表面上看很简单,现在我们不必去深究其原理,只要知道通过下面的做法,就可以将一个窗口过程转成对象的方法。
首先,到Controls单元的TWinControl类,这是所有窗口的父类,转换过程就在这里面完成。TWinControl的构造函数中写了这一句:
constructor TWinControl.Create(AOwner: TComponent);
begin
... ...
FObjectInstance := Classes.MakeObjectInstance(MainWndProc);
... ...
end;
其中的MainWndProc就是代替窗口过程的对象方法。
接着,在InitWndProc有如下代码:
function InitWndProc(HWindow: HWnd; Message, WParam,
LParam: Longint): Longint;
Begin
... ...
SetWindowLong(HWindow, GWL_WNDPROC,
Longint(CreationControl.FObjectInstance));
... ...
end;
InitWndProc就是刚开始的窗口过程,而调用了SetWindowLong之后,窗口过程就转成了FobjectInstance了。而实际上最终得到调用是却是MainWndProc。
最后,在TWinControl的析构函数中还写了如下语句:
destructor TWinControl.Destroy;
begin
... ...
if FObjectInstance <> nil then
Classes.FreeObjectInstance(FObjectInstance);
... ...
end;
这是为了回收由MakeObjectInstance使用的内存,让这块内存可在下一次重用。
上面就是TWinControl的窗口过程到对象方法的转换步骤,这的确是很神奇的事情,它们在某些情况下是很有用的,比如TComboBox,在这个控件里面有一个用于编辑的Edit和一个用于下拉选择的ListBox,这两个控件是在ComboBox创建的时候一起创建的,VCL没有办法对它们进行封装,但有时候需要处理他们的消息,这时,上面的方法就派上用场了,事实上TComboBox就是运用上面的方法,将Edit和ListBox的窗口过程转换成TcomboBox内部的方法的,有兴趣者请查阅一下VCL。
对上面进行一次总结:
1、 假设你通过原生的API创建了一个窗口,如果你想让这个窗口的窗口过程被指定为一个类的方法,那么可以在类的内部调用MakeInstanceObject,传进类的一个方法(如上面的MainWndProc,当然这个方法必须是TwndMethod类型的),并保留函数返回的指针。
2、 调用SetWindowLong,用类保留的指针替换原来的窗口过程。到这里,窗口过程就被传进MakeInstanceObject的对象方法所代替了。
3、 在消毁这个类的实例时,别忘了调用FreeObjectInstance,并传回保留的指针。如果这时窗口还未消毁,还得用SetWindowLong恢复原来的窗口过程。
知道如何使用并不是我们的最终目的,我们要更进一步,为什么会是这样,请看下一节。
实现,窗口过程到对象方法的转换技术
窗口过程实际上是一个回调函数,向API传递函数的地址,Windows保留着这个函数地址,在适当的时候调用这个函数。那么对象方法与普通函数有什么不同呢,对于同一种调用规则来说,不同之处就是对象方法在第一个参数之前有一个隐藏的参数,这个参数就是对象的实例(如果是C++应该叫实例指针,而Delphi的对象实例就是一个指针,只已经为大多数人所共知的事实)。
另一方面,Windows的API使用的是Stdcall的调用规则,从机器指令的角度看,就是在Call某个函数之前,先将函数的参数从右向左地压栈。而Delphi为了提高效率,默认使用了Register调用规则,粗略的讲就是从左向右传递参数,且前三个参数分别放在EAX,EDX,ECX寄存器中,其后则依次入栈。若要知道详细的规则,请查看Delphi的帮助主题:Calling conventions。
现在,如果我们想让窗口过程流入某个对象的方法,要解决两个问题:
1、 在进入对象方法的入口时,先将对象实例作为第一个参数传入,其次再将窗口过程的参数依次传入。对于Register调用规则来说,就是将对象实例赋值给EAX,再将其他参数按照规则赋给相应的寄存器或者压栈。
2、 Stdcall规则到Register规则的转换,这个不是必须的,因为Delphi也支持StdCall规则,但对Register规则来说效率更高,另一方面Delphi对Register规则作了更多的支持,比如Published的属性就只能指定Register规则的方法。
现在让我们围线着这两个问题开始探索VCL是如何做的。
VCL在开始的时候同样要遵守Win32的做法,首先填充一个窗口类结构然后注册窗口类,注意TWinControl.CreateWnd中的这一句:
WindowClass.lpfnWndProc := @InitWndProc;
它将窗口过程指定为InitWndProc函数。
接下来就创建窗口类,在TWinControl.CreateWindowHandle中:
FHandle := CreateWindowEx(ExStyle, WinClassName, Caption, Style,
X, Y, Width, Height, WndParent, 0, WindowClass.hInstance, Param);
现在来看,一切都似乎正常,但其实在调用CreateWindowEx的时候,事情正在稍稍发生变化。CreateWindowEx的时候系统将发送(请注意是发送而不是投递)一个WM_CREATE消息给窗口,处理这个消息的是谁呢,正是上面看到的InitWndProc。
有必要看一下这个函数的代码,我顺便作了详细的注释:
01 function InitWndProc(HWindow: HWnd; Message, WParam,
02 LParam: Longint): Longint;
03 Begin
04 //CreationControl就是窗口类,TWinControl在CreateWnd的时候将Self赋给它
05 //由此可以看到VCL的窗口类是非线程安全的。
06 CreationControl.FHandle := HWindow;
07 //重设窗口过程,从此之后,这个函数再也不会得到调用了
08 SetWindowLong(HWindow, GWL_WNDPROC,
09 Longint(CreationControl.FObjectInstance));
10 if (GetWindowLong(HWindow, GWL_STYLE) and WS_CHILD <> 0) and
11 (GetWindowLong(HWindow, GWL_ID) = 0) then
12 SetWindowLong(HWindow, GWL_ID, HWindow);
13 //设置该窗的一些属性,与我们讨论的无关,可不去理会它们
14 SetProp(HWindow, MakeIntAtom(ControlAtom), THandle(CreationControl));
15 SetProp(HWindow, MakeIntAtom(WindowAtom), THandle(CreationControl));
16 //主动调用一次FobjectInstance
17 asm
18 PUSH LParam
19 PUSH WParam
20 PUSH Message
21 PUSH HWindow
22 MOV EAX,CreationControl
23 MOV CreationControl,0
24 CALL [EAX].TWinControl.FObjectInstance
25 MOV Result,EAX
26 end;
27 end;
第6行对窗口类的Fhandle进行赋值,这么做是必要的,因为正常情况下Fhandle只有到CreateWindowsEx返回之后才能得到赋值,在这个函数调用的过程中,系统发送WM_CREATE消息给窗口,在外部,我们可以得到WM_CREATE的处理器进行处理,如果没有第6行的赋值,则那时我们将没有办法得到窗口句柄。我想这也是InitWndProc存在的原因之一。
第8行重新设置窗口过程,设置为窗口类的FobjectInstance,从此以后,窗口消息只会流到FobjectStance指向的地方,这个函数也就作废了。
而接下来是一段汇编代码,主要的意思是调用FobjectInstance,18到21行传递参数(还记得STDCALL规则吗),然后24行调用FobjectInstance。这段汇编就相当于这样的语句:
WinControl := CreationControl;
CreationControl := nil;
Result := TThunkProc(WinControl.FObjectInstance)(HWindow, Message, WParam, LParam);
其实这正是Linux版下面的做法。
在这里我想说一下CALL指令,理解它的行为,对下文是很有帮助的,CALL指令可以分解为两个动作:先将下一条指令的地址(EIP)压栈,然后跳转到操作数指定的地址去。与CALL对应的是RET指令,这个指令其实就是从栈顶弹出一个值,然后跳转到这个值指明的地址去。这就是函数的原理,在函数内部,维持堆栈的平衡是非常重要的,你必须保证在RET的时候弹出来的值正是CALL的时候压入的值,这样才能正确返回到CALL指令的下一条指令的地址,要不然执行点就不知跳到哪里去了?当然使用高级语言不用去关心这些东西,但理解堆栈的知识仍然是非常有用的。
在InitWndProc完成它的历史命令之后,我们可以把目光关注到FobjectInstance这个指针去,现在它就是新的窗口过程,但是它到底指向了什么东西呢,答案就在前面看到的MakeObjectInstance中,我们要去详细的分解这个函数的代码,不过之前我要从总体上说一下这个过程:
FobjectInstance指向一块由MakeObjectInstance分配好的内存,这块内存存放的是一段机器指令,这段机器指令其实也是在MakeObjectInstance写入的,当FobjectInstance得到调用时,就执行了那段指令,这段指令的任务是将对象方法(这个方法就是传入MakeObjectInstance的那个参数,即MainWndProc)存放在ECX,然后跳转到StdWndProc去,StdWndProc从ECX取出MainWndProc,并从这个方法中得到对象实例(对象方法其实是一个地址和一个对象实例的组合,详情请看TMethod帮助),然后构造出一个Tmessage的结构,最后调用MainWndProc,流程完毕。
为了让读者有一个总体的认知,我画了下面的流程图:
从上面的分析看,至少有这么几个元素对转换过程起着至关重要的作用:
MakeObjectInstance函数
FObjectInstance以及其指向的内存
StdWndProc函数
现在我们就来详细解析它们。
在TWinControl的构造函数中调用了MakeObjectInstance,并传入TWinControl的一个方法:MainWndProc。MakeObjectInstance的代码是这样的:
01 function MakeObjectInstance(Method: TWndMethod): Pointer;
02 const
03 //机器指令
04 BlockCode: array[1..2] of Byte = (
05 $59, { POP ECX }
06 $E9); { JMP StdWndProc }
07 PageSize = 4096;
08 var
09 Block: PInstanceBlock;
10 Instance: PObjectInstance;
11 Begin
12 //InstFreeList指向一个TObjectInstance记录,这个记录是当前可用的
13 if InstFreeList = nil then
14 begin
15 //如果InstFreeList为空,就再创建4K的内存,这个内存格式化为一个
16 //TinstanceBlock结构。
17 Block := VirtualAlloc(nil, PageSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
18 Block^.Next := InstBlockList;
19 //对新创建的4K内存进行初始化
20 Move(BlockCode, Block^.Code, SizeOf(BlockCode));
21 Block^.WndProcPtr := Pointer(CalcJmpOffset(@Block^.Code[2], @StdWndProc));
22 //TinstanceBlock里面含有313个TobjectInstance记录,对这些记录进行初始化
23 Instance := @Block^.Instances;
24 repeat
25 Instance^.Code := $E8; { CALL NEAR PTR Offset }
26 Instance^.Offset := CalcJmpOffset(Instance, @Block^.Code);
27 Instance^.Next := InstFreeList;
28 InstFreeList := Instance;
29 Inc(Longint(Instance), SizeOf(TObjectInstance));
30 until Longint(Instance) - Longint(Block) >= SizeOf(TInstanceBlock);
31 InstBlockList := Block;
32 end;
33 //将可用的TobjectInstance块返回,并让InstFreeList指向下一个可用的块
34 Result := InstFreeList;
35 Instance := InstFreeList;
36 InstFreeList := Instance^.Next;
37 //将MainWndProc保存在这里
38 Instance^.Method := Method;
39 end;
这个函数一个非常重要的任务就是管理一个链表,这个链表的每一项有4096字节大小,每一项可以认为是一个TinstanceBlock结构(实际上TinStanceBlock只有4092字节,即最后4个字节是没有用的)。这个链表会随着MakeObjectInstance 的调用而增加链表项,但是不会被释放,到进程结束时由操作系统回收。InstBlockList变量指向这个链表头,可以用下图来表示:
每一个TinstanceBlock的结构是这样的:
PInstanceBlock = ^TInstanceBlock;
TInstanceBlock = packed record
Next: PInstanceBlock; //下一个块
Code: array[1..2] of Byte; //机器码
WndProcPtr: Pointer; //指针,相当于操作数
Instances: array[0..InstanceCount] of TObjectInstance;//314个记录数组
end;
Code和WndProcPtr一起组成了一段机器指令,请回头看看第20和21行,最后Code和WndProcPtr成员一起组成了类似下面这样的指令:
POP ECX
JMP Offset
上面的Offset是另有用意的,它等于WndProcPtr,而Jmp的结果是跳到StdWndProc的入口点去,为什么能够这样呢,请看第21行:
Block^.WndProcPtr := Pointer(CalcJmpOffset(@Block^.Code[2], @StdWndProc));
CalcJmpOffset函数如下
function CalcJmpOffset(Src, Dest: Pointer): Longint;
begin
Result := Longint(Dest) - (Longint(Src) + 5);
end;
StdWndProc的地址减去Code[2]的地址与5的和,为什么Code[2]还要加上5,才能被StdWndProc的地址减呢,原因是Code[2]等到$E9,后面跟一个地址(可以是绝对地址也可以是相对地址,这里是使用相对地址)就形成了一条JMP指令,$E9占一个字节,地址是一个指针占了4个字节,所以这条指令占用了5个字节,所以Code[2]要和5加后,被StdWndProc的地址减去后才能得到一个正确的相对地址(其实也就是StdWndProc的地址到JMP指令的距离)。
接下来的Instances是一个数组,共有314个,数组的每一项是一个TobjectInstance记录:
PObjectInstance = ^TObjectInstance;
TObjectInstance = packed record
Code: Byte; //机器码
Offset: Integer; //偏移,操作数
case Integer of
0: (Next: PObjectInstance); //可能是指向下一个记录
1: (Method: TWndMethod); //也可能存放一个方法类型
end;
Code和Offset也组成了一条机器指令,请看第25,26行,这条指令相当于:
CALL NEAR PTR Offset
Offset也是通过CalcJmpOffset计算得到的,它指定当前地址到Block的Code处的偏移,也就是调用ObjectInstance所在的InstanceBlock的Code处的代码。另外,请注意这里是使用CALL而不是JMP,这是有特殊的含意的,你不妨可以思考一下,稍后我会作解释。
接下来是一个变体,有可能是Next指向下一个记录,也有可能是一个TwndMethod的变量。看MakeObjectInstance的代码,在初始化Block块的时候,是将数组中所有项都设成Next的。这样看来,当一个InstanceBlock新生成时,这个Instances数组也可以当成一个链表了,从第28行可以看出,有一个变量InstFreeList ,就指向了这个表头。
但在34行下面的几句代码,返回了InstFreeList,并将这个记录指针的Next变成了Method,将传进来的参数Method赋给它,最后InistFreeList指向下一个ObjectInstance。这样看来,一个InstanceBlock是否已经用完取决于它里面的Instances数组,如果所有ObjectInstance的最后一个成员是Method,那么表示这个块已经用完了,相反如果是Next则表示还有ObjectInstance可用。
至此,我们可以确定,MakeObjectInstance返回的值(被FObjectInstance所接收),就是一个TobjectInstance的指针,且里面的Method成员的值等于传进函数中的参数的值(即MainWndProc)。
好了,让我们对MakeObjectInstance的行为作一些总结吧:
如果InstFreeList变量是空,表示InstanceBlock链表没有创建或者已经没有可用的ObjectInstance项了,这时要创建一个新的InstanceBlock记录,并对它进行初始化,接着让这个新的块作为链表头,即InstBlockList指向它,而它的Next成员则指向原来的表头。最后,块里面第一个ObjectInstance的Method被赋值,并作为函数结果返回。而InstFreeList则指向下一个ObjectInstance。
如果InstanceBlock链表里面还有可用的ObjectInstance项,过程就相对简单一点,对InstFreeList指向的ObjectInstance的Method成员赋值,并将它作为函数结果返回。InstFreeList则指向下一个可用的ObjectInstance。这个过程可以用下面的图来分解:
那么是不是说明已用的ObjectInstance再也收不回来了呢,其实不是,那些ObjectInstance都保存在各个窗口类当中,如果调用了FreeObjectInstance,则可以将这些内存回收回来,FreeObjectInstance的代码是这样的:
01 procedure FreeObjectInstance(ObjectInstance: Pointer);
02 begin
03 if ObjectInstance <> nil then
04 begin
05 //将回收的ObjectInstance的Next成员指向InstFreeList指向的内存块
06 PObjectInstance(ObjectInstance)^.Next := InstFreeList;
07 //InstFreeList指向被回收的内存
08 InstFreeList := ObjectInstance;
09 end;
10 end;
讲完了上面的内存块管理,现在可以将普通函数到对象方法的流程走一遍,其实这一个过程经过上面的讲解之后已经顺理成章,只要照着执行流程走下去就是
假设窗口接收到一个消息,则窗口类中的FObjectInstance得到调用,实际上就是执行ObjectInstance这块内存里面的指令。
ObjectInstance的Code和Offset成员组成了这样的指令:
CALL NEAR PTR Offset
我们知道这条指令将使执行点跳到Offset处的代码,通过上面的分析知道Offset处的代码就在这个ObjectInstance记录所在的InstanceBlock的Code处。另外一个非常重要的信息是Call指令调用时,会将下一条指令地址压栈,那么这里的下一条指令地址是什么呢?不就正是下面的Method成员的地址吗。所以,我们要紧记堆栈的现场,下图是调用上面的Call指令后的堆栈:
栈是向低的地址增长的,我们假设低地址在上面,而往下则地址渐增,因此图示像上面那样,栈顶以下的第二个值是窗口过程的返回地址,想一下Windows在调用我们的窗口过程的时候也是用Call指令的,所以当然要将Call指令的下一条指令地址压栈,这里所谓的“窗口过程的返回地址”指的就是Windows调用窗口过程的下一条指令地址。只要我们的窗口过程最终在Ret的时候,从栈中弹出的是这个值,那么窗口过程就正确地完成它的任务了。
回过头来,CALL指令之后,执行点已经到InstanceBlock的Code数组处了,这个Code数组和它下面的WndProcPtr一起组成下面的指令:
POP ECX
JMP Offset
第一行,将栈顶弹出的值存入ECX,这个值当然就是ObjectInstance.Method的地址。第二行执行一个JMP,JMP指令的一个好处就是不会对堆栈有任何影响,通过上面分析得知这次是跳到StdWndProc的入口点去了,记住现在的堆栈:
现在执行点到了非常重要的StdWndProc处,从上面的堆栈现场看,可以认为StdWndProc就是由Windows调用的窗口过程,只是这个时候对象方法的地址正保存在ECX中,看下面的代码:
01 { 标准窗口过程 }
02 { In ECX = 方法指针的地址 }
03 { Out EAX = 返回结果 }
04 function StdWndProc(Window: HWND; Message, WParam: Longint;
05 LParam: Longint): Longint; stdcall; assembler;
06 asm
07 XOR EAX,EAX
08 PUSH EAX
09 PUSH LParam
10 PUSH WParam
11 PUSH Message
12 MOV EDX,ESP
13 MOV EAX,[ECX].Longint[4]
14 CALL [ECX].Pointer
15 ADD ESP,12
16 POP EAX
17 end;
第7到第11行,实际上它是在堆栈上构造一个Tmessage结构,这个结构正是TwndMethod类型的方法所需要的唯一参数,Tmessage可简化为这样:
TMessage = packed record
Msg: Cardinal;
WParam: Longint;
LParam: Longint;
Result: Longint);
end;
所以第8行推入的Result,第9行推入的Lparam,以此类推。
第12行将栈顶赋值给EDX,记得Register调用规则吗,EDX正是我们看得到的第一个参数,而这个参数被赋给了一个Tmessage记录的地址(其实就是栈顶)。我们看一下现在的堆栈现场:
之所以会有“上一个EBP”这一项,是在StdWndProc的ASM处有一个EBP压栈的指令,尽管这是一个非常有用的技术,但对我们的主题没有任何意义,所以就略去不讲了。
接着看第13行,ECX是方法指针的地址,那么[ECX]就得到方法指针本身了,而[ECX].Longint[4]是方法指针首地址偏移4个字节处,正是方法指针对应的对象实例,为了让读者更明白,我画了下面的图揭示方法指针的内存分布:
现在EDX存Tmessage的地址,EAX存对象实例,看看Twndmethod的声明
TWndMethod = procedure(var Message: TMessage) of object;
想想Register的调用规则,我们得出结论,参数传递已经完成,接下来当然是调用对象方法,看第14行,做的就是这个事情。也就是这个时候,当初通过MakeObjectInstance传进来的MainWndProc得到调用了,流程终于走到对象的方法去了。
MainWndProc如何做我们大可不去理会,现在来看在MainWndProc调用完后的第15行,栈顶加12表示栈顶的前3个值出栈(记住栈是向低处增长的),那么现在的栈顶就是Tmessage结构的Result。再看第16行,将Result弹出给EAX,将这作为函数的返回值。看一下堆栈:
在StdWndProc的End处,先有一个Pop EBP的动作,才有一个Ret指令,所以最终能够正确的返回窗口过程。整个过程到这里结束。
这真是一个激动人心的时刻,尽管李维的Inside VCL对于这一主题有详尽的描述,但只有自己将整个流程走通,才能真正理解这一个转换的过程。
我们已经走得很远了,不过我们可以走得更远一些,让我们更进一步,来讨论回调函数到对象方法的转换过程吧。
扩展,将对象方法设为回调函数
Win32的API有一些需要回调函数,说白了就是函数指针,比如钩子,列举窗口等等。如果我们要对这些技术进行面向对象的封装,就要遇到一些难题。拿钩子来说,假设我们要封装一个键盘钩子,设计一个TKeyboard Hook类,并提供一个Active属性,如果Active属性为True,就调用SetWindowsHookEx安装一个键盘钩子,如果Active为False,就调用UnhookWindowsHookEx卸载键盘钩子,一切看起来都很好,但是调用SetWindowHookEx时需要提供一个HOOKPROC类型的回调函数,而我们并不能用一个对象的方法去作为回调函数传进去。如果有一种方法,能将普通的回调函数转换成对象的方法,那将是很棒的事情,其实VCL的MakeObjectInstance函数已经为我们开了先河,尽管它只是转换了窗口的回调函数,但对于一般的回调函数,我们同样可以仿照着做。
上文中提到过在同一种调用规则下,Win32的API与对象方法之间的差别,仅有的一点就是多了个Self的隐藏参数。由于MakeObjectInstance只是针对窗口的回调函数,参数是确定的,所以可以多做一些功夫,把StdCall转成Register调用规则。但扩展到所有的回调函数,情况就复杂得多了,你不知道这个回调函数的参数个数,因此没法进行调用规则的转换。既然如此,我们退一步,让对象方法必须也是StdCall调用规则,作这一让步并不需要付出多大的代价,你只需要把这个对象方法作为中转站,在方法里面调用Register版的方法即可,而剩下的事情由编译器帮我们做就行了。
基本的原理与上文的描述是很相似的,即提供一个内存块,内存块中保留着一段机器指令,这段指令最终能够调用到对象的指定方法。声明一个指向这个内存块的指针,将它作为回调函数传进API中。
在我即将完成这个有趣的事情而感到兴奋时,我看到网上已经有人实现了这样的转换,那就是大富翁的SaveTime,我在他的2004学习笔记中看到了“让类成员函数成为Windows回调函数的方法”,原来在两年多前就有人完成了这样的事情,看来我的此举是有些多余了,我认真看了Savetime的实现方法,基本的思路是差不多的,不过他写到内存块中的机器指令似乎不是很好,他的指令是这样:
MOV EAX, [ESP]; //栈顶的值存到EAX中,此时栈顶的值即是回调函数返回地址
PUSH EAX; //将EAX入栈,
MOV EAX, ObjectAddr;
MOV [ESP+4], EAX; //将对象地址作为对象方法的第一个参数
JMP FunctionAddr; //跳到对象方法去
这段指令实现的功能与我原来想的一样,我们知道在调用API时,要先将参数从右到左的入栈,然后调用函数。我们假设Windows调用了回调函数,执行点到了上面的代码,此时栈顶是回调函数的返回地址,下面则是回调函数所需要的参数,那么这段指令就是将回调函数的返回地址下移一个栈值,再将对象指针存到函数返回地址原来的位置,先后两种情况的堆栈是这样的:
如图2所示,此时已经完成了调用对象方法所需要的一切工作,接下来跳到对象方法的入口点去就行了。
这段代码的思路是正确的,不过我认为有一点值得考虑,就是EAX,如果之前EAX的值是有用的,那么执行这段指令之后,它的值就被破坏了,最好的情况就是不要使用寄存器,我将指令优化了一下,成了下面这样子:
push [ESP]
mov [ESP+4], ObjectAddr
jmp MethodAddr
现在只需要三条指令就可以完成了,现实的功能是一样,从机器指令的大小来算,Savetime的需要18字节,而我的指令只需要16字节,所以在空间方面也有所减少。由此看来,我所做的并非无用功呀,呵呵!
至此已经万事具备,应该将代码列出来了,我写了一个CallbackToMethod的单元,这个单元具有一定的通用性,可以应用到你需要的地方去,请看下面的代码:
01 unit CallBackToMethod;
02
03 {*******************************************
04 * brief: 回调函数转对象方法的实现
05 * autor: linzhenqun
06 * date: 2006-12-18
07 * email: linzhengqun@163.com
08 ********************************************}
09 {
10 说明:本单元的实现方法是一种比较安全的方式,其中不破坏任何寄存器的值,并且
11 指令的大小只有16字节。
12 使用:下面是推荐的使用方法
13 1. 在类中保存一个指针成员 P: Pointer
14 2. 在类的构造函数中创建指令块:
15 var
16 M: TMethod;
17 begin
18 M.Code := @MyMethod;
19 M.Data := Self;
20 P := MakeInstruction(M);
21 end;
22 3. 调用需要回调函数的API时,直接传进P即可,如:
23 HHK := SetWindowsHookEx(WH_KEYBOARD, P, HInstance, 0);
24 4. 在类的析构函数中释放指令块
25 FreeInstruction(P);
26 注意:作为回调函数的对象方法必须是StdCall调用规则
27 }
28
29 interface
30
31 (* 创建回调函数转对象方法的指令块 *)
32 function MakeInstruction(Method: TMethod): Pointer;
33 (* 消毁指令块 *)
34 procedure FreeInstruction(P: Pointer);
35
36 implementation
37
38 uses SysUtils;
39
40 type
41 {
42 指令块中的内容相当于下面的汇编代码:
43 ----------------------------------
44 push [ESP]
45 mov [ESP+4], ObjectAddr
46 jmp MethodAddr
47 ----------------------------------
48 }
49 PInstruction = ^TInstruction;
50 TInstruction = packed record
51 Code1: array [0..6] of byte;
52 Self: Pointer;
53 Code2: byte;
54 Method: Pointer;
55 end;
56
57 function MakeInstruction(Method: TMethod): Pointer;
58 const
59 Code: array[0..15] of byte =
60 ($FF,$34,$24,$C7,$44,$24,$04,$00,$00,$00,$00,$E9,$00,$00,$00,$00);
61 var
62 P: PInstruction;
63 begin
64 New(P);
65 Move(Code, P^, SizeOf(Code));
66 P^.Self := Method.Data;
67 P^.Method := Pointer(Longint(Method.Code)-(Longint(P)+SizeOf(Code)));
68 Result := P;
69 end;
70
71 procedure FreeInstruction(P: Pointer);
72 begin
73 Dispose(P);
74 end;
75
76 end.
第60行是机器指令,实现的功能就是注释中的汇编,请不要被这些数字吓倒,只要先写好汇编,用CPU窗口一查就知道了,至少我就是这么做的。
在上文中曾说到封装一个键盘钩子,下面就是一个简单的实现版本:
01 unit HookKeyBoard;
02
03 interface
04 uses
05 Windows, Messages, Classes, Forms, Controls, CallBackToMethod;
06
07 type
08 TKeyEventEx = procedure(Sender: TObject; IsDown: Boolean;
09 ShiftState: TShiftState; Key: Word) of object;
10
11 TKeyBoardHook = class
12 private
13 HHK: HHOOK;
14 P: Pointer;
15 FActive: Boolean;
16 FKeyEvent: TKeyEventEx;
17 procedure SetActive(const Value: Boolean);
18 function KeyboardProc(code: Integer;
19 wParam: WPARAM; lParam: LPARAM): LRESULT; stdcall;
20 protected
21 function DoKeyEvent(IsDown: Boolean; ShiftState: TShiftState;
22 Key: Word): Boolean; virtual;
23 public
24 constructor Create;
25 destructor Destroy; override;
26 property Active: Boolean read FActive write SetActive;
27 property OnKeyEvent: TKeyEventEx read FKeyEvent write FKeyEvent;
28 end;
29
30 implementation
31
32 uses SysUtils;
33
34 { TKeyBoardHook }
35
36 constructor TKeyBoardHook.Create;
37 var
38 M: TMethod;
39 begin
40 M.Code := @TKeyBoardHook.KeyboardProc;
41 M.Data := Self;
42 P := MakeInstruction(M);
43 end;
44
45 destructor TKeyBoardHook.Destroy;
46 begin
47 SetActive(False);
48 FreeInstruction(P);
49 inherited;
50 end;
51
52 function TKeyBoardHook.DoKeyEvent(IsDown: Boolean;
53 ShiftState: TShiftState; Key: Word): Boolean;
54 begin
55 if Assigned(FKeyEvent) then
56 FKeyEvent(Self, IsDown, ShiftState, Key);
57 Result := False;
58 end;
59
60 function TKeyBoardHook.KeyboardProc(code: Integer; wParam: WPARAM;
61 lParam: LPARAM): LRESULT;
62 var
63 IsKeyDown: Boolean;
64 ShiftState: TShiftState;
65 CharCode: Word;
66 begin
67 if code >= 0 then
68 begin
69 ShiftState := KeyDataToShiftState(lParam);
70 CharCode := LOWORD(wParam);
71 IsKeyDown := lParam and $80000000 = 0;
72 if DoKeyEvent(IsKeyDown, ShiftState, CharCode) then
73 begin
74 Result := 1;
75 Exit;
76 end;
77 end;
78 Result := CallNextHookEx(HHK, code, wParam, lParam);
79 end;
80
81 procedure TKeyBoardHook.SetActive(const Value: Boolean);
82 begin
83 if FActive <> Value then
84 begin
85 if Value then
86 begin
87 HHK := SetWindowsHookEx(WH_KEYBOARD, P, HInstance, 0);
88 if HHK = 0 then
89 raise Exception.Create('can not install a keyboard hook');
90 end
91 else
92 UnhookWindowsHookEx(HHK);
93 FActive := Value;
94 end;
95 end;
96
97 end.
代码中没有作什么注释,那不是我们的重点。可以覆盖DoKeyEvent方法,以实现功能更丰富的键盘钩子类。
请用CallbackToMethod单元多测试一些例子,如果有什么错误,欢迎指正,这个转换的功劳应该归于Savetime,我只是作了一些优化,谈不上什么创造。
我的文章到此就告一段落了,写这篇文章花了我五个晚上的时间,每天晚上都是半夜才睡觉,早上几乎都是很疲惫地去上班。有时候也问自己,花这么大的力气写这些东西有什么用呢,我想,对于自己,能够用文字表达这些东西,说明自己已经很好地掌握这些知识了;而对于别人,看到这些文字,也许可以少走一些弯路,多得到一些知识。这样看来,于己于人都是大有脾益,何乐而不为呢。
还是那句话,希望对你有用!