技術文章

Free Pascal 靜態連結 jbig2enc,無需 DLL

PDFlibPas 3.538.0 將外部 JBIG2 編碼器靜態連結進 Free Pascal 與 Lazarus 程式。專案加入 PDFlibJBIG2EncC 單元,也就是 Delphi 和 C++Builder 已經使用的同一個單元,最後編碼器會進入可執行檔,除了它本身之外不必再部署任何額外檔案。這推翻了先前對這項功能的結論:Free Pascal 只能透過 DLL 存取外部編碼器

為什麼 DLL 看起來像是唯一選項

之所以看起來只有 DLL 可用,是因為三條連結路徑分別以三種互不相關的方式失敗,而且沒有任何編譯器開關能觸及其中一個問題。內部連結器會直接拒絕關聯式 COMDAT 區段。透過隨附 binutils 進行外部連結時,會在區段垃圾回收過程中崩潰,因為 Free Pascal 在 64 位元 Windows 目標上無條件啟用了這一步。新版 binutils 又完全無法處理 Free Pascal 的連結腳本。用另一套工具鏈重建 C++ 端,只是把一種拒絕換成另一種,因為模板和 inline 實體化天生會產生 weak external symbols,而 Free Pascal 會將其回報為 Unsupported COFF symbol type 105。這些證據都沒有錯,先前關於 JBIG2 編碼器後端與 Free Pascal 連結器的說明逐一走過了這些至今仍能重現的死路。錯的是對修復應該落在哪裡的假設。每次嘗試都經過編譯器或連結器,但它們都無法改變物件檔已經包含的內容。問題從頭到尾都在物件檔裡。ObjConv 讀取 COFF 並寫回 COFF,而 Free Pascal 無法處理的每種結構,都有它能接受的機械等價形式

這個錯誤從來不說明原因

Free Pascal 的內部連結器只實作了一半 pick-any COMDAT,而這個半成品實作是這裡最難診斷的部分。它確實會依格式要求折疊重複定義。但在標記區段為已使用時,TExeOutput.RemoveUnreferencedSections 會透過 exesymbol 重導向到勝出的定義,TCoffexeoutput.DoRelocationFixup 卻直接讀取 objreloc.symbol.objsection。當一個已使用的區段引用了某個符號,而該符號在自己的物件檔中定義於折疊失敗的副本裡時,兩個階段看到的是不同區段,連結就會以 Internal error 200603061 停止

把它和兩側的兩個限制比較一下。Unsupported COFF symbol type 105 表示 weak external。Associative or exact match COMDAT sections are not yet supported 表示關聯式 COMDAT,甚至會指出出問題的符號。內部錯誤 200603061 卻什麼也不說:沒有符號名稱、區段名稱、檔案名稱或階段資訊。它也不是邊角案例,而是常態,因為 MSVC 會把每個字串常值以及每個 inline 或模板實體化都放進 pick-any COMDAT;在這組編碼器的 186 個物件中,連結器執行了 2656 次折疊。使用 /Gy- 可以讓普通函式離開逐函式 COMDAT 區段,卻會讓字串常值和模板實體化留在原處

為什麼逐一補 CRT 符號時,總像是最後一個符號把它弄壞了

因為連結器只有在所有符號都解析完成後,才會進入修正階段。只要還有任何缺失,執行就會以 Undefined symbol 提前結束,COMDAT 問題根本沒有機會暴露。補上最後一個 C 執行階段樁函式後,連結器前進一個階段,直接撞上內部錯誤 200603061。因此現場症狀會系統性地誤導人:逐一為引用到的 C 符號加入 Pascal 函式本體時,看起來總是最近加入的那個破壞了建置,或者彷彿跨過了大約一百個樁函式的某個門檻。兩者都不對。最後加入的是哪個符號、總共加入多少個符號都無關緊要,因為故障從第一個物件開始就潛伏著,只是在解析成功後才變得可達。當連結器在你修復無關問題後改變報錯內容,應該先問自己是否只是推進了一個階段,而不是製造了回歸

修復是一次 ObjConv 處理,而不是編譯器開關

完整修正就是對每個已編譯物件執行一次後處理命令:ObjConv -fcoff64 -xw -xc -xn -np:__imp_:pdflibimp_。其中三個選項是為這項工作新增的。-xwIMAGE_SYM_CLASS_WEAK_EXTERNAL 符號解析為普通外部符號。-xn 正規化 IMAGE_SYM_CLASS_NULL 符號,例如 _fltused,否則 Free Pascal 會回報 Unsupported COFF symbol type 0-xc 才是核心:它把每個 COMDAT 區段降級為普通區段,並將其中定義的符號設為 static。移除這個選項就移除了故障,因為沒有 COMDAT 區段,就沒有折疊,也沒有一個階段可以重導向而另一個階段找不到的勝出副本,關聯式 .pdata.xdata 展開區段也會一併消失。代價確實存在,但很小:原本可以合法合併的副本現在都會各自保留下來

-np:__imp_:pdflibimp_ 前綴重新命名解決的是另一個衝突。MSVC 透過名為 __imp_* 的間接單元呼叫匯入的 Win32 API,Free Pascal 則為自己的匯入機制保留這個前綴,直接定義其中任何一個名稱也會觸發同一個內部錯誤 200603061。重新命名單元後,Pascal 端可以把它們作為普通變數發佈,並在執行時填入。物件本身使用靜態連結旗標 /GS- /Gs999999 /Gy- /Zl /GR- /EHs-c- 編譯,並關閉影像編解碼器,因此無用的檔案 I/O 和編解碼路徑只需要連結階段樁函式。它們會放進 Lib\thirdparty\Win64f,而 Delphi 和 C++Builder 路徑繼續連結自己的 Win64x 集合不變;對只涉及一個工具鏈的可攜性修復來說,這才是正確結果

