技術文章

PDFlibPas 的 FPC Win32 OMF 轉 COFF 物件連結

PDFlibPas 能在 32 位元 Windows 下用 Free Pascal 建置,而難的從來不是 Pascal,是物件檔:Delphi 建置所連結的 AES 與 OpenJPEG 物件是 OMF 格式,Free Pascal 的內部連結器卻只收 COFF,而兩種格式之間的轉換會產出讓連結器直接丟內部錯誤、而不是好好給出診斷訊息的區段名稱與區段定義符號

把 C 物件連結進 Pascal 函式庫的人,都知道這是一片什麼樣的地雷區。Win64 相對文明:一種物件格式、一種呼叫慣例、沒有名稱裝飾。Win32 則把這個平台累積的每一層歷史都原封保存,而一個要靜態連結第三方 C 程式碼的函式庫,會一次撞上全部

編譯器目錄名稱不會告訴您目標平台

先從建置進入點下手,因為這裡判斷錯了,任何物件檔都還沒登場就先燒掉幾個小時。Free Pascal 的安裝目錄名稱標明的是主編譯器住在哪裡,不是它會產出什麼。32 位元的 host 編譯器可以呼叫坐在它旁邊的 cross-compiler,在您傳對目標切換參數時產出 64 位元程式碼,所以從路徑推測目標只是一種碰巧有效的猜測,直到哪天有人重整了工具鏈

可靠的辦法是問編譯器本身。透過編譯器自己的資訊切換參數去查實際的目標處理器與作業系統,同時兼容兩種常見的安裝版面(扁平的 binary 目錄,以及按版本巢狀的目錄),因為不同的安裝程式與工具鏈管理器會擺出不同的形狀。把任何一種版面寫死的建置腳本,就只在那唯一一台機器上能動

為什麼轉換過的物件檔會弄垮內部連結器?

因為轉換保留了 OMF 的區段命名慣例,還合成出 COFF 連結器預期之外的區段定義符號。把 OMF 物件轉成 COFF 是必要條件,卻不是充分條件:轉出來的檔案帶著經典的 _TEXT_DATA_BSS 區段名稱,外加由它們衍生的區段定義符號名稱,把這種檔案餵給 Free Pascal 內部連結器,得到的是編譯器內部錯誤,而不是一則關於區段命名的訊息

對建置問題而言,內部錯誤是最惡劣的失敗模式,因為它對輸入到底哪裡不對隻字不提。修法是在轉換之後對 COFF 檔補一道正規化:把區段名稱改寫成預期的形式,並把對應的區段定義符號一併改寫到相符,同時符號索引、程式碼位元組與重定位資訊一律不動。最後這個限制就是全部的難點。會重編符號編號或搬移偏移量的改寫,產出的是一個連結得過、執行起來卻會當掉的物件檔

兩套物件集裡有一套需要一道前置步驟。用經典 32 位元 C++ 編譯器建出來的 OpenJPEG 物件依賴 Delphi 私有的 64 位元整數輔助常式,而 Free Pascal 不提供這些常式,所以格式轉換做再多遍也救不回來。這批物件先用 Clang 系編譯器重建(它不會產出那些相依),然後再走轉換

Win32 上把 PDFlibPas 靜態 C 物件從 Lib\thirdparty\Win32 的 Delphi OMF 送到 Lib\thirdparty\Win32f 下可供 FPC 連結的 COFF 的管線:OMF 轉 COFF、一道改寫區段名稱與區段定義符號但不動符號索引、程式碼位元組與重定位的正規化手續,以及針對呼叫 Delphi 64 位元輔助常式的 OpenJPEG 物件所做的 Clang 重建
轉換是必要卻不充分:把只改了名稱、尚未正規化的 COFF 餵給 Free Pascal 內部連結器,它回敬的是內部錯誤,所以要在轉換後補一道只修名稱與符號、不動偏移量的手續
// FPC 目標用的物件檔放在它們自己的目錄裡。這些檔案不會
// 取代 Delphi 的物件集,因為兩套工具鏈從同一份原始碼樹
// 建置,各自需要自己的連結輸入
//
//   Lib\thirdparty\Win32   Delphi OMF 物件,原樣保留
//   Lib\thirdparty\Win32f  FPC COFF 物件,已轉換並正規化
//
// 建置進入點:
//   build-Win32-Lib-FPC.cmd
//   build-Win64-Lib-FPC.cmd

編譯器私有的輔助常式不可移植,它們的慣例也一樣

Delphi 的執行階段在 32 位元 x86 上為 64 位元整數運算提供了組合語言 trampoline,為 Delphi 預先編譯的 C 物件會呼叫進去。Free Pascal 有自己的一套安排,所以這些參照必須用別的方式滿足,而不是原封轉投過去。讓轉投行不通的細節在呼叫慣例:影像程式碼用到的計時輔助常式,其四位元組參數由 callee 清理;而 64 位元除法輔助常式清的是十六位元組,並用經典的暫存器對傳回結果。兩個常式、兩種慣例,為其中一個寫的 trampoline 用在另一個上,會無聲無息地把堆疊弄壞

