《WINDOWS 环境下32位汇编语言程序设计》第11章 动态链接库和钩子

发布于:2025-09-04 ⋅ 阅读:(23) ⋅ 点赞:(0)

动态链接库在Windows系统中无处不在,在前面的章节中介绍过很多的API函数,这些函数全部都是以动态链接库的方式提供的,可以说,在不知不觉中,已经在每个例子中都用到了动态链接库。实际上,在Win32编程中不使用动态链接库是几乎不可能的,因为Windows提供给程序员的几乎所有功能都驻留在动态链接库里面。

在本章中,将介绍如何编写动态链接库,并更全面地探讨动态链接库的使用方法,包括以不同的方法装入动态链接库和以不同的方法调用其中的函数等。

除了可以使用动态链接库来发布产品外,Windows中还有一些功能是必须使用动态链接库的,如使用钩子函数等。由于Windows钩子的使用和动态链接库密切相关,所以将这部分的内容也放在本章中介绍。

11.1 动态链接库

11.1.1 动态链接库的概念

在DOS环境下编过程序的读者一定知道静态库的含义——程序员将实现各种功能的代码写成一个个子程序(函数),编译成obj文件后,用Lib.exe工具将多个obj文件组合成一个lib文件,当程序中要用到这些函数的时候,只需要指定函数名称,链接器就可以从库中抽出对应的子程序代码插入到可执行文件中去,这样就可以不必一遍遍地重写相同的功能代码,这种链接方法就是静态链接。

静态链接的缺点显而易见,如果有多个程序用到库中的同样函数,那么所有这些可执行文件中都会包含一份同样的代码,对于几乎每个程序都必须使用的常用函数来说,如果硬盘上有一万个程序用到这个函数,那么就存在一万份相同的代码,这显然是很浪费空间的。静态链接的另外一个缺点是:如果某个函数因为发现有错或更新算法等种种原因需要升级时,必须把所有用到此函数的可执行文件都找回来重新编译一遍,遗漏的程序中存在的还是旧版本的代码。

另外,从内存使用的方面考虑,DOS操作系统是单任务的操作系统,每时每刻只能有一个程序在运行,所以使用静态链接浪费的空间仅表现在磁盘空间的浪费上;而Windows操作系统是多任务的,内存中会同时装入多个程序的代码,如果使用静态链接的话,意味着有多份相同的代码被装入内存,这种浪费的代价将是更昂贵的。

Windows的解决办法就是使用动态链接库,动态链接库从表面上看也是提供了一大堆通用的函数,也可以被多个程序使用,但它和静态库在使用上有很多的不同点。

静态库仅在链接的时候使用,链接完成后,可执行文件就可以脱离库文件单独使用了。而动态链接库中的代码在程序链接的时候并不会被插入到可执行文件中,在程序运行的时候才将整个库的代码调入内存,所以称为“动态链接”。如果有多个程序用到同一个动态链接库,Windows在物理内存中只保留一份库的代码,仅通过分页机制将这份代码映射到不同进程的地址空间中,这样不管有多少程序在使用一个库,库代码实际占用的物理内存永远只有一份。当然,这时候库使用的数据段还是会被映射到不同的物理内存中,多少个程序在使用动态链接库就会有多少份数据段。动态链接库的工作方式在图1.6中就已经有所演示了。

如果应用程序要使用动态链接库中的函数,那么程序中必须包括库的名称和函数的名称,这是动态寻找对应函数所必需的,这些定位信息在编译和链接的时候被插入到可执行文件中。定位信息取自导入库文件,在前面这么多章的编程中我们已经多次用到了导入库文件。

动态链接库的缩写为DLL,大部分动态链接库是以扩展名为dll的文件形式存在的,但并不是只有dll扩展名的文件才是动态链接库,系统中的某些exe文件、字体文件(*.fon)、一些驱动程序(*.drv和*.sys)、各种控件(*.ocx)和输入法模块(*.ime)等都是动态链接库。实际上,系统中大部分包含公用代码的模块——不管扩展名是什么——都有可能是动态链接库。

一个文件是否是动态链接库取决于它的文件结构,动态链接库文件和可执行文件同样使用标准的PE文件格式,仅文件头中的属性位不同而已,所以exe文件的一些特征也存在于动态链接库中,比如,在动态链接库中也可以定义并使用各种资源,可以导入并使用其他动态链接库中的函数等。

有一个最重要的概念一定要牢记:动态链接库是被映射到其他应用程序的地址空间中执行的,它和应用程序可以看成是“一体”的动态链接库可以使用应用程序的资源它所拥有的资源也可以被应用程序使用,它的任何操作都是代表应用程序进行的,当动态链接库进行打开文件、分配内存和创建窗口等操作后,这些文件、内存和窗口都是为应用程序所拥有的

11.1.2 编写动态链接库

与前面一些例子程序相比,写动态链接库程序应该算是很简单的,程序中并不需要用到新的函数,只是在程序的结构上和链接时的选项有些区别而已。让我们通过一个简单的例子来说明,例子代码在所附光盘的Chapter11\Dll\Dll目录中,包括汇编源文件Sample.asm和定义文件Sample.def。上一层子目录Chapter11\Dll中的另两个子目录存放的是使用汇编和VC++调用这个DLL的程序,其中的内容将在下一节中分析。

Sample.asm的内容如下:

;Sample.asm       一个简单的动态链接库例子
;使用 nmake 或下列命令进行编译和链接:
;ml /c /coff Sample.asm
;Link /subsystem:windows /Dll /Def:Sample.def Sample.obj
.386
.model flat,stdcall 
option casemap:none 
 
;include 文件定义
include 	c:/masm32/include/windows.inc 

.data?
dwCounter	dword ?

.code 
;dll 的入口函数
DllEntry proc _hInstance, _dwReason, _dwReserved 
	mov eax, TRUE 
	ret 
DllEntry endp 
 
;本函数在 dll 内部使用
_CheckCounter proc 
	mov eax, dwCounter 
	cmp eax, 0 
	jge @F 				;如果eax大于等于0, 跳转到“向前的第一个@@标签”,即下面的那个@@
	xor eax, eax 
@@:	
	cmp eax, 10
	jle @F 				;如果eax小于等于10, 跳转到“向前的第一个@@标签”,即下面的那个@@
	mov eax, 10
@@:
	mov dwCounter, eax 
	ret 
_CheckCounter endp 

;dll 的导出函数之一
_IncCounter proc 
	inc dwCounter 
	call _CheckCounter 
	ret 
_IncCounter endp 

; dll 的导出函数之二
_DecCounter proc 
	dec dwCounter 
	call _CheckCounter 
	ret 
_DecCounter endp 
 
;dll 的导出函数之三
_Mod proc uses ecx edx _dwNumber1, _dwNumber2 
	xor edx, edx 
	mov eax, _dwNumber1 
	mov ecx, _dwNumber2 
	.if ecx 
		div ecx 
		mov eax, edx 
	.endif 
	ret 
_Mod endp 
 
End DllEntry 

程序很简单,没有任何和创建界面相关的代码,仅定义了5个子程序:DllEntry,_CheckCounter,_IncCounter,_DecCounter和_Mod。其中程序的入口由最后一句End语句定义到了DllEntry处。_IncCounter和_DecCounter子程序的功能分别是对dwCounter变量进行递增或递减的操作,_CheckCounter子程序被前面两个子程序调用,用于检查递增或递减后的dwCounter值是否限定在0到10之间;_Mod子程序的功能和前面的函数都不相关,它返回对两个参数进行取模运算后的结果。

一眼看上去,程序比较莫名其妙——入口的代码什么都没有做,仅返回一个TRUE;也没有地方用到_IncCounter、_DecCounter和_Mod函数,这是为什么呢?请记住,dll文件被设计为不是供自己使用的,而是被映射到其他应用程序的地址空间中代表“宿主”程序执行的,这些函数就是供其他程序使用的函数,对于“宿主”程序来说,虽然这些函数仅包含几行代码,但它们的“级别”和User32.dll中的CreateWindowEx与DefWindowProc等极其复杂的函数没有任何区别

1.入口点和初始化代码

与可执行文件一样,动态链接库需要一个入口点,动态链接库的入口点是一个函数,函数的名称并不重要,例子代码中的入口函数命名为“DllEntry”,读者也可以把它取名为其他任何合法的名字,但入口函数的格式是有规定的。

库的入口函数对调用动态链接库的应用程序来说是不可见的,它仅供操作系统使用。Windows在库加载、卸载、进程中线程的创建和结束等时候调用入口函数,以便动态链接库可以采取相应的动作。在入口函数中可以通过参数来判别Windows的本次调用究竟是在哪种情况下发生的。入口函数的结构一般如下面所示:

DllEntry proc hInstDLL,dwReason,dwReserved
    mov eax,dwReason
    .if eax ==  DLL_PROCESS_ATTACH
          ;保存hInstDll
          ;初始化库需要的各种资源
          .if       ;初始化成功
              mov eax,TRUE
          .else
              mov eax,FALSE
          .endif
    .elseif eax ==  DLL_THREAD_ATTACH
          ;为新的线程分配资源
    .elseif eax ==  DLL_THREAD_DETACH
          ;为线程释放资源
    .elseif eax ==  DLL_PROCESS_DETACH
          ;释放库使用的资源
    .endif
    ret
DllEntry endp

Windows会传给入口函数3个参数,dwReason参数的值表示本次调用的原因,它可能是下面的四种情况之一。

