技術文章

Free Pascal Win32:HotPDF 的 C 符號裝飾

Free Pascal 在 Win32 上會自動幫每一個 cdecl; external 匯入加底線前綴,而 public name 匯出的則是您寫的那個字串,一字不差。HotPDF 必須在同一份原始碼樹裡同時滿足這兩種慣例,因為 Delphi 建置早已出貨一批把底線手工寫進名稱的匯入宣告。這個不對稱弄錯了,得到的就是指名一個沒人寫過的符號的連結錯誤

把 Delphi 函式庫延伸到 Free Pascal,通常被說成可攜性問題,在 Win64 上也的確大抵如此。Win32 不是。32 位元 x86 Windows ABI 背了三十年的累積慣例:C 符號怎麼拼、堆疊由誰清理、一個編譯單元可以假設哪些編譯器私有的輔助常式存在——每一項都是兩個在語言上意見一致的 Pascal 編譯器,照樣可能在物件檔上翻臉的地方

為什麼同一個符號在 Win64 解析得到、在 Win32 卻失敗?

因為底線前綴是個 32 位元慣例,Free Pascal 對匯入套用、對匯出卻不套。宣告 function deflate(...): Integer; cdecl; external; 之後,FPC 在 Win32 的物件檔裡找 _deflate,在 Win64 找 deflate。這是正確行為,跟 C 編譯器的產出一致。陷阱在橋的另一頭:標了 public name 'deflate' 的常式,在兩個目標上都精確匯出 deflate,一個前綴都不加

再補上讓它變具體的歷史細節。Delphi 建置本來就把某些進入點宣告成名稱裡帶底線,因為它自己的物件檔裝的就是這個。同一份宣告餵給 Win32 上的 FPC,編譯器忠實地又加了一次前綴,連結器於是滿世界找 __deflate,一個誰都不匯出的符號。直覺的修法是到處補一個底線,這會弄壞本來就拼對的那些匯入

行得通的是一對前綴常數,不是單獨一個。HPDFFPCZLibHPDFFPCCodecStubs 給純 C 匯入用一個前綴,給已經帶著 Delphi 側前綴的匯入用另一個;在 Win64 上兩個常數都是空字串,既有的連結名稱原封不動地活下來。兩個常數取代一個常數,就是全部的修法,而它只在您把匯入規則與匯出規則分開之後才顯得顯而易見

同一批 C 符號宣告在 Win64 與 Win32 上由 Free Pascal 與 Delphi 解析的結果:cdecl 匯入只在 32 位元目標上多出底線,已經帶底線拼寫的 Delphi 宣告變成 __deflate 而連結失敗,public name 匯出則在兩種架構上都保持逐字
一個前綴常數服務不了兩套規則:純 cdecl 匯入與已帶 Delphi 底線的匯入,在 Win32 的 FPC 底下裝飾方式不同,所以 HotPDF 保留兩個,並在 Win64 上讓兩者皆空
// 兩個前綴,不是一個:純 C 匯入與已經帶著手工
// Delphi 前綴的匯入,在 FPC/Win32 底下裝飾方式不同
const
{$IF DEFINED(FPC) and DEFINED(CPU32)}
  CPrefix     = '_';   // cdecl external 時 FPC 自己會加
  DelphiCName = '';    // 原始碼裡已經手寫了底線
{$ELSE}
  CPrefix     = '';
  DelphiCName = '';
{$IFEND}

// 匯出側:'public name' 在每個目標上都是逐字
procedure hpdf_codec_free(P: Pointer); cdecl;
  public name 'hpdf_codec_free';

WIN32 告訴您架構,不是 ABI

這是除錯尾巴最長的條件編譯錯誤,值得直接講明白:WIN32WIN64 描述的是目標架構,對哪些編譯器私有的 runtime 輔助常式存在隻字未提。Free Pascal 在對應的 Windows 目標上兩個符號都定義,跟 Delphi 一模一樣。於是一個用 {$IFDEF WIN32} 包住呼叫 Delphi runtime 輔助常式程式碼的防衛,在 FPC 下編譯得過、在連結時陣亡

具體來說,有三族程式碼掉進這個陷阱。經 System.@_ll 輔助常式抵達的 Delphi 64 位元整數 trampoline、MSVC 的 Win32 組語支援常式,以及伴隨它們的匯入槽位,全是為了伺候 Delphi 建置所連結的預編譯 C 物件而存在。Free Pascal 不連結那些物件,所以那套機制一個都不需要,每一處參照都必須消失。微妙之處在於宣告與實作必須一起排除。只排除一邊,編譯器回報的是一段對某個它配對不到任何東西的識別字的無用牢騷

推導出來的規則很短。問題關於 ABI 或 runtime 支援,就用編譯器當防衛條件;問題關於指標寬度或暫存器數量,就用架構當防衛條件;永遠別讓一個替另一個代班

宣告與實作要一起防衛