名稱裝飾(name decoration)補上問題的後半。在 Win32 上,Free Pascal 會自動幫外部 C 匯入加底線前綴,卻把 public name 宣告原樣匯出,於是同一座橋的匯入端與匯出端遵守著不同的規則。OpenJPEG 需要的那座 C runtime 橋因此必須匯出分毫不差的 C 符號名稱,而 variadic 進入點需要的是 32 位元間接跳躍,不是直接跳躍。這些事情講明了都不稀奇,而它們全部以同一種方式失敗:一則連結錯誤,指名道姓地抱怨一個沒人寫過的符號

是什麼讓 Win32 執行檔活不到 main?

答案是搜尋路徑上的一個 64 位元 DLL:Free Pascal 的 zlib unit 採動態繫結,不是靜態連結。症狀是程式帶著 invalid-image 狀態碼立即結束,程式裡任何 Pascal 程式碼都還沒跑,於是您盯著剛建置出來的那支程式找問題,而真正的毛病在載入器對著錯誤架構去解析一個匯入

這一課的重點在假設,不在 zlib。一個名字取自壓縮函式庫的 unit 不一定真裝了那個函式庫;它可能只是一層繫結,期待執行階段有個共用函式庫可抓,而非預期的動態相依即使碰巧解析成功,也是部署上的負債。改用純 Pascal 的串流實作,兩個目標平台就都有一條靜態編入、零外部相依的壓縮路徑——嵌在別人應用程式裡的函式庫,本來就該長這樣

同樣的直覺也適用於外部 JBIG2 編碼器後端。在 32 位元目標上,外部編碼器沒有被連結,請求會退回內建的 Pascal 編碼器,而驗證這件事的測試必須去查目前目標的註冊狀態,而不是把一次成功的編碼當成外部後端存在的證據。一個運作良好的 fallback 恰恰就是藏住缺失相依的那個東西,這正是診斷無聲 stub 失敗一文檢視的失敗模式;64 位元靜態連結的工程則記錄在FPC 下的 jbig2enc 靜態連結

Free Pascal 下 Win32 執行檔活不到 main 的診斷流程:unit 初始化執行期間載入器解析匯入,zlib 繫結在搜尋路徑上找到 64 位元 DLL,處理序在任何 Pascal 敘述之前就帶著 invalid-image 狀態死掉,促使 PDFlibPas 改走靜態編入的純 Pascal 壓縮路徑
毛病從來不在剛建好的那支程式:名叫 zlib 的 unit 其實是個 runtime 繫結,對著錯誤的架構解析,而運作良好的 fallback(例如內建的 JBIG2 編碼器)恰恰把缺失的相依藏了起來

記憶體串流上的 32 位元算術

用指標寬度的無號算術去操作緩衝區大小的程式碼,在 Win64 上是對的,在 Win32 上則離溢位只差一張大圖。餵給 JPEG 2000 codec 的記憶體串流以翻倍成長、以加法前進,在 32 位元目標上,這兩個運算都可能在很大、但完全合法的輸入上發生環繞溢位

因此每一次寫入、跳過、seek 與初始配置都先檢查再計算,容量上限取的是帶號指標寬度整數的最大值,刻意對齊區塊搬移常式與 callback 傳回值所能表達的範圍。拒絕請求時的行為要求很容易做錯:拒絕不得改變串流位置或長度。先改了一半然後回報錯誤,串流就停在呼叫端無從推斷的狀態,下一個操作還會把事情滾得更大

// 先檢查、再計算。在 Win32 上,一張大 JPEG 2000 影像給出的
// 合法輸入就足以讓下面兩處發生環繞溢位
if Needed > NativeUInt(High(NativeInt)) - FPosition then
  Exit(False);                    // 拒絕,位置與大小原封不動

NewCapacity := FCapacity;
while (NewCapacity < FPosition + Needed) do
begin
  if NewCapacity > NativeUInt(High(NativeInt)) shr 1 then
    Exit(False);                  // 翻倍會溢位
  NewCapacity := NewCapacity shl 1;
end;

比移植活得更久的兩個建置輸出陷阱

把測試與範例執行檔按目標架構分進各自的輸出目錄,顯然是對的,但也立刻弄壞了所有靠向上數目錄層級來定位測試資料的程式。修法是向上搜尋資產目錄,而不是假設固定深度,但保留一個刻意的限制:簽署範例只接受來自自己專案目錄的憑證 fallback,絕不往上亂認祖先目錄,因為在目錄樹更高處撿到的同名憑證是資安驚喜,不是便利

第二個陷阱比每一次移植都活得久,值得帶進任何 FPC 專案。編譯器升級之後,光讓編譯器拒收過期的 PPU 檔並不夠,因為連結器仍然偏愛 unit 搜尋路徑裡殘留的物件檔,就算它載入的 PPU 來自正確目錄也一樣,而加上明確的物件輸出路徑也蓋不過這個偏好。唯一可靠的答案是每一輪建置都換一個全新的暫存 unit 目錄。折扣方案做出來的二元檔是兩個編譯器版本連結出來的混血,壞起來活像原始碼 bug

平台條件編譯是最後一塊,而選對軸線比表面上重要。該問的問題通常是這段程式碼是不是 Windows 專屬,而不是某個特定 widget 函式庫在不在場,EMF 向量匯入與平台條件編譯一文的 metafile 轉換工程就是例子:把那道防衛從控制項函式庫條件換成平台條件,一場看似要整個重寫的工程縮成改一個指示詞。兩個 Windows 目標的 Free Pascal 與 Lazarus 支援隨 PDFlibPas Delphi PDF library 出貨,與 Delphi 及 C++Builder 套件由同一份原始碼建置