当dwReason的值是DLL_PROCESS_ATTACH的时候,表示动态链接库刚被映射到某个进程的地址空间,程序可以在这里进行一些初始化的工作,并返回TRUE表示初始化成功,返回FALSE表示初始化出错,这样库的装入就会失败。这给了动态链接库一个机会来阻止自己被装入。比如,库程序可以在这里申请并保留一些内存,如果申请失败的话就可以返回FALSE告诉Windows,库无法正常工作。

当dwReason的值是DLL_PROCESS_DETACH的时候则相反,表示动态链接库将被卸载,库程序可以在这里进行一些资源的释放工作,如将初始化时申请的内存释放,将打开的文件关闭等。以DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH值进行的调用在库的生命周期中只可能出现一次。

当dwReason的值是DLL_THREAD_ATTACH的时候,表示应用程序创建了一个新的线程。当某个线程正常终止的时候,dwReason的值是DLL_PROCESS_DETACH。如果应用程序不是以多线程方式工作的话,就不会有这两种原因的调用;反之,如果应用程序频繁地创建和结束线程,那么入口函数将不断被调用。

hInstDll是动态链接库的模块实例句柄。当使用这个句柄来装入资源的时候,表示资源是定义在库文件中的。对于动态链接库来说,获取这个句柄的唯一途径就是在入口函数被调用的时候保存这个参数,如果在DLL_PROCESS_ATTACH时不将这个句柄保存下来的话,运行时可能就没有其他方法可以获取了。

dwReserved参数是系统保留的参数,可以不必理会。

读者可能会问:不是可以通过invoke GetModuleHandle,NULL来获取模块实例句柄吗?是的,但是由于动态链接库是代表应用程序运行的,所以,如果在库中调用这个函数,得到的仍然是“宿主”程序的实例句柄,而不是库程序的实例句柄。

在例子程序中,不需要初始化工作,所以仅返回一个TRUE,表示任何情况下,Windows都可以装入这个库文件。动态链接库有一种很“极端”的应用:纯资源库,这些库仅包含资源而没有任何的功能函数,如字体文件等,对于这些库来说,库中的全部代码仅是入口函数中用来返回TRUE的那几句,这是库能被正常装入所必需的代码

2.导出函数

与写普通的可执行文件相比,动态链接库的设计流程中多了一个文件,那就是定义文件*.def,源代码目录中就有一个Sample.def文件,它的内容是:

    EXPORTS  _IncCounter
            _DecCounter
            _Mod

文件内容总共只有三行:一个EXPORTS关键字加上三个库中函数的名称,这是用来告诉链接器这三个函数需要导出,也就是说这三个函数可以被其他程序调用。动态链接库的文件格式是PE格式,每个PE格式文件的文件头中都可以有一个导出表,只有导出表中列出的函数才可以被其他程序调用,链接器根据def文件的内容在导出表中加入由EXPORTS关键字指定的函数名(导出表的详细说明请参考第17章)。

如果库文件中的函数没有在def文件中指定(如例子中的_CheckCounter函数),那么这个函数就仅能被库文件本身之中的代码调用,而无法在其他应用程序中使用,这是因为库文件的导出表中没有列出它的名称,这样其他程序根本不会知道它的存在。对于这些函数,可以把它们叫做私有函数。

3.链接选项

为了生成动态链接库文件,在链接的时候必须使用合适的选项,来看看Sample库文件例子使用的Makefile文件:

DLL = Sample
ML_FLAG = /c /coff
LINK_FLAG = /subsystem:windows /Dll
$(DLL).dll: $(DLL).obj $(DLL).def
    Link  $(LINK_FLAG) /Def:$(DLL).def $(DLL).obj
.asm.obj:
    ml $(ML_FLAG) $<
.rc.res:
    rc $<
clean:
    del *.obj
    del *.exp

编译的时候,使用Ml.exe编译器的方法并没有什么不同,但是使用Link.exe链接程序的时候,必须使用/Dll和/Def选项,/Dll选项告诉链接器输出文件的格式是动态链接库,/Def:filename.def选项用来指定定义了导出函数名称的def文件名,在这个例子中,库文件中没有包含资源,如果包含资源的话,链接时还可以指定资源文件名,一个完整的链接参数如下所示:

Link /DLL /subsystem:windows /Def:filename.def filename.obj filename.res

4.发布动态链接库

当使用Link.exe链接器完成链接工作后,链接器生成3个文件,它们分别以dll,lib和exp为扩展名,dll文件就是动态链接库,而lib文件是供程序开发用的导入库,exp文件是输出库文件,这是链接时的一个副产品,一般没有什么用途,我们可以直接将它删掉。

回想一下:当在汇编源程序中用到某个动态链接库中的函数时,在源文件的一开始就要用includelib语句指定动态链接库的导入库,这样链接的时候链接器才知道到哪个库中寻找指定的函数,如果开发的时候没有动态链接库的导入库文件,使用起来就比较麻烦了。

为了在开发其他程序的时候使用自己编写的动态链接库,就必须提供这个动态链接库的导入库文件,Link.exe考虑了这一点,所以在生成dll文件的同时也生成了导入库文件。如果dll文件是作为最终应用程序的一部分发布的,可以仅发布dll文件;如果是当做组件供其他人做二次开发用的,那么开发者就应该为其他程序员同时提供dll文件和lib文件,并且根据情况提供不同语言使用的头文件,头文件中最好为每个导出函数写一个说明,包括函数的功能、参数的数量、类型和定义等,同时写上版权、版本号等信息,以便其他程序员参考使用。

例如,目录中还有一个Sample.inc文件,这就是为Sample.dll文件书写的供其他汇编程序员使用的头文件,它的内容如下:

;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Version          1.0
;                  Date: 2004.05.01
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Sample.dll导出函数:
;
; 1、invoke _IncCounter
;        增加Dll内部计数器的值(最大增加到10)并返回计数值
; 2、invoke _DecCounter
;        减少Dll内部计数器的值(最小减少到0)并返回计数值
; 3、invoke _Mod,dwNumber1,dwNumber2
;        输入:dwNumber1 和dwNumber2 为两个整数
;        输出:两数的模dwNumber1 % dwNumber2

_IncCounter        proto
_DecCounter        proto
_Mod               proto    dwNumber1:dword,dwNumber2:dword

文件中不但列出了三个导出函数的函数声明,而且以注释的形式给出了函数的功能、调用方法和参数说明。(读者现在应该知道MASM32 SDK软件包中包含的lib文件和inc文件是怎么来的了!)

要是编写的动态链接库是供其他C程序员做开发用的,那么还应该书写.h头文件,.h头文件的写法具体见11.1.5节。

11.1.3 使用动态链接库

虽然在前面的学习中一直在使用动态链接库,但本节仍然要介绍一下使用动态链接库的方法,这是为了比较全面地介绍使用动态链接库的不同途径和它们之间的区别。相关的例子文件包含在所附光盘的Chapter11\Dll\ MASM Sample目录中。在开始分析例子之前,首先要把上一节中生成的相关文件拷贝到本目录中以便使用,它们是Sample.dll,Sample.lib和Sample.inc文件。

例子包括两个不同的程序UseDll1和UseDll2,分别用来演示使用DLL的两种方法。两个例子文件使用了同样的资源定义文件UseDll.rc,所以它们的界面是一样的,事实上,它们完成的功能也是一样的,仅实现的方法不同而已,UseDll.rc文件的内容如下:

//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#include     <resource.h>
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#define ICO_MAIN          1000
#define DLG_MAIN          1000
#define IDC_COUNT         1001
#define IDC_INC           1002
#define IDC_DEC           1003
#define IDC_NUM1          1004
#define IDC_NUM2          1005
#define IDC_MOD           1006
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ICO_MAIN  ICON        "Main.ico"
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN DIALOG 186, 132, 173, 79
STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "DLL例子"
FONT 9, "宋体"
{
  GROUPBOX "Dll内部计数器", -1, 4, 2, 164, 32, BS_GROUPBOX
  EDITTEXT IDC_COUNT, 10, 15, 73, 12, ES_READONLY
  PUSHBUTTON "增加(&A)", IDC_INC, 86, 13, 36, 14
  PUSHBUTTON "减少(&D)", IDC_DEC, 123, 13, 37, 14
  GROUPBOX "取模函数测试", -1, 4, 37, 164, 32, BS_GROUPBOX
  EDITTEXT IDC_NUM1, 11, 50, 43, 12, ES_NUMBER
  LTEXT "%", -1, 57, 52, 8, 8
  EDITTEXT IDC_NUM2, 64, 50, 43, 12, ES_NUMBER
  LTEXT "=", -1, 110, 52, 8, 8
  EDITTEXT IDC_MOD, 117, 50, 43, 12, ES_READONLY
}
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

资源中定义了一个如图11.1所示的对话框,对话框上方设计了两个按钮,分别用来调用Sample.dll中的_IncCounter和_DecCounter函数,以便对DLL内部计数器中的计数进行增减操作,操作结果显示在按钮左边的框中;对话框下方有两个可以填入数值的编辑框,程序随数值的输入调用_Mod函数计算两数之模。

                                                   图11.1 DLL使用例子的运行界面

1.方法一:常规方法

先看UseDll1.asm程序,这个程序用常规的方法实现了对Sample.dll动态链接库中函数的调用:

; UseDll1.asm
; 自编 Sample.dll 中的函数的使用方法演示程序
; 用导入库的方式调用 dll 中的函数
;-------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff UseDll1.asm
; rc UseDll.rc
; Link /subsystem:windows UseDll1.obj UseDll.res
.386
.model flat,stdcall 
option casemap:none 

