Delphi 開發中,效能最佳化是至關重要的課題。除了演算法最佳化、記憶體管理和程式碼微調之外,善用外部函式庫也能大幅提升程式效能。然而,連結外部函式庫,特別是 C/C++ 函式庫,並非總是順利無阻。本文將探討 Delphi 連結外部函式庫的技術細節,並提供實務上的解決方案。理解物件檔案格式 (OMF、COFF、ELF) 的差異以及處理「未滿足的外部宣告」錯誤,是成功連結外部函式庫的關鍵。透過 FastMath 函式庫的案例,我們可以看到如何針對不同平台 (x86、x64、ARM) 選擇最佳化的實作。而 Abbrevia 函式庫的案例則示範瞭如何解決連結過程中缺失函式或變數的問題,例如透過 tdump 工具分析物件檔案、自行實作缺失函式,或是連結必要的系統函式庫 (例如 System.Win.Crtl)。

使用外部函式庫

在這本文中,我們已經看到了許多提高程式速度的方法。我們已經改變了演算法、減少了記憶體分配的次數、對程式碼進行了微調、使程式的部分平行化等等。然而,有時所有這些方法都不夠。也許問題太難了,我們就放棄了,或者也許問題出在Delphi;更具體地說,在編譯器上。

在這種情況下,我們將會在本地電腦或公司以外的地方,在網際網路上尋找解決方案。也許有人已經解決了這個問題,並為它創造了一個包裝良好的解決方案。大多數時候,它們並不是專門為Delphi設計的。

事實上,Delphi程式設計師(儘管我們有數百萬人)仍然是C、Java和Python世界中的少數派。要找到一個針對困難問題的C或C++解決方案,比找到Delphi的解決方案要簡單得多。然而,這並不是放棄的理由。只要經過一些努力,總是可以將這樣的解決方案用於Delphi程式中,即使它是為C或C++使用者預先包裝好的。

在本章中,我們將回答以下問題:

  • 在您的專案中,可以使用哪些型別的C和C++檔案?
  • 最常用的物件檔案格式有哪些?
  • 哪些物件格式可以在Delphi專案中使用?
  • 當您嘗試將物件檔案連結到Delphi時,會遇到什麼陷阱?
  • 如何在Delphi專案中使用C++函式庫?

技術需求


Second-Edition/tree/main/ch11.

與物件檔案連結

在探討與C和C++介面的複雜世界之前,我將介紹一個簡單的例子——一個用Delphi編寫的函式庫。使用它的動機並不是來自Mr. Smith編寫的糟糕演算法,而是來自表現不佳的64位元編譯器。這不是我無憑無據地聲稱。多個Delphi程式設計師已經指出,64位元Windows編譯器(dcc64)生成的浮點數程式碼比C編譯器從等效原始碼生成的浮點數程式碼慢2-3倍。

當您已經探索了所有提高程式速度的標準方法,而編譯器是問題的唯一來源時,您無能為力。您只能用組合語言重寫程式的部分,或者使用比原生程式碼執行得更快的外部函式庫。這樣的函式庫要麼會使用大量的組合語言程式碼,要麼(大多數時候)包含一堆積用最佳化C編譯器編譯的物件檔案。

