DELPHI基础教程
10.1 Windows 的动态链接库原理 动态链接库 (DLLs) 是从 C 语言函数库和 Pascal 库单元的概念发展而来的。所有的 C 语言标准库函数都存放在某一函数库中,同时用户也可以用 LIB 程序创建自己的函数库。在链接应用程序的过程中,链接器从库文件中拷贝程序调用的函数代码,并把这些函数代码添加到可执行文件中。这种方法同只把函数储存在已编译的 .OBJ 文件中相比更有利于代码的重用。 但随着 Windows 这样的多任务环境的出现,函数库的方法显得过于累赘。如果为了完成屏幕输出、消息处理、内存管理、对话框等操作,每个程序都不得不拥有自己的函数,那么 Windows 程序将变得非常庞大。 Windows 的发展要求允许同时运行的几个程序共享一组函数的单一拷贝。动态链接库就是在这种情况下出现的。动态链接库不用重复编译或链接,一旦装入内存, Dlls 函数可以被系统中的任何正在运行的应用程序软件所使用,而不必再将 DLLs 函数的另一拷贝装入内存。 10.1.1 动态链接库的工作原理 “动态链接”这几字指明了 DLLs 是如何工作的。对于常规的函数库,链接器从中拷贝它需要的所有库函数,并把确切的函数地址传送给调用这些函数的程序。而对于 DLLs ,函数储存在一个独立的动态链接库文件中。在创建 Windows 程序时,链接过程并不把 DLLs 文件链接到程序上。直到程序运行并调用一个 DLLs 中的函数时,该程序才要求这个函数的地址。此时 Windows 才在 DLLs 中寻找被调用函数,并把它的地址传送给调用程序。采用这种方法, DLLs 达到了复用代码的极限。 动态链接库的另一个方便之处是对动态链接库中函数的修改可以自动传播到所有调用它的程序中,而不必对程序作任何改动或处理。 DLLs 不仅提供了函数重用的机制,而且提供了数据共享的机制。任何应用程序都可以共享由装入内存的 DLLs 管理的内存资源块。只包含共享数据的 DLLs 称为资源文件。如 Windows 的字体文件等。 10.1.2 Windows 系统的动态链接库 Windows 本身就是由大量的动态链接库支持的。这包括 Windows API 函数 ( KRNLx86.EXE , USER.EXE , GDI.EXE ,… ) ,各种驱动程序文件,各种带有 .Fon 和 .Fot 扩展名的字体资源文件等。 Windows 还提供了针对某一功能的专用 DLLs ,如进行 DDE 编程的 ddeml.dll ,进行程序安装的 ver.dll 等。 虽然在编写 Windows 程序时必然要涉及到 DLLs ,但利用 Delphi ,用户在大部分时候并不会注意到这一点。这一方面是因为 Delphi 提供了丰富的函数使用户不必直接去使用 Windows API; 另一方面即使使用 Windows API ,由于 Delphi 把 API 函数和其它 Windows DLLs 函数重新组织到了几个库单元中,因而也不必使用特殊的调用格式。所以本章的重点放在编写和调用用户自定义的 DLLs 上。 使用传统的 Windows 编程方法来创建和使用一个 DLLs 是一件很令人头痛的事,正如传统的 Windows 编程方法本身就令人生畏一样。用户需要对定义文件、工程文件进行一系列的修改以适应创建和使用 DLLs 的需要。 Delphi 的出现,在这一方面,正如在其它许多方面所做的那样,减轻了开发者的负担。更令人兴奋的是 Delphi 利用 DLLs 实现了窗体的重用机制。用户可以将自己设计好的窗体储存在一个 DLLs 中,在需要的时候可随时调用它。 10.2 DLLs 的编写和调用 10.2.1 DLLs 的编写 在 Delphi 环境中,编写一个 DLLs 同编写一个一般的应用程序并没有太大的区别。事实上作为 DLLs 主体的 DLL 函数的编写,除了在内存、资源的管理上有所不同外,并不需要其它特别的手段。真正的区别在工程文件上。 在绝大多数情况下,用户几乎意识不到工程文件的存在,因为它一般不显示在屏幕上。如果想查看工程文件,则可以打开 View 菜单选择 Project Source 项,此时工程文件的代码就会出现在屏幕的 Code Editor( 代码编辑器 ) 中。 一般工程文件的格式为: 工程标题 ; uses 子句 ; 程序体 而 DLLs 工程文件的格式为: library 工程标题 ; uses 子句 ; exprots 子句 ; 程序体 它们主要的区别有两点: 1. 一般工程文件的头标用 program 关键字,而 DLLs 工程文件头标用 library 关键字。不同的关键字通知编译器生成不同的可执行文件。用 program 关键字生成的是 .exe 文件,而用 library 关键字生成的是 .dll 文件; 2.假如DLLs要输出供其它应用程序使用的函数或过程,则必须将这些函数或过程列在exports子句中。而这些函数或过程本身必须用export编译指令进行编译。 根据 DLLs 完成的功能,我们把 DLLs 分为如下的三类: 1. 完成一般功能的 DLLs ; 2. 用于数据交换的 DLLs ; 3. 用于窗体重用的 DLLs 。 这一节我们只讨论完成一般功能的 DLLs ,其它内容将在后边的两节中讨论。 10.2.1.1 编写一般 DLLs 的步骤 编写一般 DLLs 的步骤如下: 1. 利用 Delphi 的应用程序模板,建立一个 DLLs 程序框架。 对于 Delphi 1.0 的用户,由于没有 DLLs 模板,因此: (1). 建立一个一般的应用程序,并打开工程文件; (2). 移去窗体和相应的代码单元; (3). 在工程文件中,把 program 改成 library ,移去 Uses 子句中的 Forms ,并添加适当的库单元(一般 SysUtils 、 Classes 是需要的),删去 begin...end 之间的所有代码。 2. 以适当的文件名保持文件,此时 library 后跟的库名自动修改; 3. 输入过程、函数代码。如果过程、函数准备供其它应用程序调用,则在过程、函数头后加上 export 编译指示; 4. 建立 exports 子句,包含供其它应用程序调用的函数和过程名。可以利用标准指示 name 、 Index 、 resident 以方便和加速过程 / 函数的调用; 5. 输入库初始化代码。这一步是可选的; 6. 编译程序,生成动态链接库文件。 10.2.1.2 动态链接库中的标准指示 在动态链接库的输出部分,用到了三个标准指示: name 、 Index 、 resident 。 1.name name 后面接一个字符串常量,作为该过程或函数的输出名。如: exports InStr name MyInstr; 其它应用程序将用新名字 (MyInstr) 调用该过程或函数。如果仍利用原来的名字 (InStr) ,则在程序执行到引用点时会引发一个系统错误。 2.Index Index 指示为过程或函数分配一个顺序号。如果不使用 Index 指示,则由编译器按顺序进行分配。 Index 后所接数字的范围为 1...32767 。使用 Index 可以加速调用过程。 3.resident 使用 resident ,则当 DLLs 装入时特定的输出信息始终保持在内存中。这样当其它应用程序调用该过程时,可以比利用名字扫描 DLL 入口降低时间开销。 对于那些其它应用程序常常要调用的过程或函数,使用 resident 指示是合适的。例如: exports InStr name MyInStr resident; 10.2.1.3 DLLs 中的变量和段 一个 DLLs 拥有自己的数据段 (DS) ,因而它声明的任何变量都为自己所私有。调用它的模块不能直接使用它定义的变量。要使用必须通过过程或函数界面才能完成。而对 DLLs 来说,它永远都没有机会使用调用它的模块中声明的变量。 一个 DLLs 没有自己的堆栈段 (SS) ,它使用调用它的应用程序的堆栈。因此在 DLL 中的过程、函数绝对不要假定 DS = SS 。一些语言在小模式编译下有这种假设,但使用 Delphi 可以避免这种情况。 Delphi 绝不会产生假定 DS = SS 的代码, Delphi 的任何运行时间库过程 / 函数也都不作这种假定。需注意的是如果读者想嵌入汇编语言代码,绝不要使 SS 和 DS 登录同一个值。 10.2.1.4 DLLs 中的运行时间错和处理 由于 DLLs 无法控制应用程序的运行,导致很难进行异常处理,因此编写 DLLs 时要十分小心,以确保被调用时能正常执行 。当 DLLs 中发生一个运行时间错时,相应 DLLs 并不一定从内存中移去(因为此时其它应用程序可能正在用它),而调用 DLLs 的程序异常中止。这样造成的问题是当 DLLs 已被修改,重新进行调用时,内存中保留的仍然可能是以前的版本,修改后的程序并没有得到验证。对于这个问题,有以下两种解决方法: 1. 在程序的异常处理部分显式将 DLL 卸出内存; 2. 完全退出 Windows ,而后重新启动,运行相应的程序。 同一般的应用程序相比, DLL 中运行时间错的处理是很困难的,而造成的后果也更为严重。因此要求程序设计者在编写代码时要有充分、周到的考虑。 10.2.1.5 库初始化代码的编写 传统 Windows 中动态链接库的编写,需要两个标准函数: LibMain 和 WEP ,用于启动和关闭 DLL 。在 LibMain 中,可以执行开锁 DLL 数据段、分配内存、初始化变量等初始化工作;而 WEP 在从内存中移去 DLLs 前被调用,一般用于进行必要的清理工作,如释放内存等。 Delphi 用自己特有的方式实现了这两个标准函数的功能。这就是在工程文件中的 begin...end 部分添加初始化代码。和传统 Windows 编程方法相比,它的主要特色是: 1. 初始化代码是可选的。一些必要的工作(如开锁数据段)可以由系统自动完成。所以大部分情况下用户不会涉及到; 2. 可以设置多个退出过程,退出时按顺序依次被调用; 3.LibMain 和 WEP 对用户透明,由系统自动调用。 初始化代码完成的主要工作是: 1. 初始化变量、分配全局内存块、登录窗口对象等初始化工作。在 (10.3.2) 节“利用 DLLs 实现应用程序间的数据传输”中,用于数据共享的全局内存块就是在初始化代码中分配的。 2. 设置 DLLs 退出时的执行过程。 Delphi 有一个预定义变量 ExitProc 用于指向退出过程的地址。用户可以把自己的过程名赋给 ExitProc 。系统自动调用 WEP 函数,把 ExitProc 指向的地址依次赋给 WEP 执行,直到 ExitProc 为 nil 。 下边的一段程序包含一个退出过程和一段初始化代码,用来说明如何正确设置退出过程。 library Test; {$S-} uses WinTypes, WinProcs; var SaveExit: Pointer; procedure LibExit; far; begin if ExitCode = wep_System_Exit then begin { 系统关闭时的相应处理 } end else begin { DLL 卸出时的相应处理 } end; ExitProc := SaveExit; { 恢复原来的退出过程指针 } end; begin {DLL 的初始化工作 } SaveExit := ExitProc; { 保存原来的退出过程指针 } ExitProc := @LibExit; { 安装新的退出过程 } end. 在初始化代码中,首先把原来的退出过程指针保存到一个变量中,而后再把新的退出过程地址赋给 ExitProc 。而在自定义退出过程 LibExit 结束时再把 ExitProc 的值恢复。由于 ExitProc 是一个系统全局变量,所以在结束时恢复原来的退出过程是必要的。 退出过程 LibExit 中使用了一个系统定义变量 ExitCode ,用于标志退出时的状态。 ExitCode 的取值与意义如下: 表 10.1 ExitCode 的取值与意义 ━━━━━━━━━━━━━━━━━━━━━ 取 值 意 义 ————————————————————— WEP_System_Exit Windows 关闭 WEP_Free_DLLx DLLs 被卸出 ━━━━━━━━━━━━━━━━━━━━━ 退出过程编译时必须关闭 stack_checking ,因而需设置编译指示 {$S-} 。 10.2.1.6 编写一般 DLLs 的应用举例 在下面的程序中我们把一个字符串操作的函数储存到一个 DLLs 中,以便需要的时候调用它。应该注意的一点是:为了保证这个函数可以被其它语言编写的程序所调用,作为参数传递的字符串应该是无结束符的字符数组类型 ( 即 PChar 类型 ) ,而不是 Object Pascal 的带结束符的 Srting 类型。程序清单如下: library Example; uses SysUtils, Classes; { 返回字符在字符串中的位置} function InStr(SourceStr: PChar;Ch: Char): Integer; export; var Len,i: Integer; begin Len := strlen(SourceStr); for i := 0 to Len-1 do if SourceStr[i] = ch then begin Result := i; Exit; end; Result := -1; end; exports Instr Index 1 name 'MyInStr' resident; begin end. 10.2.2 调用 DLLs 有两种方法可用于调用一个储存在 DLLs 中的过程。 1. 静态调用或显示装载 使用一个外部声明子句,使 DLLs 在应用程序开始执行前即被装入。例如: function Instr(SourceStr : PChar;Check : Char); Integer; far; external 'UseStr'; 使用这种方法,程序无法在运行时间里决定 DLLs 的调用。假如一个特定的 DLLs 在运行时无法使用,则应用程序将无法执行。 2. 动态调用或隐式装载 使用 Windows API 函数 LoadLibray 和 GetProcAddress 可以实现在运行时间里动态装载 DLLs 并调用其中的过程。 若程序只在其中的一部分调用 DLLs 的过程,或者程序使用哪个 DLLs , 调用其中的哪个过程需要根据程序运行的实际状态来判断,那么使用动态调用就是一个很好的选择。 使用动态调用,即使装载一个 DLLs 失败了,程序仍能继续运行。 10.2.3 静态调用 在静态调用一个 DLLs 中的过程或函数时, external 指示增加到过程或函数的声明语句中。被调用的过程或函数必须采用远调用模式。这可以使用 far 过程指示或一个{ $F +} 编译指示。 Delphi 全部支持传统 Windows 动态链接库编程中的三种调用方式,它们是: ● 通过过程 / 函数名 ● 通过过程 / 函数的别名 ● 通过过程 / 函数的顺序号 通过过程或函数的别名调用,给用户编程提供了灵活性,而通过顺序号 (Index) 调用可以提高相应 DLL 的装载速度。 10.2.4 动态调用 10.2.4.1 动态调用中的 API 函数 动态调用中使用的 Windows API 函数主要有三个 , 即: Loadlibrary , GetProcAddress 和 Freelibrary 。 1.Loadlibrary: 把指定库模块装入内存 语法为: function Loadlibrary(LibFileName: PChar): THandle; LibFileName 指定了要装载 DLLs 的文件名,如果 LibFileName 没有包含一个路径,则 Windows 按下述顺序进行查找: (1) 当前目录; (2)Windows 目录 ( 包含 win.com 的目录 ) 。函数 GetWindowDirectory 返回这一目录的路径; (3)Windows 系统目录 ( 包含系统文件如 gdi.exe 的目录 ) 。函数 GetSystemDirectory 返回这一目录的路径; (4) 包含当前任务可执行文件的目录。利用函数 GetModuleFileName 可以返回这一目录的路径; (5) 列在 PATH 环境变量中的目录; (6) 网络的映象目录列表。 如果函数执行成功,则返回装载库模块的实例句柄。否则,返回一个小于 HINSTANCE_ERROR 的错误代码。错误代码的意义如下表: 表 10.2 Loadlibrary 返回错误代码的意义 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 错误代码 意 义 —————————————————————————————————————— 0 系统内存不够,可执行文件被破坏或调用非法 2 文件没有被发现 3 路径没有被发现 5 企图动态链接一个任务或者有一个共享或网络保护错 6 库需要为每个任务建立分离的数据段 8 没有足够的内存启动应用程序 10 Windows 版本不正确 11 可执行文件非法。或者不是 Windows 应用程序,或者在 .EXE 映 像中有错误 12 应用程序为一个不同的操作系统设计 ( 如 OS/2 程序 ) 13 应用程序为 MS DOS4.0 设计 14 可执行文件的类型不知道 15 试图装载一个实模式应用程序 ( 为早期 Windows 版本设计 ) 16 试图装载包含可写的多个数据段的可执行文件的第二个实例 19 试图装载一个压缩的可执行文件。文件必须被解压后才能被装裁 20 动态链接库文件非法 21 应用程序需要 32 位扩展 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 假如在应用程序用 Loadlibrary 调用某一模块前,其它应用程序已把该模块装入内存,则 Loadlibrary 并不会装载该模块的另一实例,而是使该模块的“引用计数”加 1 。 2.GetProcAddress :捡取给定模块中函数的地址 语法为: function GetProcAddress(Module: THandle; ProcName: PChar): TFarProc; Module 包含被调用的函数库模块的句柄,这个值由 Loadlibrary 返回。如果把 Module 设置为 nil ,则表示要引用当前模块。 ProcName 是指向含有函数名的以 nil 结尾的字符串的指针,或者也可以是函数的次序值。如果 ProcName 参数是次序值,则如果该次序值的函数在模块中并不存在时, GetProcAddress 仍返回一个非 nil 的值。这将引起混乱。因此大部分情况下用函数名是一种更好的选择。如果用函数名,则函数名的拼写必须与动态链接库文件 EXPORTS 节中的对应拼写相一致。 如果 GetProcAddress 执行成功,则返回模块中函数入口处的地址,否则返回 nil 。 3.Freelibrary :从内存中移出库模块 语法为: procedure Freelibrary(Module : THandle); Module 为库模块的句柄。这个值由 Loadlibrary 返回。 由于库模块在内存中只装载一次,因而调用 Freelibrary 首先使库模块的引用计数减一。如果引用计数减为 0 ,则卸出该模块。 每调用一次 Loadlibrary 就应调用一次 FreeLibray ,以保证不会有多余的库模块在应用程序结束后仍留在内存中。 10.2.4.2 动态调用举例 对于动态调用,我们举了如下的一个简单例子。系统一共包含两个编辑框。在第一个编辑框中输入一个字符串,而后在第二个编辑框中输入字符。如果该字符包含在第一个编辑框的字符串中,则标签框显示信息:“位于第 n 位。”,否则显示信息:“不包含这个字符。”。如图是程序的运行界面。 输入检查功能的实现在Edit2的OnKeyPress事件处理过程中,程序清单如下。 procedure TForm1.Edit2KeyPress(Sender: TObject; var Key: Char); var order: Integer; txt: PChar; PFunc: TFarProc; Moudle: THandle; begin Moudle := Loadlibrary('c:\dlls\example.dll'); if Moudle > 32 then begin Edit2.text := ''; Pfunc := GetProcAddress(Moudle,'Instr'); txt := StrAlloc(80); txt := StrPCopy(txt,Edit1.text); Order := TInstr(PFunc)(txt,Key); if Order = -1 then Label1.Caption := '不包含这个字符 ' else Label1.Caption := '位于第'+IntToStr(Order+1)+'位'; end; Freelibrary(Moudle); end; 在利用GetProcAddess返回的函数指针时,必须进行强制类型转换: Order := TInstr(PFunc)(text,Key); TInStr是一个定义好了的函数类型: type TInStr = function(Source: PChar;Check: Char): Integer; 10.3 利用DLLs实现数据传输 10.3.1 DLLs中的全局内存 Windows规定:DLLs并不拥有它打开的任何文件或它分配的任何全局内存块。这些对象由直接或间接调用DLLs的应用程序拥有。这样,当应用程序中止时,它拥有的打开的文件自动关闭,它拥有的全局内存块自动释放。这就意味着保存在DLLs全局变量中的文件和全局内存块变量在DLLs没有被通知的情况下就变为非法。这将给其它使用该DLLs的应用程序造成困难。 为了避免出现这种情况,文件和全局内存块句柄不应作为DLLs的全局变量,而是作为DLLs中过程或函数的参数传递给DLLs使用。调用DLLs的应用程序应该负责对它们的维护。 但在特定情况下,DLLs也可以拥有自己的全局内存块。这些内存块必须用gmem_DDEShare属性进行分配。这样的内存块直到被DLLs显示释放或DLLs退出时都保持有效。 由DLLs管理的全局内存块是应用程序间进行数据传输的又一途径,下面我们将专门讨论这一问题。 10.3.2 利用DLLs实现应用程序间的数据传输 利用DLLs实现应用程序间的数据传输的步骤为: 1. 编写一个DLLs程序,其中拥有一个用gmem_DDEShare属性分配的全局内存块; 2. 服务器程序调用DLLs,向全局内存块写入数据; 3. 客户程序调用DLLs,从全局内存块读取数据。 10.3.2.1 用于实现数据传输的DLLs的编写 用于实现数据传输的DLLs与一般DLLs的编写基本相同,其中特别的地方是: 1. 定义一个全局变量句柄: var hMem: THandle; 2. 定义一个过程,返回该全局变量的句柄。该过程要包含在exports子句中。如: function GetGlobalMem: THandle; export; begin Result := hMem; end; 3. 在初始化代码中分配全局内存块: 程序清单如下: begin hMem := GlobalAlloc(gmem_MOVEABLE and gmem_DDEShare,num); if hMem = 0 then MessageDlg('Could not allocate memory',mtWarning,[mbOK],0); end. num是一个预定义的常数。 Windows API函数GlobalAlloc用于从全局内存堆中分配一块内存,并返回该内存块的句柄。该函数包括两个参数,第一个参数用于设置内存块的分配标志。可以使用的分配标志如下表所示。 表10.3 全局内存块的分配标志 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 标 志 意 义 ————————————————————————————————— gmem_DDEShare 分配可由应用程序共享的内存 gmem_Discardable 分配可抛弃的内存(只与gmem_Moveable连用) gmem_Fixed 分配固定内存 gmem_Moveable 分配可移动的内存 gmem_Nocompact 该全局堆中的内存不能被压缩或抛弃 gmem_Nodiscard 该全局堆中的内存不能被抛弃 gmem_NOT_Banked 分配不能被分段的内存 gmem_Notify 通知功能。当该内存被抛弃时调用GlobalNotify函数 gmem_Zeroinit 将所分配内存块的内容初始化为零 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 有两个预定义的常用组合是: GHND = gmem_Moveable and gmem_Zeroinit GPTK = gmem_Fixed and gmem_Zeroinit 第二个参数用于设置欲分配的字节数。分配的字节数必须是32的倍数,因而实际分配的字节数可能比所设置的要大。 由于用gmem_DDEShare分配的内存在分配内存的模块终止时自动抛弃,因而不必调用GlobalFree显式释放内存。
|