;include 文件定义
include 	c:/masm32/include/windows.inc 
include 	c:/masm32/include/user32.inc 
includelib 	c:/masm32/lib/user32.lib 
include 	c:/masm32/include/kernel32.inc 
includelib 	c:/masm32/lib/kernel32.lib 
include 	Sample.inc 
includelib 	Sample.lib 

;equ 等值定义
ICO_MAIN 	equ 1000
DLG_MAIN 	equ 1000 
IDC_COUNT 	equ 1001 
IDC_INC 	equ 1002 
IDC_DEC 	equ 1003 
IDC_NUM1 	equ 1004 
IDC_NUM2 	equ 1005 
IDC_MOD 	equ 1006
 
;代码段
.code 
_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam 
	mov eax, wMsg 
	.if eax == WM_CLOSE 
		invoke EndDialog, hWnd, NULL 
	.elseif eax == WM_COMMAND 
		mov eax, wParam 
		.if ax == IDC_INC 
			invoke _IncCounter 
			invoke SetDlgItemInt, hWnd, IDC_COUNT, eax, FALSE 
		.elseif ax == IDC_DEC 
			invoke _DecCounter 
			invoke SetDlgItemInt, hWnd, IDC_COUNT, eax, FALSE 
		.elseif ax == IDC_NUM1 || ax == IDC_NUM2 
			invoke GetDlgItemInt, hWnd, IDC_NUM1, NULL, FALSE 
			push eax 
			invoke GetDlgItemInt, hWnd, IDC_NUM2, NULL, FALSE 
			pop ecx 
			invoke _Mod, ecx, eax 
			invoke SetDlgItemInt, hWnd, IDC_MOD, eax, FALSE 
		.endif 
	.else 
		mov eax, FALSE 
		ret 
	.endif 
	mov eax, TRUE 
	ret 
_ProcDlgMain endp 
 
;main函数
main proc 
	invoke GetModuleHandle, NULL 
	invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, NULL 
	invoke ExitProcess, 0
main endp 
end main 
 

这段代码是再简单不过的了,读者只需要注意程序中用黑体标出的地方。

首先是程序开头的包含语句:

include      Sample.inc
includelib   Sample.lib

其次就是WM_COMMAND消息中调用_IncCounter、_DecCounter和_Mod函数的方法,无需多言,大家一眼就可以看出:这个DLL的使用方法和使用Windows提供的DLL时是一模一样的。

那么,这种方法有什么缺点呢?先来做几个实验。首先,将Sample.dll文件删除,再执行UseDll1.exe文件,这时对话框并没有被显示,系统弹出如图11.2所示的错误提示框。

                                                       图11.2 丢失Dll文件时的错误信息这

说明不管程序要用到哪几个dll文件,如果丢失任何一个dll文件的话(UseDll1.exe文件中还用到了Kernel32.dll文件和User32.dll文件,它们并没有丢失),可执行文件将无法被装入执行。

第二个实验:修改上一节中的Sample.asm,将入口函数中的返回值改为FALSE,也就是说模拟dll初始化失败的情况。修改完毕后重新编译链接生成新的Sample.dll文件,并将文件拷贝到UseDll1.exe所在目录后运行,系统将弹出如图11.3所示的错误提示。也就是说,任何一个dll文件因为初始化失败而无法装入时,可执行文件也是无法被装入执行的。

                                                 图11.3 Dll初始化失败时的错误信息

第三个实验:模拟软件升级或dll文件版本不对时的情况,这种情况在Windows系统中经常发生,因为当某些应用软件包被安装的时候,它可能会用自己附带的某个版本的dll文件替换掉Windows目录中已存在的dll文件,当程序卸载的时候,它有可能会根据备份恢复原来的版本,但更多的情况是根本没有恢复,经过多次安装和卸载不同的应用软件包后,最终的结果就是Windows系统目录中各个dll文件的版本参差不齐。不同版本的dll文件中可能增加了一些函数,也可能废弃了一些函数,有时其他使用这个dll文件的程序可能刚好用到不存在的函数,而这个函数在原来版本的dll文件中本来是存在的。

现在就修改程序来模拟这种情况,将Sample.def文件中的_DecCounter一行去掉,这样dll文件的导出表中就不会有这个函数,相当于函数不存在了,然后重新编译dll文件并将它拷贝到UseDll1.exe所在目录,执行UseDll1.exe,这时系统显示的是如图11.4所示的错误提示,UseDll1.exe程序仍然不能被正常装入执行。

                                                     图11.4 找不到导出函数的错误信息

现在读者一定明白这个最熟悉不过的错误信息的由来了,通常对付这种莫名其妙的错误的最好方法就是重新安装Windows,这将使所有dll文件的版本被重新安装为统一的版本,错误也就自然消失了。读者也可能会说:把出错的dll替换掉不就行了吗,为什么要整个重装呢?问题是你知道原来的版本应该是多少吗?

使用标准的方法调用动态链接库中的函数,在源代码被编译链接成exe文件时,链接器会根据导入库中的信息将使用的dll文件名和函数名存放在exe文件头的导入表中,这样Windows要执行文件的时候,会根据导入表中的dll列表寻找每个dll文件,并根据函数名在每个dll中寻找导出函数,如果这中间出现任何错误,如上面演示的dll文件丢失、dll文件初始化失败或dll中的函数名无法找到等情况,应用程序都无法被装载执行。

2.方法二:动态装入

方法一的优点就是使用方便,应用程序可以像使用自己内部的函数一样使用DLL中的函数,缺点也显而易见,如果装入DLL的过程中有任何错误,应用程序没有任何机会完成应变的措施,因为它根本没来得及被装入执行。

编程中有时候会有下面的需求:

        ● 程序需要使用系统中的保留函数。这个函数确实存在于动态链接库的导出表中,可以被其他程序引用,但是软件开发包提供的lib文件中并不包含它。

        ● 不同版本Windows中的函数集不同(如Windows NT中的很多与安全有关的函数在Windows 9x中不存在),同一版本Windows中不同版本dll文件的函数集也可能不同,程序需要根据函数是否存在做不同的处理。

        ● 程序使用的某些库并不重要(如仅用来显示程序版本的库),如果丢失这个库,程序希望能继续运行,而不是像上面演示的那样出现根本无法装入的情况。

对于这些需求,解决的办法就是不能将动态链接库的导入信息保存在可执行文件的导入表中,也就是说不要让Windows系统来做动态链接库的装入工作,这些工作由应用程序自己的代码来完成。有3个函数可以用来完成这样的功能:LoadLibrary(装入动态链接库),FreeLibrary(释放动态链接库)和GetProcAddress(获取导出函数地址)。

例子UseDll2.asm程序使用这种动态装入的方法来实现UseDll1程序同样的功能,来看看UseDll2.asm的内容:

; UseDll2.asm
; 自编 Sample.dll 中的函数的使用方法演示程序
; 用动态装入 dll 文件的方式调用 dll 中的函数
;--------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff UseDll2.asm
; rc UseDll.rc
; Link /subsystem:windows UseDll2.obj UseDll.res
.386
.model flat,stdcall 
option casemap:none 

;include 文件定义
include 	c:/masm32/include/windows.inc 
include 	c:/masm32/include/user32.inc 
includelib 	c:/masm32/lib/user32.lib 
include 	c:/masm32/include/kernel32.inc 
includelib 	c:/masm32/lib/kernel32.lib 
 
;equ 等值定义
ICO_MAIN 	equ 1000
DLG_MAIN 	equ 1000
IDC_COUNT 	equ 1001 
IDC_INC 	equ 1002 
IDC_DEC 	equ 1003 
IDC_NUM1 	equ 1004 
IDC_NUM2 	equ 1005 
IDC_MOD 	equ 1006 

_PROCVAR2 	typedef proto :dword, :dword 
_PROCVAR0	typedef proto 
PROCVAR2 	typedef ptr _PROCVAR2 
PROCVAR0 	typedef ptr _PROCVAR0 

;数据段
.data?
hDllInstance dword ?
lpIncCounter PROCVAR0 ?
lpDecCounter PROCVAR0 ?
lpMod PROCVAR2 ?

.const 
szError byte 'Sample.dll 文件丢失或装载失败,程序功能无法实现',0
szDll 	byte 'Sample.dll',0
szIncCounter byte '_IncCounter',0
szDecCounter byte '_DecCounter',0
szMod 		 byte '_Mod',0
 
;代码段
.code 
_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam 
	mov eax, wMsg 
	.if eax == WM_CLOSE 
		.if hDllInstance 
			xor eax, eax 
			mov lpIncCounter, eax 
			mov lpDecCounter, eax 
			mov lpMod, eax 
			invoke FreeLibrary, hDllInstance 
		.endif 
		invoke EndDialog, hWnd, NULL 
	.elseif eax == WM_INITDIALOG 
		invoke LoadLibrary, addr szDll 
		.if eax 
			mov hDllInstance, eax 
			invoke GetProcAddress, hDllInstance, addr szIncCounter 
			mov lpIncCounter, eax 
			invoke GetProcAddress, hDllInstance, addr szDecCounter 
			mov lpDecCounter, eax 
			invoke GetProcAddress, hDllInstance, addr szMod 
			mov lpMod, eax 
		.else 
			invoke MessageBox, hWnd, addr szError, NULL, MB_OK or MB_ICONWARNING 
			invoke GetDlgItem, hWnd, IDC_INC 
			invoke EnableWindow, eax, FALSE 
			invoke GetDlgItem, hWnd, IDC_DEC 
			invoke EnableWindow, eax, FALSE 
			invoke GetDlgItem, hWnd, IDC_NUM1 
			invoke EnableWindow, eax, FALSE 
			invoke GetDlgItem, hWnd, IDC_NUM2 
			invoke EnableWindow, eax, FALSE 
		.endif 
	.elseif eax == WM_COMMAND 
		mov eax, wParam 
		.if ax == IDC_INC 
			.if lpIncCounter 
				invoke lpIncCounter 
				invoke SetDlgItemInt, hWnd, IDC_COUNT, eax, FALSE 
			.endif 
		.elseif ax == IDC_DEC 
			.if lpDecCounter 
				invoke lpDecCounter 
				invoke SetDlgItemInt, hWnd, IDC_COUNT, eax, FALSE 
			.endif 
		.elseif ax == IDC_NUM1 || ax == IDC_NUM2 
			.if lpMod 
				invoke GetDlgItemInt, hWnd, IDC_NUM1, NULL, FALSE 
				push eax 
				invoke GetDlgItemInt, hWnd, IDC_NUM2, NULL, FALSE 
				pop ecx 
				invoke lpMod, ecx, eax 
				invoke SetDlgItemInt, hWnd, IDC_MOD, eax, FALSE 
			.endif 
		.endif 
	.else 
		mov eax, FALSE 
		ret 
	.endif 
	mov eax, TRUE 
	ret 
