後一頁
前一頁
回目錄
回首頁
第十章 動態鍊結庫程式設計(一)

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.EXEUSER.EXEGDI.EXE,…),各種驅動程式文件,各種帶有.Fon.Fot 檔案附加名的字形資源文件等。Windows還提供了針對某一功能的專用DLLs,如進行DDE程式設計的ddeml.dll,進行程式裝設的ver.dll等。

  雖然在編寫Windows程式時必然要涉及到DLLs,但利用Delphi ,用戶在大部分時候並不會注意到這一點。這一方面是因為Delphi提供了豐富的函數使用戶不必直接去使用Windows API;另一方面即使使用Windows API,由於DelphiAPI函數和其它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(代碼編輯器)中。

  一般工程文件的格式為: 

  program   工程標題;

  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,並加入適當的庫單元(一般SysUtilsClasses是需要的),刪去begin...end之間的所有代碼。

  2.以適當的檔案標簽保持文件,此時library後跟的庫名自動修改;

  3.輸入過程、函數代碼。如果過程、函數準備供其它應用程式呼叫,則在過程、函數頭後加上export 編譯指示;

  4.建立exports子句,包含供其它應用程式呼叫的函數和過程名。可以利用標準指示 name Indexresident以方便和加速過程/函數的呼叫;

  5.輸入庫初始化代碼。這一步是可選的;

  6.編譯程式,產生動態鍊結庫文件。 

10.2.1.2 動態鍊結庫中的標準指示 

  在動態鍊結庫的輸出部分,用到了三個標準指示:nameIndexresident

  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的任何執行時間庫過程/函數也都不作這種假定。需注意的是如果讀者想嵌入群群組合語言代碼,絕不要使SSDS登入同一個值。 

10.2.1.4 DLLs中的執行時間錯和處理 

  由於DLLs無法控制應用程式的執行,導致很難進行異常處理,因此編寫DLLs時要十分小心,以確保被呼叫時能正常執行 。當DLLs中發生一個執行時間錯時,相應DLLs並不一定從記憶體中移去(因為此時其它應用程式可能正在用它),而呼叫DLLs的程式異常中止。這樣造成的問題是當DLLs已被修改,重新進行呼叫時,記憶體中保留的仍然可能是以前的版本,修改後的程式並沒有得到驗證。對於這個問題,有以下兩種解決方法:

  1.在程式的異常處理部分顯式將DLL卸出記憶體;

  2.完全結束Windows,而後重新啟動,執行相應的程式。

  同一般的應用程式相比,DLL中執行時間錯的處理是很困難的,而造成的後果也更為嚴重。因此要求程式設計者在編寫代碼時要有充分、周到的考慮。 

10.2.1.5 庫初始化代碼的編寫 

  傳統Windows中動態鍊結庫的編寫,需要兩個標準函數:LibMainWEP,用於啟動和關閉DLL。在LibMain中,可以執行開鎖DLL數據段、分配記憶體、初始化變數等初始化工作;而WEP在從記憶體中移去DLLs前被呼叫,一般用於進行必要的清理工作,如釋放記憶體等。Delphi用自己特有的方式實現了這兩個標準函數的功能。這就是在工程文件中的begin...end部分加入初始化代碼。和傳統Windows程式設計方法相比,它的主要特色是:

  1.初始化代碼是可選的。一些必要的工作(如開鎖數據段)可以由系統自動完成。所以大部分情況下用戶不會涉及到;

  2.可以設定多個結束過程,結束時按順序依次被呼叫;

  3.LibMainWEP對用戶透明,由系統自動呼叫。

  初始化代碼完成的主要工作是:

  1.初始化變數、分配全局記憶體塊、登入視窗物件等初始化工作。在(10.3.2)節“利用DLLs實現應用程式間的數據傳輸”中,用於數據共享的全局記憶體塊就是在初始化代碼中分配的。

  2.設定DLLs結束時的執行過程。Delphi有一個預定義變數ExitProc用於指向結束過程的位址。用戶可以把自己的過程名賦給ExitProc。系統自動呼叫WEP函數,把ExitProc指向的位址依次賦給WEP執行,直到ExitProcnil

  下邊的一段程式包含一個結束過程和一段初始化代碼,用來敘述如何正確設定結束過程。 

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函數LoadLibrayGetProcAddress可以實現在執行時間裏動態裝載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函數主要有三個,即:LoadlibraryGetProcAddressFreelibrary

   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顯式釋放記憶體。

 


後一頁
前一頁
回目錄
回首頁