技術文章

Delphi Linux64 LP64 陷阱:Longint、指標與 WIN64

Delphi 的 Linux64 平台採用 LP64 資料模型,Longint 和 LongWord 在那裡是 8 位元組,而 Windows 上的 Delphi(Win32 與 Win64)以及各目標平台上的 Free Pascal 都維持 4 位元組。把 PDFlibPas 這套 Delphi PDF Library 帶上 dcclinux64 時,這個差異會以三種不同的方式咬人,外加一個指標寬度的陷阱:用 {$IFDEF WIN64} 來挑選指標大小的整數,會在所有非 Windows 的 64 位元目標上悄悄選錯寬度

這些陷阱沒有一個是冷門貨。每一個都是「Windows 碰巧幫你兜住」的習慣:RTL 說 Longint 的地方寫成 Integer、照著 C 結構用 Longint 欄位鏡像一份 record、或是該問「指標是不是 8 位元組」的時候去問「這是不是 Win64」。這些習慣可以平安帶上好幾年,因為在 Win32、Win64 和 FPC 上兩種寫法意思相同;到了 Delphi Linux64 上兩者不再等價,而且第一個發現的是編譯器

以下講的都是編譯器抓得到的問題。PDFlibrary.pas 用同一份原始碼在 dcc32、dcc64 和 dcclinux64 下建置,而這些正是橫在「Windows-only 習慣」與第三個目標之間的問題

Delphi 改以 Linux64 為目標時,什麼東西變了?

在 Delphi Linux64 上改變寬度的整數型別就只有 Longint 和 LongWord;Integer 和 Cardinal 在每個 Delphi 平台上都維持 4 位元組,NativeInt 則跟著指標走。Windows 是 LLP64,只有指標在 64 位元下變寬。Delphi 的 64 位元 POSIX 目標(Linux64,以及 macOS 與 iOS 的 64 位元編譯器)是 LP64,C 的 long 也會變寬,而 Embarcadero 就讓 Pascal 的 Longint 跟著它走

型別Delphi Win32Delphi Win64Delphi Linux64Free Pascal
Integer / Cardinal4444
Longint / LongWord448每個目標都是 4
NativeInt / Pointer488跟著目標的指標大小
C long(平台 ABI)448跟著作業系統 ABI

Free Pascal 那一欄正是跨編譯器函式庫的陷阱所在。PDFlibPas 早就透過 FPC 出貨 Linux 版,而 FPC 在 Linux x86-64 上把 Longint 保持在 32 位元,所以「Linux 上能動」這句話是真的,但對 Delphi Linux64 沒有說出任何事。事後證明 FPC 的非 Windows 後端正是 Delphi 移植的正確基礎,只是它腳下的型別大小不同

PDFlibPas PDF 函式庫在 Delphi Win32、Win64、Linux64 與 Free Pascal 各目標上 Integer、Longint、NativeInt 與 C long 寬度對照矩陣
在 Delphi Linux64 上只有 Longint、LongWord 與 C long 變寬,FPC 則在每個目標上都維持 4 位元組

為什麼 WIN64 檢查在 Linux 上會把指標截斷?

簡短回答:WIN64 的意思是「64 位元 Windows」,不是「64 位元」,把兩者當同義詞就會選錯整數寬度。PDFlibDither 是一個 Floyd-Steinberg 誤差擴散抖動器,把 24 位元點陣圖降到每通道少數幾階,它用裸指標算術在三列誤差項上前進,而這套算術背後的指標大小整數必須跟著 CPU 寬度走,而不是跟著作業系統。天真的條件判斷會錯在這裡:

// 天真版:寬度跟著作業系統選,於是所有非 Windows 的 64 位元目標都拿到 Integer
{$IFDEF WIN64}
Type
  OSPtr= IntPtr;
{$ELSE}
Type
  OSPtr= Integer;
{$ENDIF}

// 蛇形掃描一次一步地移動誤差列指標
Inc(OSPtr(ErrorR), SizeOf(TErrorTerm));  // FPC x86-64:只改寫低 32 位元
Dec(OSPtr(ErrorG), SizeOf(TErrorTerm));  // dcclinux64:E2064,等號左側不可被指定

// 正確版:所有平台都用同一個指標大小的型別
Type
  OSPtr= TPLIntPtr;  // PDFlibTypes:非 Windows 用 NativeInt,Win64 / Win32 用 IntPtr / Integer