_ProcDlgMain endp 

;main函数
main proc 
	invoke GetModuleHandle, NULL 
	invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, NULL 
	invoke ExitProcess, 0 
main endp 
end main 

下面分析这个程序与UseDll1程序的不同点。

首先,程序的开始不再需要Sample.lib文件和Sample.inc文件,少了对应的include和includelib语句,因为并不需要链接器去定位函数的位置。

第二是 .const数据段中需要自己定义装入的库文件名和函数名:

szDll            db       'Sample.dll',0            ;装入的动态链接库名称
szIncCounter     db       '_IncCounter',0           ;装入的函数名
szDecCounter     db       '_DecCounter',0           ;装入的函数名
szMod            db       '_Mod',0                  ;装入的函数名

这些信息原来是由链接器根据lib文件的信息写在可执行文件头的导入表中的,既然现在由程序自己来装入库函数,那么这些信息也就需要自己定义了。

第三是在使用库中的函数之前需要使用LoadLibrary将库装入,并使用GetProcAddress函数得到函数的入口地址。程序中将这个步骤安排在对话框初始化消息WM_INITDIALOG中完成,读者也可以在使用函数前的任何地方完成。LoadLibrary函数的使用方法是:

invoke LoadLibrary, lpDllFileName
.if eax
    mov hDllInstance, eax
.endif

参数lpDllFileName指向需要装载的库文件名,库文件名是一个以NULL结尾的字符串。函数按下列顺序在不同目录中查找指定的库文件:当前目录、Windows系统目录和PATH环境变量列出的目录。如果这些目录中存在同名的库文件,那么先搜索到的库文件会被装入。

如果装载成功,函数返回库文件的实例句柄,装载失败则返回NULL。返回的实例句柄需要被保存起来,以后在获取库中的导出函数、装载库中的资源,以及释放库的操作中都要用到它。

对于方法一中库文件丢失和库的入口函数返回FALSE告诉Windows初始化失败的情况,LoadLibrary函数均返回NULL,这样程序就可以根据情况决定该怎么做,程序可以显示一个提示信息并退出,也可以不使用这个库文件而继续执行。UseDll2程序的处理方法是显示一个“Sample.dll文件丢失或装载失败,程序功能无法实现”提示信息,然后将对话框中的“增加”、“减少”两个按钮,以及输入的编辑框灰化后继续执行。这样,对话框可以正常显示出来,但是使用库函数的功能被屏蔽掉了。

如果装载动态链接库成功,下一步就是使用GetProcAddress函数来获取库中函数的地址。GetProcAddress函数的使用方法是:

invoke GetProcAddress,hDllInstance,lpProcName
.if eax
    mov lpProc,eax
.endif

hDllInstance参数就是LoadLibrary函数返回的动态链接库的实例句柄,lpProcName指向要获取的函数名称,函数名也是用以NULL结尾的字符串来定义。有些系统DLL中的函数名称并不是字符串,而是使用数值编号,对于这种情况,lpProcName参数可以指定为函数的编号数值(详见第17章的导出表部分)。

如果执行成功,返回值是要获取的函数的入口地址,程序可以保存它并在以后调用。如果执行失败,比如,因为版本变化等原因导致需要获取的函数不存在,这时函数返回NULL。

在不再需要动态链接库的时候,为了释放库所占用的系统资源,需要使用FreeLibrary函数释放它。FreeLibrary函数的用法是:

invoke FreeLibrary,hDllInstance

输入参数是LoadLibrary函数返回的实例句柄,函数导致系统以DLL_PROCESS_DETACH代码调用库的入口函数,这样库文件可以自己释放占用的一些资源,然后,整个库的代码和数据被从应用程序的地址空间中清除。

但是在一个应用程序中使用FreeLibrary函数并不会影响另一个应用程序使用同一个库文件,当库文件还在被另一个程序使用的时候,它还是在物理内存中存在,操作系统为每个库文件维护一个装入计数器,每次使用LoadLibrary装载库文件(或者使用第一种方法由Windows来装入一个库)的时候,计数器递增;每次使用FreeLibrary函数将库释放的时候,计数器递减,只有到计数器减到零,也就是库文件没有被任何程序使用的时候,操作系统才会将它从物理内存中真正释放掉,否则仅是从某个进程的地址空间中解除了内存映射关系而已。

方法二和方法一的最后一个不同点是调用函数的方法,在使用GetProcAddress函数获取了库中导出函数的入口点以后,程序在调用的时候一般使用将参数手工入栈的方法,如对_Mod函数的调用可以写为:

push          num2
push          num1
call          lpMod        ;lpMod保存有GetProcAddress获取的地址

这样写法的缺点是无法使用invoke伪指令来进行参数检验,容易引发错误。实际上还有一个变通的方法,可以将一个变量定义为子程序入口指针,并为它定义参数个数,方法是两次使用typedef伪操作:

_PROCVAR2         typedef proto :dword,:dword
PROCVAR2          typedef ptr _PROCVAR2

如上面的第一句将_PROCVAR2类型定义为使用两个参数的函数类型,第二句将PROCVAR2类型定义为_PROCVAR2类型的指针,这样,在数据段中就可以将保存函数入口地址的变量使用PROCVAR2类型来定义了,得到的好处就是可以用invoke语句来调用这个变量中的指针:

.data?
lpMod PROCVAR2 ?

有人曾询问笔者这样一个问题:如果既没有导入库,也没有资料,该如何使用DLL中的函数?答案是:函数名是没有问题的,通过一些工具查看导出表就可以得知库中所有的导出函数列表,但是有关调用函数使用参数的数量和参数的定义方法等资料就成问题了,唯一的办法就是通过反汇编或者跟踪来找出参数的数量和含义后再通过本节介绍的方法调用。

使用方法二时要注意:不管是使用LoadLibrary函数还是GetProcAddress函数,对返回值必须要检查,否则一旦失败的话,很容易引发调用NULL指针的错误。

11.1.4 动态链接库中的数据共享

当多个应用程序同时使用同一个动态链接库的时候,这些动态链接库在系统中是存在于不同进程的地址空间中的,它们代表“宿主”程序工作,互相之间没有任何联系。这一点可以通过一个简单的实验来演示:当我们多次运行UseDll1.exe(或UseDll2.exe),按动不同对话框中的“增加”或“减少”按钮的时候,每个对话框中的计数值按照自己的规律增减,不会受到其他对话框中计数值的影响。这就是说,虽然Sample.dll被多个进程同时装入,但是操作系统为它们映射了各不相同的数据段,使它们工作起来互不影响。

但是需要在进程间进行数据共享的时候,这种互相隔离的特征就不是我们所需要的了,当然,解决的方法之一就是使用第10章中介绍的内存映射文件,但是更简单的办法是通过构造特殊的动态链接库来实现。

再次以前面的Sample.dll为例,现在将它的计数器改成是全局的,也就是说运行UseDll1.exe的多个拷贝的时候,不同对话框增减的是同一个计数器。

回顾第2章对Link.exe程序的介绍,会发现链接器有一个/SECTION选项,可以将某个节区的属性自行定义,选项中有一个S属性,代表将节区的属性设置为共享,这就是我们需要的,实际上不必修改Sample.asm源程序,只需要把Makefile文件中的Link选项修改一下:

    DLL = Counter
    ML_FLAG = /c /coff
    LINK_FLAG = /subsystem:windows /Dll /section:.bss,S
    $(DLL).dll: $(DLL).obj $(DLL).def
        Link  $(LINK_FLAG) /Def:$(DLL).def $(DLL).obj
    .asm.obj:
        ml $(ML_FLAG) $<
    .rc.res:
        rc $<

未初始化数据段 .data?的节区名称为 .bbs,加上/section:.bss,S选项就可以将这个段的属性改为共享,这样,当DLL被不同应用程序装载的时候,不但映射到不同进程地址空间中的代码段来自同一段物理内存,.data?段的映射也来自同一段物理内存。

修改Makefile以后来验证一下,使用nmake /a将DLL重新编译并将Sample.dll文件拷贝到UseDll1程序所在目录,然后多次执行UseDll1程序以产生多个对话框,当在一个对话框中按下“增加”按钮将计数增加到x的时候,再换到另一个对话框中再按“增加”按钮,会发现出现的值是x+1而不是原来应该出现的1,表示这些对话框操作的是一个共享的计数器。

如果不希望全部的数据都共享,如hInstance等私有的数据,可以把这些数据放在初始化数据段 .data中,它的节区名称不同于 .data?段,在将 .data?段的属性修改为共享的时候并不会影响 .data段的属性。