介面區段裡的條件編譯區塊很容易在不知不覺間踩進去,而隨之而來的錯誤訊息指哪裡都好、就是不指病因。往類別介面加一個方法宣告,自然的擺法是放在相關方法旁邊,這一切都很好,直到那些鄰居碰巧住在一個既有的 {$IFDEF} 區塊裡。條件指示詞沒有縮排,四十行之前打開的區塊,在您閱讀周邊宣告時基本上是隱形的

接下來發生的是:在同一套工具鏈上編譯成功,在另一套上炸出一串連鎖。如果周邊的防衛是一個 Free Pascal 不滿足的 Delphi 版本檢查,宣告在 FPC 那邊憑空消失,無條件的實作卻還在,編譯器於是回報一長串它預期卻找不到的方法識別字抱怨。沒有任何一則訊息提到肇事的條件區塊

兩個習慣可以擋下這整類失敗。往介面區段插入之前,向上找最近一個還開著的條件指示詞,不要相信視覺上的分組。並且把一套全綠的 Delphi 測試當成只關於 Delphi 的證據:Free Pascal 函式庫建置是另一道關卡,要知道它過關,唯一的方法是把 build-Win32-Lib-FPC.cmdbuild-Win64-Lib-FPC.cmd 當成同一個變更的一部分跑過

32 位元算術程式碼裡壞掉的是什麼

有一條語言限制偏偏出現在最不願意改動的程式碼裡:32 位元 Free Pascal 不接受 UInt64for 迴圈控制變數。在承載 X25519 與 X448 的橢圓曲線 unit 裡,走訪 limb 陣列的迴圈之所以用 64 位元計數器寫,只不過因為檔案裡其他所有東西都是 64 位元

修法必須像手術一樣精準,因為在有限體運算裡,變數的寬度是正確性論證的一部分。迴圈索引改成 Integer,反正 limb 陣列只有少數幾個元素,索引永遠碰不到 32 位元範圍。所有參與運算的東西——limb 本身、進位傳播與 mask——都維持 UInt64,因為把其中任何一個改窄,都會無聲無息地改變對體質數取模的結果

// 32 位元 FPC 拒收 UInt64 迴圈變數。只把索引改窄;
// limb、mask 與進位保持寬度,否則有限體運算就變了
var
  I: Integer;                 // 原本是 UInt64
  Carry, Mask: UInt64;
begin
  Carry := 0;
  for I := 0 to High(Limbs) do
  begin
    Limbs[I] := Limbs[I] + Carry;
    Carry := Limbs[I] shr 51;
    Limbs[I] := Limbs[I] and Mask;
  end;
end;

這種變更的驗證不能靠來回測試。用同一個壞掉的實作加密再解密,它跟自己是完美一致的,這就是為什麼 known-answer 向量在這裡沒得商量:把公開的 X25519 與 X448 測試向量跑一遍,逐位元組比對輸出。這是唯一能區分正確實作與自我一致的錯誤實作的檢查,同樣的原則也適用於Free Pascal 的 deflate 與 AES codec 邊界一文討論的對稱原語

HotPDF Win32 Free Pascal 建置的兩個斷點:包住 Delphi runtime 輔助常式的 {$IFDEF WIN32} 防衛編譯得過卻在連結失敗,除非宣告與實作一起排除;以及 X25519 與 X448 limb 走訪中的 UInt64 迴圈變數改窄為 Integer,而 limb、進位與 mask 保持寬度
問題是 ABI 或 runtime 支援就用編譯器防衛,是指標寬度就用架構防衛,然後用公開的 known-answer 向量、而不是來回測試,去證明算術變更是對的

Win32 Free Pascal 建置的價值

實務上的回報是:以 32 位元 Windows 為目標的 Lazarus 應用程式,拿到與 Delphi 版相同的文件引擎,不必多養一份二進位契約。這對那些很少被談起的部署最重要:工業控制器、POS 終端機、還有跑了十幾年的營運軟體——在那些地方,32 位元 runtime 不是遺留選擇,是硬體約束

Win64 的故事在先,記錄在Free Pascal 與 Lazarus 的 Win64 支援。Win32 不是它的重播。Win64 只有一種呼叫慣例、沒有名稱裝飾、也沒有需要繞開的 Delphi 私有整數輔助常式,所以本文幾乎每一件事都是 32 位元目標限定。需要改迴圈變數的那幾個算術 unit,正是NIST 曲線上的 Montgomery 算術描述的那幾個,寬度紀律在那裡有更深的展開

通則是:跨編譯器的可攜性工程,重點從來不在語言特性。這裡兩個編譯器吃的是同一種 Object Pascal。不同的是物件檔:符號怎麼拼、runtime 假設提供哪些輔助常式、連結裡有哪些預編譯物件。HotPDF 在 HotPDF Delphi PDF component 裡把 Free Pascal 與 Lazarus 套件跟 Delphi、C++Builder 套件一起出貨,讓同一份原始碼樹餵養每一套工具鏈,而不是按編譯器分家