在這個條件編譯下,FPC 的 Linux x86-64、macOS x64 與 ARM64 目標會把 OSPtr 解析成 4 位元組的 Integer,於是 Inc(OSPtr(ErrorR), ...) 把 8 位元組指標的前 4 位元組當整數重新解讀之後遞增。在 little-endian CPU 上動到的就是位址的下半部,上半部永遠碰不到,這正是這個錯誤藏得這麼好的原因:結果一路都對,直到低 32 位元繞回去,指標便悄悄跳回 4 GiB 之前。FPC 編譯這種變數型別轉換時不會抱怨;Delphi 則拒絕在指定的左邊出現轉成不同大小的變數型別轉換,所以 dcclinux64 在第一個 Inc 就停在 E2064

這裡有個很好用的不對稱:拒絕這種型別轉換的編譯器,能在程式碼於新目標上跑出第一行之前,就抓到另一個編譯器默默放行的指標寬度錯誤。PDFlibPas 在這裡用的是 PDFlibTypes 裡的 TPLIntPtr,它先測 OS_WINDOWS,然後才區分 Win64 與 Win32,所以非 Windows 目標一律拿到 NativeInt

我們帶走的規則:永遠不要從作業系統 define 推斷指標寬度。需要指標大小的整數時,用 NativeInt / NativeUInt(Delphi 和 FPC 都有)或建立在它們之上的專案別名,整個條件編譯直接省掉;真的需要在 Delphi 程式碼裡按寬度分支時,CPU64BITS 才是回答你真正想問的那個問題的 define

PDFlibPas Delphi 程式碼中指標寬度型別的決策流程:WIN64 define 在非 Windows 64 位元目標上選出 4 位元組 Integer,直到改用 NativeInt 把關
WIN64 define 問的是作業系統,所以指標寬度型別必須改由 NativeInt 或 CPU64BITS 測試取得

TStream 覆寫:逐字元照抄 RTL 簽章

Delphi Linux64 上的 TStream 衍生類別,其 Read、Write 和 SetSize 必須用 Longint 參數來宣告與實作,跟 RTL 的宣告一字不差,因為 Integer 已經不再是同一個型別。PDFlibPas 為了 Unicode 檔名有自己的 TPDFlibWideFileStream,它的類別宣告確實用了 Longint,但實作區段用的是 Integer

Type
  TPDFlibWideFileStream= Class(TStream)
  Protected
    Procedure SetSize(NewSize: Longint); Override;
  Public
    Function Read(Var Buffer; Count: Longint): Longint; Override;
    Function Write(Const Buffer; Count: Longint): Longint; Override;
  End;

// 修改前:在 Win32、Win64 和 FPC 上被接受了多年,因為那裡 Integer = Longint
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Integer): Longint;

// 修改後:實作表頭一字不差地重複宣告
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Longint): Longint;
Begin
  Result:= FileRead(FHandle, Buffer, Count);
  If Result= - 1 Then
    Result:= 0;
End;

在這個函式庫遇過的每一個編譯器上,Integer 和 Longint 都是同一個 4 位元組型別,所以表頭不匹配和匹配根本無法區分。到了 Delphi Linux64,實作不再匹配自己的宣告,單元就編譯不過。這條比它表面上看起來更痛,因為 PDFlibExtra 是大多數單元都依賴的基礎單元,一個不匹配的實作表頭就擋住了大部分單元的編譯

同樣的道理反向適用在新程式碼上:override RTL 方法時,逐字照抄它的參數型別,就算你「知道」兩個型別一樣也照抄。Delphi Linux64 只不過是第一個讓這份知識失效的目標

哪些 record 要改用 Integer 而不是 Longint,哪些又必須保留?

鏡像檔案格式或 Windows 結構的 record 必須用固定寬度型別(Integer、Cardinal、Int32),而鏡像以 long 宣告的 C 結構的 record 則應該保留 Longint / LongWord。這兩種情況在原始碼裡看起來一模一樣,答案卻相反,因為 Windows 的 LONG 永遠是 32 位元,C 的 long 則聽平台 ABI 的

Delphi record 欄位在 LP64 下的決策矩陣:磁碟上的位元組用固定寬度 Integer,PDFlibPas 中 C long 的綁定保留 Longint
鏡像位元組的 record 用固定寬度,只有 C long 的綁定保留 Longint,並用一行大小斷言護住
Type
  // Uniscribe GOFFSET:兩個 Windows LONG,合計永遠 8 位元組
  TGoffset= Packed Record
    du: Integer;  // 原為 Longint:record 在 Delphi Linux64 上長成 16 位元組
    dv: Integer;
  End;

  // EMF LOGCOLORSPACE:表頭用固定的 68 位元組 Move 從 record 資料填入
  TPLEMFLogColorSpace= Packed Record
    Signature, Version, Size, ColorType, Intent: Cardinal;
    Endpoints: Array[0..8] Of Integer;  // 原為 Longint:36 位元組變成 72
    Gamma: Array[0..2] Of Cardinal;
    Filename: Array[0..259] Of WideChar;
  End;

  // PDFium FPDF_FILEACCESS:m_FileLen 對應 C 的 unsigned long,LongWord 才是對的
  FPDF_FILEACCESS= Record
    m_FileLen: LongWord;  // Windows 上 4 位元組,LP64 上 8 位元組,與 C 端一致
    m_GetBlock: TFPDF_GetBlock;
    m_Param: Pointer;
  End;