11.1.5 在VC++中使用动态链接库

在Windows下,动态链接库是混合编程的一种好方法,由于大部分语言支持动态链接库技术,所以将代码封装成动态链接库就可以在很广的范围内使用,常用的如在C、C++或者VB中使用,不常用的如在Oracle数据库的存储过程中使用Dll中的函数,动态链接库远比在DOS使用静态库来进行混合编程要方便得多。

不同语言默认的调用约定和函数的命名方式是不同的,要想不同语言开发的动态链接库能够互相使用,链接库的开发语言和调用语言中的函数约定必须相同。语言对函数的约定有两种:调用约定和名字修饰约定。

调用约定决定了函数参数传送时入栈和出栈的顺序,以及堆栈平衡的方式(其细节在第3章的3.4.2节中就已经讲到了)。名字修饰约定指编译器在编译阶段如何定义函数的修饰名,各种编译器在编译函数时,会根据函数原型生成包含诸如函数名称、参数和返回值等信息的标识字符串,该字符串就称为函数的修饰名。

由于函数的修饰名仅在编译和链接阶段使用,所以该名称字符串存在于导入库(*.lib)文件中,而在最终生成的Dll文件中,导出的函数名和源文件中定义的函数名是一致的。

如果调用程序开发时和开发Dll使用的名字修饰约定不一致,那么在使用Dll对应的lib文件时,即使使用了正确的函数名,编译器仍然会报“找不到函数”错误,因为这时编译器在lib文件中找的是修饰名而不是函数名。

1.各种语言的名字修饰约定

Win32汇编语言使用的函数名字修饰是怎样的呢?我们可以从一个简单的例子看出来,首先观察下面的代码:

                      .586
                      .model flat,stdcall
                      option casemap:none
    Test0             proto
    Test1             proto   :dword
    Test2             proto   :dword,:dword
    TestC             proto   C :dword
    TestPascal        proto   PASCAL :dword
                      .code
    start:
                      invoke  Test0
                      invoke  Test1,1
                      invoke  Test2,1,2
                      invoke  TestC,1
                      invoke  TestPascal,1
                      ret
                  end         start

由于这段代码仅仅对函数进行了声明和调用,实际上并没有一个动态链接库来提供这些函数,所以链接的时候,链接器会报找不到外部函数符号的错误,从链接器报出的函数名就可以看出函数在lib文件中的修饰名应该是什么样子的:

    Test.obj : error LNK2001: unresolved external symbol _Test0@0
    Test.obj : error LNK2001: unresolved external symbol _Test1@4
    Test.obj : error LNK2001: unresolved external symbol _Test2@8
    Test.obj : error LNK2001: unresolved external symbol _TestC
    Test.obj : error LNK2001: unresolved external symbol TESTPASCAL
    Test.exe : fatal error LNK1120: 6 unresolved externals

Win32汇编和VC使用的名字修饰方式是一样的,如果用cl编译链接下面的C代码,得到的错误提示是相同的(VC++从5.0版开始取消了对PASCAL约定的支持,所以演示代码中去掉了TestPascal函数):

    (1)      /* Test.c ---- 用cl Test.c编译 */
    (2)      __stdcall Test0();
    (3)      __stdcall Test1(int p1);
    (4)      __stdcall Test2(int p1,int p2);
    (5)      // __stdcall Test2(int p1,int p2,int p3);
    (6)      TestC(int p1);
    (7)      main ()
    (8)      {
    (9)               Test0();
    (10)              Test1(1);
    (11)              Test2(1,2);
    (12)     //       Test2(1,2,3);
    (13)              TestC(1);
    (14)     }

在VC和Win32汇编中使用的名字修饰约定如下:stdcall调用约定在输出函数名前加上一个下划线前缀,后面加上一个“@”符号以及参数的字节数,格式为“_函数名@参数字节数”;C调用约定仅在输出函数名前加上一个下划线前缀,格式为“_函数名”。这可以从上面例子中链接器的输出信息中得到验证,另外,PASCAL调用除了将函数名转为大写外无其他修饰。

由于VC和Win32汇编使用的名字修饰约定是相同的,所以在VC中使用Win32汇编写的Dll函数非常方便,只要在头文件中加上类似于例子中_stdcall Test0()方式的声明,然后直接进行调用就可以了。反过来,在VC写的Dll中,只要将函数定义为stdcall方式,那么在Win32汇编中就可以和使用系统API同样的方法来调用这些Dll函数。

但是,VC++使用的名字修饰约定却完全不同,将上面的C例子代码中第5和12行的注释符号去掉,并将文件存盘,改名为Test.cpp,然后用命令cl Test.cpp进行编译,由于文件名有cpp后缀,VC将使用C++方式进行编译,这时得到的信息如下:

    unresolved external symbol "int __cdecl TestC(int)" (?TestC@@YAHH@Z)
    unresolved external symbol "int __stdcall Test2(int,int,int)" (?Test
2@@YGHHHH@Z)
    unresolved external symbol "int __stdcall Test2(int,int)" (?Test2@@YGHHH@Z)
    unresolved external symbol "int __stdcall Test1(int)"(?Test1@@YGHH@Z)
    unresolved external symbol "int __stdcall Test0(void)" (?Test0@@YGHXZ)

可以看到,C++编译器使用的函数名称修饰方式比较复杂,对于stdcall方式的约定,编译器在函数名前面加“?”作为开始,然后后面跟“@@YG”表示参数表开始,参数表以下列代号表示:X表示void,D表示char,E表示unsigned char,F表示short,H表示int,I表示unsigned int……参数表的第一项为该函数的返回值类型,其后依次为参数的数据类型,参数表后以“@Z”标识整个名字的结束,如果该函数无参数,则以“Z”标识结束。

如例子中的Test1函数的返回值是int型的,有一个int型的参数,那么?Test1@@YG后面的第一个H表示返回值,第二个H表示参数,然后以@Z结束;同理,Test0函数修饰名中的X表示没有参数(即void),这时修饰名以Z而不是@Z结束。

对于C方式,函数名称修饰方式同上面的stdcall方式,只是参数表的开始标识由上面的“@@YG”变为“@@YA”。这一点可以从TestC函数的修饰名可以看出。

C++使用这样复杂的名称修饰方式的好处是可以支持重载等特征,如例子中定义的两个Test2函数虽然函数名是相同的,但是由于参数不同,它们在C++内部的修饰名是不同的,这样编译器就可以从调用参数中区分究竟要去调用哪个函数。坏处就是C++的名称修饰方式无法和其他语言兼容,造成用C++修饰方式命名的Dll函数无法被其他语言所使用。

幸亏C++中还提供了extern "C"关键字,在函数名和函数声明前加上extern "C"关键字后,C++对该函数强制使用标准C的函数名称修饰方式,所以,在Dll中用这种方式输出的函数可以被其他语言使用;反过来,其他语言编写的Dll函数在C++的头文件中声明时,前面也必须加上extern "C"关键字,这样C++才会在lib文件中找到正确的函数修饰名。

2.书写供C语言使用的头文件

从上一段的介绍中可以总结出,在写C语言头文件时,如果头文件供C语言使用,那么在函数声明前加__stdcall关键字,如果供C++语言使用,那么函数声明前要加extern "C"__stdcall关键字。

为了使头文件可以同时在C和C++中使用,可以利用C编译器的内部变量__cplusplus进行条件编译,当使用C++方式编译时,该变量将被设置成TRUE,否则被设置成FALSE,以前面Sample.dll例子中输出的函数为例,改进后的头文件如下:

/*******************************************************************
; Sample.h 
; Sample.dll 导出函数:
;
; 1、_IncCounter()
;	增加 Dll 内部计数器的值(最大增加到10)并返回计数值
; 2、_DecCounter()
;	减少 Dll 内部计数器的值(最小减少到0)并返回计数值
; 3、_Mod(unsigned num1,unsigned num2)
;	num1 和 num2 为两个整数
;	输出:两个输入数之模 num1 % num2
;********************************************************************/

#ifdef __cplusplus
extern "C" {
#endif

__stdcall _IncCounter();
__stdcall _DecCounter();
__stdcall _Mod(unsigned num1,unsigned num2);

#ifdef __cplusplus
}
#endif

这样,在C++中使用时,函数声明前就会自动被加上extern "C"关键字。

完整的在VC++中使用Sample.dll的代码例子见光盘的Chapter11\Dll\VC++ Sample目录,读者可以尝试将例子中的Mod.cpp文件改名为Mod.c并重新进行编译,就可以发现Sample.h和Sample.dll文件仍然可以正常使用。

【学习笔记代码】

/********************************************************************
; Mod.cpp  ---------- 自编 Sample.dll 中的函数的使用方法演示程序
; 使用 nmake 或下列命令进行编译和链接:
; cl Mod.cpp Sample.lib
;*******************************************************************/
#include "stdio.h"
#include "Sample.h"
int main(int argc, char* argv[])
{
	unsigned num1, num2;
	if(3 == argc)
	{
		sscanf(argv[1], "%u", &num1);
		sscanf(argv[2], "%u", &num2);
		printf("%u %% %u = %u\n", num1, num2, _Mod(num1, num2));
	}
	else 
	{
		printf("Usage: Mod num1 num2\n");
	}
	return 0;
}

VC6.0环境编译运行,首先添加Sample.dll动态库

设置输入参数:

编译运行:

11.2 Windows钩子

11.2.1 什么是Windows钩子

1.Windows钩子简介