Pascal 端仍然必須匯出的內容

Free Pascal 透過符號名稱解析 C 物件的匯入,因此每個代替 C 入口點的 Pascal 例程都要帶上明確的 public name 子句。Delphi 直接把例程名稱作為符號名稱,不需要任何子句,所以同一個單元可以透過 {$IFDEF FPC} 下的子句服務兩個編譯器。陷阱在於,external 'msvcrt.dll' 宣告並不能滿足連結需求:它只建立匯入項,從來不是連結物件可以繫結的定義。必須存在轉送函式本體

// 外部宣告只建立匯入項,沒有連結物件可以
// 繫結到它
function crt_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  external 'msvcrt.dll' name 'memcmp';

// 在精確的 C 符號名稱下發佈 Pascal 函式本體,才是
// 物件集合實際繫結的目標
function jbig2_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  public name 'memcmp';
begin
  Result := crt_memcmp(Buf1, Buf2, Count);
end;

可變參數入口點會打破這個模式,因為 Pascal 包裝器無法把自己的 varargs 轉送給另一個 varargs 被呼叫函式。解法是不再使用包裝器:以 C 名稱匯出一個裸例程,並按照呼叫方已經安排好的參數暫存器和堆疊原樣跳到真正的實作。JPEG 2000 層已經用這種方式處理 snprintfvsnprintf,跳到帶底線前綴的 msvcrt 拼法,因為普通名稱只有 UCRT 匯出。另一個相關限制也來自同一個內部錯誤:重新命名後的匯入單元透過 initialization 區段中的 GetModuleHandleAGetProcAddress 填入,而不是使用靜態初始化器,因為在初始化器中取得匯入例程地址會讓編譯器產生無法處理的修正項,再次以 200603061 失敗

function crt_sprintf(Buffer: PAnsiChar; Fmt: PAnsiChar): Integer; cdecl; varargs;
  external 'msvcrt.dll' name 'sprintf';

var
  SPrintfTarget: Pointer = @crt_sprintf;

// Varargs 無法從 Pascal 包裝器轉送,因此匯出符號會
// 在保留呼叫方堆疊框架的情況下直接跳到目標實作
procedure jbig2_sprintf; assembler; nostackframe;
  public name 'sprintf';
asm
  jmp qword ptr [rip + SPrintfTarget]
end;

Free Pascal 專案現在需要怎麼做

除了 uses 子句中的單元名稱之外沒有其他變化,而且現在也不再有需要部署的檔案。後端會從自己的 initialization 區段透過 RegisterJBIG2EncoderBackend 註冊,呼叫方仍然像以前一樣要求它:使用值為 4 的選項位元 PDF_JBIG2_OPTION_EXTERNAL_ENCODER,或使用擴充入口點的 UseExternalEncoder 引數。要求它仍然只是偏好而不是保證,因為遺漏該單元的建置會靜默回退到原生 Pascal MMR 編碼器,產生較大的檔案而不是報錯

uses
  Classes, SysUtils,
  PDFlibrary,
  PDFlibJBIG2EncC;   // Delphi、C++Builder,以及從 3.538.0 起的 Free Pascal

var
  Pdf: TPDFlib;
  Scan: TStream;
  ImageId: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.NewPage;
    Scan := TFileStream.Create('scan-page-1.tif', fmOpenRead);
    try
      // Interpolate、SymbolExtract、UseExternalEncoder、SkipBlackDots、
      // BlackDotSize、LossyLevel
      ImageId := Pdf.AddImageJBIG2FromStreamEx(Scan, 0, 1, 1, 0, 0, 0);
    finally
      Scan.Free;
    end;
    if ImageId = 0 then
      raise Exception.Create('JBIG2 encoding failed');
    Pdf.SaveToFile('archive.pdf');
  finally
    Pdf.Free;
  end;
end;

有兩個限制需要直說。現在只有 Win64 物件集合,因此在所有其他 Free Pascal 目標上,外部編碼入口點都會回報失敗,由原生 Pascal 編碼器完成工作。另一個決定整個功能是否可靠的回歸測試是渲染比較而不是大小檢查:兩個編碼器對同一來源都是無損的,所以會渲染輸出並逐位元組比較,Lazarus 測試套件連同這項測試在內通過了 26 項中的 26 項。比較壓縮串流大小沒有意義,因為一張倒置頁面壓縮後與正確頁面大致一樣大

這個經驗不只適用於 JBIG2。當邊界確實需要動態時,DLL 是正確形態,DLL、ActiveX 和 dylib 整合介面正是為此服務;但當 DLL 只是 COFF 讀取器的臨時繞道時,它就是錯誤形態,因為它會為每個安裝程式增加一個檔案,為每次部署增加一條搜尋路徑,還會引入靜態連結不會有的版本錯配故障。上游同樣重要,因為二值影像的產生方式對最終大小的影響大於編碼器本身,Delphi 中以區域為基礎的單色渲染涵蓋了流程的另一半。工具鏈涵蓋範圍、各編譯器的物件集合以及支援的目標平台列在 losLab PDF Developer Library 產品頁