有時,這樣的外部函式庫只會滿足您的基本需求。例如,FastMath函式庫是用Delphi編寫的(https://github.com/neslib/FastMath),它包含了對向量和矩陣進行操作的浮點數函式。其他的,例如Intel的Math Kernel Library(https://software.intel.com/en-us/mkl),還增加了更複雜的數學演算法,例如快速傅立葉變換。

FastMath範例

為了簡要介紹,讓我們來看看FastMath以及它如何實作一個Radians函式,該函式將四個浮點數的向量(TVector4)從度轉換為弧度。為了您的方便,本章程式碼存檔中包含了FastMath儲存函式庫的副本,位於FastMath-master資料夾中。

作為後備,函式庫在Neslib.FastMath.Pascal.inc檔案中實作了一個純Pascal Radians函式。如下所示的簡單實作,只是對單精確度引數呼叫四次Radians函式:

function Radians(const ADegrees: Single): Single;
begin
  Result := ADegrees * (Pi / 180);
end;

function Radians(const ADegrees: TVector4): TVector4;
begin
  Result.Init(Radians(ADegrees.X), Radians(ADegrees.Y),
              Radians(ADegrees.Z), Radians(ADegrees.W));
end;

使用SSE2組合語言實作

Neslib.FastMath.Sse2_32.inc單元中的不同實作(如下所示)使用更快的Intel SSE2組合語言指令實作了相同的方法。Neslib.FastMath.Sse2_64.inc中的類別似程式碼(未在書中顯示,但可在程式碼存檔中的FastMath資料夾中找到)對64位元Intel目標執行相同的操作:

function Radians(const ADegrees: TVector4): TVector4;
assembler;
asm
  movups xmm0, [ADegrees]
  movups xmm1, [SSE_PI_OVER_180]
  mulps xmm0, xmm1
  movups [Result], xmm0
end;

ARM平台實作

對於ARM平台(Android和iOS),相同的程式碼在trig_32.S(和trig_64.S,用於64位元平台)中實作。以下是顯示32位元實作的該檔案片段:

_radians_vector4: // (const ADegrees: TVector4;
// out Result: TVector4);
adr r2, PI_OVER_180
vld1.32 {q0}, [r0]
vld1.32 {q1}, [r2]
vmul.f32 q0, q0, q1
vst1.32 {q0}, [r1]
bx lr

編譯與連結

fastmath-android和fastmath-ios資料夾中的批次檔使用Google和Apple提供的原生開發工具,將組合語言原始檔編譯成物件函式庫libfastmath-android.a和libfastmath-ios.a。然後,函式在Neslib.FastMath.Internal.inc單元中使用一種我們將在本章後續探討的機制連結到函式庫。

一系列簡單的$IF/$INCLUDE陳述式然後將這些包含檔案連結到一個單元Neslib.FastMath.pas中:

{$IF Defined(FM_PASCAL)}
// Pascal implementations
{$INCLUDE 'Neslib.FastMath.Pascal.inc'}
{$ELSEIF Defined(FM_X86)}
// 32-bit SSE2 implementations
{$INCLUDE 'Neslib.FastMath.Sse2_32.inc'}
{$ELSEIF Defined(FM_X64)}
// 64-bit SSE2 implementations
{$IF Defined(MACOS64)}
{$INCLUDE 'Neslib.FastMath.Sse2_64.MacOS.inc'}
{$ELSE}
{$INCLUDE 'Neslib.FastMath.Sse2_64.inc'}
{$ENDIF}
{$ELSEIF Defined(FM_ARM)}
// Arm NEON/Arm64 implementations
{$INCLUDE 'Neslib.FastMath.Arm.inc'}
{$ENDIF}

這種設計允許函式庫為您的Delphi應用程式提供統一的應用程式介面,使用每個平台的最佳實作。

物件檔案格式

物件檔案有多種格式,並非所有格式都對與Delphi的連結有用。您將遇到的三種最常見的檔案格式是OMF、COFF和ELF:

  • 可重定位物件模組格式(OMF)是由Intel開發的一種格式。它曾經在DOS時代被廣泛使用。支援連結OMF物件檔案的功能已經包含在Turbo Pascal DOS編譯器中,並且仍然存在於現代Delphi中。 此格式可以與Delphi的Win32編譯器連結。它由C++Builder的Win32編譯器(bcc32)生成。

要分析OMF檔案,您可以使用tdump實用工具(Delphi和C++Builder標準安裝的一部分),並使用-d開關。例如,tdump -d object_file.obj將輸出有關OMF檔案的大量資訊。最好使用命令列重定向功能將此輸出重定向到檔案中。例如,您可以輸入tdump -d object_file.obj > object_file.txt,將輸出重定向到object_file.txt。

一旦您有了該檔案,請搜尋標籤為PUBDEF或PUBD32的條目,以找到匯出函式的名稱。您也可以使用命令列引數-oiPUBDEF(或-oiPUBD32)來限制輸出到PUBDEF(PUBD32)記錄。

圖表翻譯:OMF、COFF和ELF格式比較

@startuml
skinparam backgroundColor #FEFEFE
skinparam defaultTextAlignment center
skinparam rectangleBackgroundColor #F5F5F5
skinparam rectangleBorderColor #333333
skinparam arrowColor #333333

title 圖表翻譯:OMF、COFF和ELF格式比較

rectangle "支援" as node1
rectangle "不支援" as node2

node1 --> node2

@enduml

圖表翻譯: 上圖展示了OMF、COFF和ELF三種物件檔案格式與不同Delphi編譯器的相容性。OMF格式支援Win32編譯器,但不支援Win64編譯器。COFF格式支援Win64編譯器,但不支援Win32編譯器。ELF格式則主要支援Linux編譯器。

內容解密:

此圖表展示了不同的物件檔案格式與Delphi編譯器之間的相容性。其中,OMF格式適用於舊版Win32編譯器,而COFF格式則適用於較新的Win64編譯器。ELF格式主要用於Linux平台上的Delphi編譯器。瞭解這些相容性有助於正確選擇和使用外部函式庫。

連結物件檔的實務挑戰與解決方案

在開發過程中,我們經常需要將物件檔(Object File)連結到 Delphi 專案中。物件檔的三種主要格式分別為 OMF(Object Module Format)、COFF(Common Object File Format)以及 ELF(Executable and Linkable Format)。本章節將探討這些格式的特點以及如何在 Delphi 中連結物件檔。

物件檔格式解析

OMF 格式

OMF 格式主要由 Borland 編譯器使用,Delphi 的 Win32 和 Win64 編譯器能夠直接連結這種格式的檔案。透過 tdump 工具,我們可以檢視 OMF 格式的物件檔內容,例如 LzmaDec.obj 檔案:

Turbo Dump Version 6.5.4.0 Copyright (c) 1988-2016 Embarcadero Technologies, Inc.
Display of File lzmadec.obj
002394 PUBDEF 'LzmaDec_InitDicAndState' Segment: _TEXT:172E
0023B5 PUBDEF 'LzmaDec_Init' Segment: _TEXT:176B
0023CB PUBDEF 'LzmaDec_DecodeToDic' Segment: _TEXT:17D2
...

從上述輸出結果中,我們可以看到 LzmaDec.obj 檔案包含了 LzmaDec_InitDicAndStateLzmaDec_Init 等函式,這些函式可以被 Delphi 程式呼叫。

COFF 格式

COFF 格式由 Unix System V 引入,並被 Microsoft 的 Visual C++ 編譯器使用。Delphi 的 Win32 和 Win64 編譯器同樣支援連結 COFF 格式的物件檔。我們可以使用 tdump -E 或 Microsoft 的 dumpbin 工具來檢視 COFF 格式的檔案。

ELF 格式

ELF 格式主要用於 Unix 平台,並且被 LLVM 編譯器基礎設施所採用。雖然某些 Delphi 和 C++Builder 編譯器支援 ELF 格式,但目前的 Win32 和 Win64 Delphi 編譯器並不支援。值得注意的是,Win64 C++Builder 編譯器(bcc64)使用 LLVM 基礎設施,因此產生 ELF 格式的物件檔,這使得在 Delphi 中連結這些檔案變得困難。

連結物件檔的實務挑戰

為了展示連結物件檔的實際問題,我們以 Abbrevia 開源函式庫為例。Abbrevia 原本是由 TurboPower 開發的商業壓縮函式庫,後來捐贈給開源社群。

範例:LzmaDecTest

LzmaDecTest 範例中,我們嘗試連結 LzmaDec.obj 檔案:

{$L LzmaDec.obj}

然而,編譯過程中出現了錯誤:

[dcc32 Error] LzmaDecTest.dpr(22): E2065 Unsatisfied forward or external declaration: 'memcpy'

這個錯誤表明 LzmaDec.obj 檔案參照了未實作的 memcpy 函式。這種情況在連結 C 編譯器產生的物件檔時很常見。

解決方案

為瞭解決上述問題,我們有兩種方法:

  1. 自行實作缺失的函式:我們可以自行實作 memcpy 函式,並從 MSVCRT.DLL 動態連結函式庫中匯入:
function memcpy(dest, src: Pointer; count: size_t): Pointer; cdecl; external 'msvcrt.dll';

別忘了在 uses 子句中加入 Windows 單元,因為 size_t 型別是在該單元中定義的。

  1. 使用 System.Win.Crtl 單元:我們可以直接在 uses 子句中加入 System.Win.Crtl 單元,這個單元連結了 MSVCRT.DLL 中的所有函式。

然而,需要注意的是,MSVCRT.DLL 並不是作業系統的一部分。如果我們的應用程式連結到這個 DLL,就需要同時發布 Microsoft Visual C++ Redistributable 套件。

程式碼範例與解析

以下是一個完整的範例程式碼,展示瞭如何連結 LzmaDec.obj 檔案並解決缺失函式的問題:

program LzmaDecTest;

{$APPTYPE CONSOLE}

{$R *.res}

uses
  System.SysUtils,
  Windows,
  System.Win.Crtl; // 使用這個單元連結 MSVCRT.DLL 中的函式

{$L LzmaDec.obj}

// 如果不使用 System.Win.Crtl,也可以自行實作缺失的函式
// function memcpy(dest, src: Pointer; count: size_t): Pointer; cdecl; external 'msvcrt.dll';

begin
  try
    // 程式的主要邏輯
    Writeln('LzmaDecTest 程式啟動成功!');
  except
    on E: Exception do
      Writeln(E.ClassName, ': ', E.Message);
  end;
end.

#### 內容解密:

此範例程式展示瞭如何處理 Delphi 連結物件檔時可能出現的缺失函式問題。首先,我們使用 {$L LzmaDec.obj}LzmaDec.obj 物件檔連結到專案中。接著,為瞭解決 memcpy 函式缺失的問題,我們選擇了加入 System.Win.Crtl 單元來間接連結 MSVCRT.DLL 中的函式。另一種方法是直接匯入 memcpy 函式。無論採用哪種方法,都能成功編譯並執行程式。需注意的是,發布應用程式時,若連結到 MSVCRT.DLL,則需同時發布 Microsoft Visual C++ Redistributable 套件。

與物件檔連結

在某些情況下,缺失的函式相當簡單,或者已經在 Delphi RTL 中實作,這時我們可以自行編寫這些函式。以下是一個 memcpy 實作的例子:

function memcpy(dest, src: Pointer; count: size_t): Pointer; cdecl;
begin
  Move(src^, dest^, count);
  Result := dest;
end;

內容解密:

  • memcpy 函式用於將記憶體區塊從一個位置複製到另一個位置。
  • destsrc 分別是目標和來源記憶體區塊的指標。
  • count 指定要複製的位元組數。
  • cdecl 呼叫約定表示該函式使用 C 語言的呼叫約定。
  • Move 是 Delphi 中的一個內建程式,用於在記憶體中行動資料。

使用外部函式庫

為了找到包含缺失函式的物件檔,可以使用支援二進位檔案的全文檢索工具來搜尋缺失的名稱。這樣做可能會產生假陽性結果,因為也會傳回使用這些函式的物件檔。然後,可以使用 tdump 工具來檢查潛在的候選檔並找到這些單元的真實來源。

在第二個範例中,我們想要連結到 decompress.obj 檔案,該檔案也是 Abbrevia 的一部分,並且儲存在與 LzmaDec.obj 相同的資料夾中。這個範例被命名為 DecompressTest

首先,我們建立了一個空的控制檯應用程式,並新增了 {$LINK decompress.obj} 指令。編譯器報告了四個錯誤:

[dcc32 Error] DecompressTest.dpr(45): E2065 Unsatisfied forward or external declaration: 'BZ2_rNums'
[dcc32 Error] DecompressTest.dpr(45): E2065 Unsatisfied forward or external declaration: 'BZ2_hbCreateDecodeTables'
[dcc32 Error] DecompressTest.dpr(45): E2065 Unsatisfied forward or external declaration: 'BZ2_indexIntoF'
[dcc32 Error] DecompressTest.dpr(45): E2065 Unsatisfied forward or external declaration: 'bz_internal_error'

內容解密:

  • 編譯器報告錯誤是因為找不到某些符號的定義。
  • 這些符號可能是變數或函式。
  • 需要根據物件檔的檔案來解決這些錯誤。

為瞭解決這些問題,我們需要新增一些程式碼:

var
  BZ2_rNums: array [0..511] of Longint;

procedure bz_internal_error(errcode: Integer); cdecl;
begin
  raise Exception.CreateFmt('Compression Error %d', [errcode]);
end;

內容解密:

  • BZ2_rNums 是一個初始化表格,定義為一個長整數陣列。
  • bz_internal_error 是一個錯誤處理函式,當發生壓縮錯誤時丟擲例外。
  • 使用 cdecl 呼叫約定以符合 C 物件檔的要求。

接著,我們需要連結其他物件檔來解決剩餘的錯誤。例如,BZ2_hbCreateDecodeTablesBZ2_indexIntoF 分別在 huffman.objbzlib.obj 中實作。

{$LINK huffman.obj}
{$LINK bzlib.obj}

內容解密:

  • 使用 {$LINK} 指令將額外的物件檔連結到專案中。
  • 這樣可以解決部分缺失符號的問題。

然而,這樣做又引入了新的錯誤:

[dcc32 Error] DecompressTest.dpr(40): E2065 Unsatisfied forward or external declaration: 'BZ2_crc32Table'
[dcc32 Error] DecompressTest.dpr(40): E2065 Unsatisfied forward or external declaration: 'BZ2_compressBlock'
[dcc32 Error] DecompressTest.dpr(40): E2065 Unsatisfied forward or external declaration: 'BZ2_decompress'

內容解密:

  • 繼續出現缺失符號的錯誤。
  • 需要進一步分析並連結必要的物件檔或實作缺失的函式。

為瞭解決這些錯誤,我們需要新增更多的程式碼和連結其他物件檔。例如:

var
  BZ2_crc32Table: array[0..255] of Longint;

procedure BZ2_decompress; external; //decompress.obj

內容解密:

  • BZ2_crc32Table 是另一個初始化表格。
  • 宣告 BZ2_decompress 為外部函式,告訴編譯器該函式在其他地方實作。

最終,透過逐步連結必要的物件檔和實作或宣告缺失的函式,我們可以成功編譯專案。然而,這只是第一步,後續還需要檢查這些函式的正確性和完整性。