在DOS操作系统下编程的时候,如果想截获某种系统功能,可以采取截获中断的办法。比如,要获取击键的动作可以截获9号中断,要获取应用程序对文件操作功能的调用可以截获21h号DOS中断,由于DOS是单任务系统,所以这些操作几乎全部是内存驻留程序做的。DOS下截获中断的方法是这样的简单和随意,不管在驱动程序层次还是在应用程序层次都可以完成,以至于到最后截获操作被病毒泛滥成灾地使用。

在Windows下就不同了,我们已经知道保护模式下的中断描述符表是受系统保护的,在应用程序层次不可能再通过修改中断向量来截获系统中断了,但这样也对一些应用造成了不便,作为一种变通的措施,Windows提供了钩子来完成类似的功能。那么,钩子是什么呢?Win32 API手册中是这样描述的:

“A hook is a point in the Microsoft Windows message-handling mechanism where an application can install a subroutine to monitor the message traffic in the system and process certain types of messages before they reach the target window procedure.”

翻译过来就是:“钩子是Windows的消息处理机制中的一个监视点,应用程序可以在这里安装一个监视子程序,这样就可以在系统中的消息流到达目的窗口过程前监控它们。”

也就是说,钩子可以用来截获系统中的消息流,显然,钩子不是像截获中断一样用来随心所欲地截获系统底层功能的,那么钩子能够用来做什么事情呢?(我仿佛听到了一些阴险的笑声……)不用笑得这么阴险嘛!大家想得没错,如果把钩子用在后台执行的程序中,就能够偷偷检查任何程序中发生的WM_CHAR消息,这样用户输入的任何内容:账号、密码、情书——不管是什么,不管是否显示在屏幕上——都可以被记录下来。事实上,很多木马程序就是这样做的,像冰河一类的木马程序就可以在后台记录用户的击键并偷偷发送到人家的信箱中去。

2.钩子的类型

钩子是Windows消息机制中的监视点,应用程序可以在这里安装一个监视函数,这样就可以捕捉自己进程或者其他进程发生的事件。通过SetWindowsHookEx函数就可以做到这一点。SetWindowsHookEx函数定义了监视函数的位置和监视消息的类型,这样,每当发生我们感兴趣的消息时,Windows就会将消息发送给监视函数,监视函数是一个处理消息的回调函数,也称为“钩子函数”。

Windows安装的钩子有两种类型:局部的和远程的。它们处理消息的范围不同。局部钩子仅钩挂属于自身进程的事件;远程钩子除了可以钩挂自身进程的事件,还可以钩挂其他进程中发生的事件。远程钩子又分两种:基于线程的和系统范围的。基于线程的远程钩子用来捕获其他进程中某一特定线程的事件;而系统范围的远程钩子将捕捉系统中所有进程中发生的事件消息。

安装钩子会影响系统的性能,因为系统在处理所有的相关事件时都会调用钩子函数,特别是监视范围是整个系统范围的全局钩子。如果钩子函数中的处理代码过多的话,系统运行速度将会明显减慢,所以对于全局钩子一定要小心使用,不需要的时候应该立刻卸载。在DOS操作系统下编写中断服务程序的时候,如果代码有错误的话会影响其他调用它的程序。同样道理,由于钩子函数在其他进程的消息处理流程中插了一腿,所以一旦钩子函数存在问题的话,也会影响其他进程的运行。

可以把钩子想像成钓鱼钩,不同鱼钩用来钓的鱼是不同的,大钩钓大鱼,小钩钓小鱼,不同钩子钓的消息也是不同的,没有必要每次钓来所有的消息,根据监视的消息类型和时机的不同,钩子可以分为如表11.1所示的几种。

                                                                表11.1 钩子的类型

在这些钩子中,有些只能当做局部钩子使用,如WH_MSGFILTER钩子;有些只能当做系统范围的远程钩子使用,如WH_JOURNALRECORD和WH_JOURNALPLAYBACK钩子;而大多数的钩子可以在任何范围内使用。

对于不同的钩子,由于它们处理的消息类型不同,所以钩子函数的参数定义也是不同的,在具体的编程中,需要查看Win32 API手册来了解各种钩子函数的参数定义。

另外,远程钩子和局部钩子的程序结构也是不同的。当安装了一个局部钩子时,每当指定的事件发生,Windows就可以调用进程中的钩子函数;但是若安装的是远程钩子,系统不能从其他进程的地址空间中调用钩子函数,因为两个进程的地址空间是隔离的,又由于系统中只有DLL程序是可以插入到其他进程的地址空间中去的,所以远程钩子的钩子函数必须位于一个动态链接库中,而且必须是共享数据段的动态链接库(因为写远程钩子要用到动态链接库,所以本书中将两部分内容合在一章中介绍)。

但是也有两个例外:日志记录钩子和日志回放钩子虽然属于远程钩子,但是它们的钩子函数却可以放在安装钩子的程序中,并不需要单独放在一个动态链接库中。Microsoft并没有说明为什么有这样的例外,笔者认为其中的原因是这两个钩子是用来监控比较底层的硬件事件的,所以钩子函数的调用并不是从其他进程的地址空间中发起的,而是从Windows内部发起的,所以不存在不同进程之间地址空间隔离的问题(猜想而已,如果读者有明确的资料请告知笔者)。

下面的11.2.2节以键盘钩子为例来说明系统范围远程钩子的安装和使用,局部钩子的使用步骤与之类似,只不过不必将钩子函数放在动态链接库中而已,使用起来更加简单,读者可以举一反三自己尝试一下。11.2.3节中演示日志钩子的使用方法。

11.2.2 远程钩子的安装和使用

1.钩子程序的结构

钩子程序一般包括3个功能模块:

(1)主程序——用来实现界面或者其他功能。

(2)钩子回调函数——用来接收系统发过来的消息。

(3)钩子的安装和卸载模块。

对于局部钩子来说,这些模块可以处在同一个可执行文件中。而对于远程钩子来说,第2部分必须放在一个动态链接库中,第3部分虽然没有要求,但一般也放在动态链接库中,这是因为钩子创建以后得到一个钩子句柄,这个句柄要在钩子回调函数中,以及卸载钩子的时候用到,如果把这部分代码放在主程序中的话,还需要创建一个函数将它传回给动态链接库,所以还不如直接放到库中。

所附光盘的Chapter11\KeyHook目录中的例子采用的就是这样的结构。目录中包括两部分文件:HookDll.asm和HookDll.def文件用来生成动态链接库;Main.asm和Main.rc是主程序部分。程序用一个系统范围的远程钩子来实现监视所有键盘输入的功能。由于安装钩子回调函数的动态链接库要求是共享数据段的,所以请读者注意Makefile中dll文件的链接选项,它使用了/section:.bss,S选项。Makefile文件的内容如下:

NAME = Main
DLL = Hookdll
ML_FLAG = /c /coff
LINK_FLAG = /subsystem:windows
DLL_LINK_FLAG = /subsystem:windows /section:.bss,S
$(DLL).dll $(NAME).exe:
$(DLL).dll: $(DLL).obj $(DLL).def
      Link  $(DLL_LINK_FLAG) /Def:$(DLL).def /Dll $(DLL).obj
$(NAME).exe: $(NAME).obj $(NAME).res
    Link  $(LINK_FLAG) $(NAME).obj $(NAME).res
.asm.obj:
                ml $(ML_FLAG) $<
.rc.res:
                rc $<
clean:
                del *.obj
                del *.res
                del *.exp
                del *.lib

HookDll.asm文件的内容如下:

;Hookdll.asm    键盘钩子使用的 dll 程序用来放置钩子过程
;--------------------------------------------------------------------
;使用 nmake 或下列命令进行编译和链接:
;ml /c /coff Hookdll.asm
;Link  /subsystem:windows /section:.bss,S /Def:Hookdll.def /Dll Hookdll.obj
.386
.model flat,stdcall 
option casemap:none 
;include 文件定义
include 	c:/masm32/include/windows.inc 
include 	c:/masm32/include/user32.inc 
includelib 	c:/masm32/lib/user32.lib 
include 	c:/masm32/include/kernel32.inc 
includelib 	c:/masm32/lib/kernel32.lib 

.data
hInstance 	dword ?
;需要共享的变量
.data?
hWnd 		dword ?
hHook		dword ?
dwMessage 	dword ?
szAscii 	byte 4 dup(?)

.code 
;dll 的入口函数
DllEntry proc _hInstance, _dwReason, _dwReserved 
	push _hInstance 
	pop hInstance 
	mov eax, TRUE 
	ret 
DllEntry endp 

;键盘钩子回调函数
HookProc proc _dwCode, _wParam, _lParam 
	local @szKeyState[256]:byte 
	
	invoke CallNextHookEx, hHook, _dwCode, _wParam, _lParam 
	invoke GetKeyboardState, addr @szKeyState 
	invoke GetKeyState, VK_SHIFT 
	mov @szKeyState + VK_SHIFT, al 
	mov ecx, _lParam 
	shr ecx, 16
	invoke ToAscii, _wParam, ecx, addr @szKeyState, addr szAscii, 0 
	mov byte ptr szAscii[eax], 0
	invoke SendMessage, hWnd, dwMessage, dword ptr szAscii, NULL 
	xor eax, eax 
	ret 
HookProc endp 

;安装钩子
InstallHook proc _hWnd, _dwMessage 
	push _hWnd 
	pop hWnd 
	push _dwMessage 
	pop dwMessage 
	invoke SetWindowsHookEx, WH_KEYBOARD, addr HookProc, hInstance, NULL 
	mov hHook, eax 
	ret 
InstallHook endp 

;卸载钩子
UninstallHook proc 
	invoke UnhookWindowsHookEx, hHook 
	ret 