EMF 這個案例說明了這種錯可以錯得多麼無聲。匯入器往 TPLEMFLogColorSpace 複製 68 位元組:20 位元組表頭、36 位元組 CIE endpoints、12 位元組 gamma。若 endpoints 在 LP64 上是 Longint,同樣的 68 位元組會落進一個 72 位元組的 endpoint 陣列,gamma 值永遠不會被寫入,而且沒有任何東西報錯。這兩個 record 都在 Windows-only 的程式路徑上,而且無論如何都用固定寬度型別,因為鏡像磁碟位元組的 record 不該取決於碰巧建置它的是哪個編譯器

PDFium 綁定則是反例。它的 m_FileLen 對應 C 的 unsigned long,在 Windows 上是 32 位元、LP64 上是 64 位元,LongWord 在兩邊都精確跟著走。若在整理程式碼時順手改寫成 Cardinal,等 PDFium host 哪天有了 POSIX loader 就會出事;PDFium host 目前是 Windows-only,所以這是留給未來的備註。C 綁定的呼叫慣例那一側另有自己的陷阱,收錄在PDF Library 的 DLL、ActiveX 與 dylib 綁定一文

給任何鏡像位元組的 record 一個便宜的護欄:在啟動時對它的大小斷言一次,例如在 initialization 區段寫 Assert(SizeOf(TGoffset) = 8)。零成本,而且能把無聲的版面變化變成新目標第一次執行就立刻失敗

把條件 define 弄對:DELPHI_LINUX、Android 與 Types

在這些型別問題浮上檯面之前,得先讓 Linux 上的 Delphi 別再假裝自己是 Windows。PDFlibDefines.inc 原本對 Delphi Linux 什麼都沒定義,編譯器直接落進 Windows/VCL 預設,第一個錯誤就是找不到 Vcl.Graphics。這些 define 把 Delphi Linux 帶上 FPC 在非 Windows 目標用的同一套後端:Cairo 渲染、fontconfig,以及執行時載入的 OpenSSL 與 libcurl

{$IFDEF LINUX}
{$IFNDEF FPC}
{$IFNDEF ANDROID}
{$DEFINE DELPHI_LINUX}
{$DEFINE NOVCL}
{$DEFINE MD5_MAC}
{$DEFINE NOCRYPTAPI}
{$DEFINE USE_Cairo}
{$ENDIF}
{$ENDIF}
{$ENDIF}

這個區塊裡有兩個細節容易漏掉。排除 ANDROID 是因為 Delphi 對 Android 目標也定義了 LINUX,而這套後端不涵蓋 Android;新的 define 刻意與既有的 Delphi macOS 分支(不帶 FPC 的 OS_MAC)分開,後者依賴 Macapi 單元,Linux 的 define 走的則是 FPC 非 Windows 路徑

另一個反覆出現的錯誤是 TPoint 和 TRect 未宣告。Windows 上這些型別透過 Windows 單元間接進到大多數單元;Delphi Linux 的 uses 子句裡沒有 Windows 單元,所以 PDFlibFont、PDFlibPageTree 這類單元現在在 DELPHI_LINUX 下明確列出 Types。如果你的程式碼能在 Windows 上編譯,只是因為 Windows 順手拖進了 System.Types,就等著撞同一堵牆。程式碼只因巧合而只在一個編譯器上能動的通用模式,正是五個直到 FPC 揭穿前都只是碰巧能動的 Delphi 習慣一文的主題

編譯器不再幫忙的地方

上面的 LP64 問題是編譯器找得到的;找不到的——例如某個 record 大小不對卻從不經過型別轉換——只會在測試真的跑在目標平台上時現形,這正是上面那些大小斷言存在的目的

如果你最初是為了 1-bit 輸出才來看抖動程式碼,渲染頁面的正規路徑是另一條:在渲染器之外做 GDI 半色調降頻,見把 PDF 頁面渲染成 1-bit 黑白點陣圖一文

這裡描述的跨編譯器基礎工程都在PDF Library for Delphi 裡,如今支援 Windows 上的 Delphi 與 C++Builder,加上 Linux 與 macOS 上的無頭 Free Pascal 建置;完整支援的編譯器與平台清單見產品頁