UninstallHook endp 

end DllEntry 

需要共享的变量被放在 .data? 段中,如钩子句柄和钩住的按键内容等,仅dll程序的实例句柄不需要共享,不需要共享的变量放在 .data段中。动态链接库的入口函数例行公事地返回了一个TRUE来表示允许被装入。程序中只写了3个函数,HookProc是钩子回调函数,InstallHook和UninstallHook函数是供主程序使用的钩子安装函数和卸载函数。由于这3个函数是需要导出的,所以HookDll.def文件中包括了它们的名称:

EXPORTS HookProc
         InstallHook
         UninstallHook

InstallHook子程序用来安装钩子,程序为它设计了两个参数:窗口句柄和自定义消息ID。动态链接库保存这两个参数,以便在钩子回调函数收到消息的时候将截获的按键通过自定义消息ID转发给父窗口,这样父窗口在初始化完成后只需要等待自定义消息ID就可以了。

在子程序中,通过SetWindowsHookEx函数安装钩子。SetWindowsHookEx函数的用法是:

invoke SetWindowsHookEx,idHook,lpHookProc,hInstance,dwThreadID
.if eax
    mov hHook,eax
.endif

idHook参数指定钩子的类型,它就是表11.1中列出的钩子名称。由于例子中要安装的是键盘钩子,所以使用WH_KEYBOARD。lpHookProc参数指出钩子回调函数的地址

hInstance指定钩子回调函数所在DLL的实例句柄。如果安装的是局部钩子的话,由于局部钩子的回调函数并不需要放在动态链接库中,这时这个参数就使用NULL。

dwThreadID是安装钩子后想监控的线程的ID号。该参数可以决定钩子是局部的还是系统范围的。如果参数指定的是自己进程中的某个线程ID号,那么该钩子是一个局部钩子;如果指定的线程ID是另一个进程中某个线程的ID,那么安装的钩子是一个局部的远程钩子;如果想要安装系统范围的全局钩子的话,可以将这个参数指定为NULL,这样钩子就会被解释成系统范围的,可以用来监控所有的进程及它们的线程(与进程、线程ID相关的内容请参考第12章和第13章)。

如果钩子安装成功,函数返回钩子句柄,否则返回NULL。钩子句柄必须被保存下来,因为在回调函数和卸载钩子的时候还要用到这个句柄。

动态链接库导出的另一个函数是UninstallHook,用来供主程序卸载钩子。程序在这里使用UnhookWindowsHookEx函数卸载钩子,这个函数的输入参数只有一个,就是安装钩子时候得到的钩子句柄。

现在来看主程序。Main.rc文件的内容如下:

  //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
  #include     <resource.h>
  //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
  #define      ICO_MAIN           1000
  #define      DLG_MAIN           1000
  #define      IDC_TEXT           1001
  //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
  ICO_MAIN     ICON               "Main.ico"
  //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
  DLG_MAIN DIALOG 208, 130, 234, 167
  STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
  CAPTION "键盘钩子"
  FONT 9, "宋体"
  {
  EDITTEXT IDC_TEXT, 5, 5, 224, 158, ES_MULTILINE | ES_AUTOVSCROLL
        | WS_BORDER | WS_VSCROLL | WS_TABSTOP | ES_READONLY
    }
    //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

资源脚本文件中的定义很简单,仅定义了一个对话框,对话框中有个多行的编辑控件,用来显示“钩住”的按键。Main.asm的内容如下:

;Main.asm --------- 键盘钩子演示程序的主程序,调用 dll 装载键盘钩子
;--------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff Main.asm
; rc Main.rc
; Link  /subsystem:windows Main.obj Main.res
.386
.model flat,stdcall 
option casemap:none 

;include 文件定义
include 	c:/masm32/include/windows.inc 
include		c:/masm32/include/user32.inc 
includelib 	c:/masm32/lib/user32.lib 
include 	c:/masm32/include/kernel32.inc 
includelib 	c:/masm32/lib/kernel32.lib 
include 	Hookdll.inc 
includelib 	Hookdll.lib 

;equ 等值定义
ICO_MAIN 	equ 1000
DLG_MAIN 	equ 1000 
IDC_TEXT 	equ 1001 
WM_HOOK 	equ WM_USER + 100h 

;代码段
.code 
_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam 
	local @dwTemp 
	
	mov eax, wMsg 
	.if eax == WM_CLOSE 
		invoke UninstallHook 
		invoke EndDialog, hWnd, NULL 
	.elseif eax == WM_INITDIALOG 
		invoke InstallHook, hWnd, WM_HOOK 
		.if !eax 
			invoke EndDialog, hWnd, NULL 
		.endif 
	.elseif eax == WM_HOOK 
		mov eax, wParam 
		.if al == 0dh 
			mov eax, 0a0dh 
		.endif 
		mov @dwTemp, eax 
		invoke SendDlgItemMessage, hWnd, IDC_TEXT, EM_REPLACESEL, 0, addr @dwTemp 
	.else 
		mov eax, FALSE 
		ret 
	.endif 
	mov eax, TRUE 
	ret 
_ProcDlgMain endp 

;main函数
main proc 
	invoke GetModuleHandle, NULL 
	invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, NULL 
	invoke ExitProcess, 0
main endp 
end main 

运行结果(win10环境):

为了使用动态链接库中的导出函数InstallHook和UninstallHook,在程序的开头需要用include语句和includelib语句将动态链接库的函数声明和导入库包含进来。

在对话框初始化的消息WM_INITDIALOG中,程序调用InstallHook函数安装钩子,输入的参数是主窗口句柄和自定义的消息ID:WM_HOOK(Windows系统中ID值在WM_USER以后的值都可以由用户使用,在这里将WM_HOOK定义为WM_USER+100h),这样每当钩子回调函数得到按键消息的时候,就可以通过这个消息ID通知主窗口。接下来程序对返回值进行检查,如果返回值表示失败则直接退出程序。在关闭对话框的WM_CLOSE消息中,程序调用UninstallHook函数卸载钩子。

在平时,主程序等待自定义消息WM_HOOK,并将传递过来的按键字符串通过发送EM_REPLACESEL消息添加到编辑框中,在添加之前先检测按键是否为回车键,如果是,再人为地插入一个换行符(0ah),以便将编辑框中的内容换行显示。

2.钩子回调函数

现在回过头来看HookDll.asm程序中的钩子回调函数,回调函数的写法一般如下:

HookProc proc dwCode,wParam,lParam
    invoke CallNextHookEx,hHook,_dwCode,_wParam,_lParam
    ;处理消息的代码
    mov  eax,返回值
    ret
HookProc endp

各种类型钩子的回调函数的参数都是这样3个,但是它们的定义各不相同,就像窗口过程在收到各种不同消息的时候,wParam和lParam的定义各不相同一样,不同类型的钩子回调函数的返回值定义也是各不相同的。

对于键盘钩子来说,参数的定义如下所示。

● dwCode——键盘消息的处理方式。如果是HC_ACTION,表示收到一个正常的击键消息;如果是HC_NOREMOVE,表示对应消息并没有从消息队列中移去(当某个进程用指定PM_NOREMOVE标志的PeekMessage函数获取消息时就是如此)。

● wParam——按键的虚拟码(即Windows.inc中定义的VK_xxx值)。

● lParam——按键的重复次数、扫描码和标志等数据,不同数据位的定义如下:

■ 位0~15:按键的重复次数。

■ 位16~23:按键的扫描码。

■ 位24:按键是否是扩展键(F1与F2等Fx键,小键盘数字键等),如果此位是1表示按键是扩展键。

■ 位25~28:未定义。

■ 位29:如果Alt键在按下状态,此位置1,否则置0。

■ 位30:按键的原先状态,消息发送前按键原来是按下的,此位被设置为1,否则置0。

■ 位31:按键的当前动作,如果是按键按下,那么此位被设置为0;按键释放的话被设置为1。

对于每个击键动作,钩子回调函数会在键按下和释放的时候被调用两次,只需根据lParam的位31中的标志来记录一次,否则得到的是重复信息。

另外,回调函数收到的参数是以按键的扫描码和虚拟码表示的,在送给主窗口前需要将它转换成我们认识的ASCII码,但虚拟码或扫描码和ASCII码之间的对应关系并没有规律,必须进行查表操作才能转换。如果在程序中自己转换的话,需要一个键码对应表和查表程序。

Windows中现成的函数ToAscii可以完成这个功能并自动辨认按键的按下或释放动作。代码如下。

HookProc proc _dwCode,_wParam,_lParam
    local   @szKeyState[256]:byte
    invoke  CallNextHookEx,hHook,_dwCode,_wParam,_lParam
    invoke  GetKeyboardState,addr @szKeyState
    invoke  GetKeyState,VK_SHIFT
    mov      @szKeyState + VK_SHIFT,al
    mov      ecx,_lParam
    shr      ecx,16
    invoke  ToAscii,_wParam,ecx,addr@szKeyState,addr szAscii,0
    mov      byte ptr szAscii [eax],0
    invoke  SendMessage,hWnd,dwMessage,dword ptr szAscii,NULL
    ...

ToAscii函数的用法是:

invoke ToAscii,dwVirtKey,uScanCode,lpKeyState,lpBuffer,uFlags

dwVirtKey参数指定按键的虚拟码,在使用时直接用钩子回调函数的wParam参数就可以了,uScanCode指定按键的扫描码,并用位15来表示是按键按下还是按键释放,与回调函数的lParam参数对比可以看出,lParam参数的高16位就是需要的数据,所以程序将lParam右移16位后用做uScanCode参数。

lpKeyState指向一个256字节的缓冲区,其中存放键盘中所有按键的当前状态,一个字节表示一个按键,数值为1表示按下,为0表示释放,数据在缓冲区中的排列位置按照VK_xx虚拟码的顺序排列。这是为了让函数得知键盘上各种控制键的状态(如Shift,Alt和Ctrl等),因为这些键是否按下对转换结果是有影响的,比如,同样是按键“1”,如果Shift键不按下,对应的就是“1”,按下的话函数必须返回“!”才是正确的结果。当然不可能自己去填写这个缓存区,使用GetKeyboardState函数就可以让系统根据当前的键盘状态填写这个缓冲区。

lpBuffer指向一个缓存区,用来接收转换后的ASCII码,最后的uFlags参数表示当前是否有一个菜单在激活状态,0表示没有,1表示有菜单正在激活。

函数的返回值表示转换后返回在lpBuffer缓冲区中的字符数量,它可能是0(如按键放开时不产生字符)、1或者是2,下面的语句根据返回字符数将缓冲区中的字符尾部加上一个NULL:

mov byte ptr szAscii [eax],0

对于Shift等控制键来说,GetKeyboardState函数返回的状态是区分左、右键的(分别对应VK_LSHIFT和VK_RSHIFT),而ToAscii函数检测的是VK_SHIFT,不对Shift键进行处理的话,转换结果可能是错误的,所以程序使用GetKeyState函数单独获取VK_SHIFT的状态并手工修改缓冲区中VK_SHIFT位置的状态。

转换完成后,用SendMessage函数将转换后的按键内容传递给主窗口,就大功告成了!不过要注意的还有两点:如果钩取的不是键盘消息而是其他窗口消息,在这里就应该使用PostMessage而不是SendMessage函数,否则可能造成死循环;其次不要向主窗口传递地址,因为钩子DLL被插入到其他进程的地址空间中运行,所以将地址传回去可能是无效的。

不同类型钩子回调函数返回值的定义是不同的。对于键盘钩子,返回0表示允许Windows将消息转发给目标窗口过程,返回非0值表示让Windows将消息丢弃,这样钩子函数可以检测到按键动作,目标程序却无法收到键盘消息,相当于所有的按键都失效了。

需要再次提醒的是:不同类型钩子的回调函数的参数,以及返回值的含义都是不同的,上面的例子是键盘钩子的情况,在使用其他类型钩子的时候请参考手册中对参数和返回值的说明。曾经有读者将上面键盘钩子的例子几乎原封不动地套到其他钩子的使用中,结果总是无法调试成功,其实原因就在于此。

3.钩子链

Windows系统中可以同时存在多个同类型的钩子,多个程序同时安装同一种钩子的时候就会出现这种情况,这些钩子组成一个钩子链,最近加入的钩子放在链表的头部,Windows负责为每种钩子维护一个钩子链。当一个事件发生的时候,Windows调用最后安装的钩子,然后由当前钩子的回调函数发起调用下一个钩子的动作,Windows收到这个动作后,再从链表中取出下一个钩子的地址并将调用传递下去。

在大多数的情况下,一个钩子回调函数最好把消息事件传递下去以便其他的钩子都有获得处理这一消息的机会。调用下一个钩子函数是CallNextHookEx,该函数的用法是:

invoke CallNextHookEx,hHook,dwCode,wParam,lParam

hHook参数是当前钩子的句柄,dwCode,wParam和lParam参数就是当前钩子收到的参数,这个函数让Windows调用钩子链中的下一个钩子。如果调用成功,函数的返回值是下一个钩子回调函数返回的数值。

11.2.3 日志记录钩子

日志记录钩子是一种特殊的钩子,说它特殊是因为它是远程钩子,却不用放在动态链接库中,这就为监视系统范围的消息提供了方便。本节中尝试用日志记录钩子的办法来实现键盘监视的功能,源代码包括在所附光盘的Chapter11\RecHook目录中,包括汇编源文件RecHook.asm和资源脚本文件RecHook.rc,其中RecHook.rc文件的内容和上一个例子的Main.rc文件是一样的。

RecHook.asm文件的内容如下:

; RecHook.asm   -----    使用日志钩子实现键盘消息勾挂的例子
;------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff RecHook.asm
; rc RecHook.rc
; Link  /subsystem:windows RecHook.obj RecHook.res
.386
.model flat,stdcall 
option casemap:none 

;include 文件定义
include 	c:/masm32/include/windows.inc 
include 	c:/masm32/include/user32.inc 
includelib 	c:/masm32/lib/user32.lib 
include 	c:/masm32/include/kernel32.inc 
includelib 	c:/masm32/lib/kernel32.lib 

; equ 等值定义
ICO_MAIN 	equ 1000
DLG_MAIN 	equ 1000
IDC_TEXT 	equ 1001 
WM_HOOK 	equ WM_USER + 100h 

; 数据段
.data?
hInstance 	dword ?
hWinMain 	dword ?
hHook 		dword ?
szAscii 	byte 32 dup(?)

; 代码段
.code 
; 钩子回调函数
HookProc proc _dwCode, _wParam, _lParam 
	local @szKeyState[256]:byte 
	
	invoke CallNextHookEx, hHook, _dwCode, _wParam, _lParam 
	pushad 
	.if _dwCode == HC_ACTION 
		mov ebx, _lParam 
		assume ebx:ptr EVENTMSG 
		.if [ebx].message == WM_KEYDOWN 
			invoke GetKeyboardState, addr @szKeyState 
			invoke GetKeyState, VK_SHIFT
			mov @szKeyState + VK_SHIFT, al 
			mov ecx, [ebx].paramH 
			shr ecx, 16 
			invoke ToAscii, [ebx].paramL, ecx, addr @szKeyState, addr szAscii, 0 
			mov byte ptr szAscii [eax], 0 
			.if szAscii == 0dh 
				mov word ptr szAscii+1, 0ah 
			.endif 
			invoke SendDlgItemMessage, hWinMain, IDC_TEXT, EM_REPLACESEL, 0, addr szAscii 
		.endif 
		assume ebx:nothing 
	.endif 
	popad 
	xor eax, eax 
	ret 
HookProc endp 

_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam 
	mov eax, wMsg 
	.if eax == WM_CLOSE 
		invoke UnhookWindowsHookEx, hHook 
		invoke EndDialog, hWnd, NULL 
	.elseif eax == WM_INITDIALOG 
		push hWnd 
		pop hWinMain 
		invoke SetWindowsHookEx, WH_JOURNALRECORD, addr HookProc, hInstance, NULL 
		.if eax 
			mov hHook, eax 
		.else 
			invoke EndDialog, hWnd, NULL 
		.endif 
	.else 
		mov eax, FALSE 
		ret 
	.endif 
	mov eax, TRUE 
	ret 
_ProcDlgMain endp 

;main函数
main proc  
	invoke GetModuleHandle, NULL 
	mov hInstance, eax 
	invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, NULL 
	invoke ExitProcess, 0 
main endp 
end main 

运行结果(winXP系统):

由于不再需要动态链接库了,钩子回调函数HookProc被移到了主程序中,也取消了InstallHook和UninstallHook两个子程序,相应的内容直接放在WM_INITDIALOG和WM_CLOSE消息中完成。在WM_INITDIALOG消息中用下面的语句完成对钩子的安装:

invoke SetWindowsHookEx,WH_JOURNALRECORD,addr HookProc,hInstance,NULL

参数WH_JOURNALRECORD表示安装的钩子是日志记录钩子。由于钩子回调函数也写在主程序中,所以没有必要再通过自定义的WM_HOOK消息来通信,在回调函数中使用ToAscii函数将监测到的按键扫描码转换成ASCII码字符串以后,程序直接发送EM_REPLACESEL消息将它添加到编辑框中。读者可以和前面一个例子对比一下这些细节上的不同。

程序比较重要的一个不同点在于日志钩子回调函数的参数定义不同,在这里dwCode的参数定义如下:

● HC_ACTION——系统准备从消息队列中移去一条消息,消息的具体信息由lParam参数中指定的EVENTMSG结构定义。

● HC_SYSMODALOFF——某个系统模态对话框准备被关闭。

● HC_SYSMODALON——某个系统模态对话框准备被建立。

我们关心的是HC_ACTION标志,当发生HC_ACTION标志的消息时,lParam参数指向一个EVENTMSG结构,其定义为:

EVENTMSG STRUCT
  message     DWORD      ?      ;消息队列中将要移去的消息ID
  paramL      DWORD      ?      ;消息的wParam参数
  paramH      DWORD      ?      ;消息的lParam参数
  time        DWORD      ?      ;消息发生的事件
  hwnd        DWORD      ?      ;消息对应的窗口句柄
EVENTMSG ENDS

由于日志记录钩子可以截获的不仅是键盘消息,也有鼠标等其他消息,所以需要有个地方指定消息类型,通过检测EVENTMSG结构中的消息ID字段就可以得知截获的究竟是什么消息。如果关心的是按键消息的话,那么发现消息ID为WM_KEYDOWN时进行处理就可以了,同理,如果关心的是鼠标消息的话,使用日志记录钩子也可以完成鼠标钩子完成的工作。例子程序中的相关代码如下:

.if _dwCode == HC_ACTION
    mov      ebx,_lParam
    assume   ebx:ptr EVENTMSG
    .if      [ebx].message == WM_KEYDOWN
             ;处理按键消息
    .endif
    assume   ebx:nothing
.endif

日志记录钩子回调函数的返回值没有被定义。所以不管返回什么值对消息的传递都没有影响。


网站公告

今日签到

点亮在社区的每一天
去签到