C 函式庫之上的 Pascal 繫結讀起來就像一般的 Pascal;您呼叫方法、取得記錄、釋放您配置的空間;問題在於 PDFium 是一個 C 和 C++ 函式庫,具有其自身的呼叫慣例、自身的整數寬度,以及關於誰擁有記憶體和誰釋放記憶體的自身規則;這些協定本身都不會跨越語言邊界;每一個協定都必須在 Pascal 宣告中手動重新說明,而單個錯誤的字詞就會將看起來乾淨的呼叫轉化為堆疊損壞、截斷的位移量或重複釋放;針對 PDFium VCL 繫結的 v1.61.0 稽核發現了每種型態的一個缺陷;它們值得探討,因為它們並非此繫結所特有;它們是將任何 C API 包裝在 Delphi 或 Lazarus 中時一直存在的危害
cdecl 是函數型態的一部分,而不是裝飾
PDFium 是編譯後的 C;在 Win32 上,它的匯出,更重要的是它叫用的回呼,都使用 cdecl 呼叫慣例;在 cdecl 下,呼叫者在呼叫返回後清理堆疊;Delphi 的原生預設是 register,而一些函式庫中回呼的 Win32 C 標準是 stdcall(由被呼叫者清理);當結構向 PDFium 傳遞函數指標而您忘記了該指標型態上的 cdecl 時,雙方就會對誰來調整堆疊指標產生分歧;兩者都修正,或者都不修正,且堆疊指標在每次叫用時都會漂移引數的大小
此缺陷難以被發現的原因是其損害是非局部的;損壞的呼叫返回並且看起來正常;錯位稍後會顯現在某些無關的函數中,其訊框(Frame)此時位於漂移了幾個位元組的堆疊指標上,並表現為亂讀、錯誤的返回位址,或是帶有指向與您實際弄錯的回呼毫不相干之回溯追蹤的崩潰;表單填寫是這種情況咬人的典型地方,因為表單填寫介面是一個充滿了 PDFium 所叫用回呼的記錄;其中之一 FFI_OpenFile 向 PDFium 傳遞一個它將叫用以開啟外部檔案的函數,宣告為 function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl;結尾的 cdecl 是值得複製的重點;丟棄它,程式碼仍然可以編譯、仍然可以連結,並且仍然可以執行,直到 PDFium 呼叫該函數為止;該慣例屬於函數型態本身;它不是可選的糖衣,且當它缺失時編譯器不會警告您,因為簡單的函數型態是完全合法的 Pascal 型態;唯一的防禦是將呼叫慣例視為每個匯入特徵標記以及您向外傳遞的每個回呼的必填欄位
size_t 是指標寬度,在 FPC Win64 上這代表 64 位元
第二個缺陷是僅出現在一個目標平台上的整數寬度不匹配;C 的 size_t 被定義為足夠寬以容納任何物件大小,在 64 位元平台上這意味著 64 位元無符號整數;PDFium 的漸進式載入介面使用 size_t 位元組位移量進行溝通;可用性提供者的 FX_FILEAVAIL 記錄攜帶一個 IsDataAvail 回呼,PDFium 使用位移量和大小來呼叫它,且 FX_DOWNLOADHINTS 記錄的 AddSegment 回呼也接收相同的參數;兩個參數都是 size_t
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
如果您將這些位移量宣告為 32 位元型態,繫結在 Win32 和 Delphi Win64 上運作正常,但在 FPC 和 Lazarus Win64 上會靜默地失效;原因很微妙;在 FPC Win64 上,NativeUInt 是真實的指標寬度 64 位元型態,且 size_t 是它的別名;繫結在型態區段中有一行註釋,精確地警告不要在 FPC 上遮蔽 NativeUInt,因為在那裡將其重新定義為 32 位元別名會迫使 size_t 變為 32 位元,並損壞傳遞給該函式庫或由該函式庫寫入的每個 size_t 參數;到達 32 位元參數的 64 位元位移量會失去其前半部分;對於小檔案,每個位移量都符合 32 位元,沒有任何問題;對於大檔案,一旦位移量越過 4 GB 界線,截斷後的值就會指向完全不同的地方,PDFium 會詢問錯誤的位元組範圍是否可用,漸進式載入就會停頓或讀取垃圾;該缺陷是不可見的,直到檔案足夠大且目標平台正是 size_t 實際變寬的平台
Pascal 異常絕不能透過 C 訊框展開
第三類是關於異常模型,這是 C 所沒有的;當 PDFium 呼叫您的回呼之一時,您的 Pascal 程式碼會在對 Delphi 異常機制一無所知的 C 和 C++ 訊框堆疊中執行;如果您的回呼引發異常並讓異常傳播,它將透過從未設計用於展開的訊框進行展開;PDFium 自身的清理工作不會執行,其內部不變量處於半更新狀態,程序現在處於該函式庫從未預料到的狀態;這些回呼的協定是返回碼,而不是異常
兩個回呼使這一點變得具體;FPDF_FILEWRITE 是 PDFium 寫入已儲存檔案的接收端(Sink),而 FPDF_FILEACCESS 是它從中讀取輸入檔案的來源端;兩者在此處都是基於 Delphi TStream 實現的,且兩者都可能像任何串流一樣失敗:磁碟填滿、串流在下方被關閉、讀取超出末尾;寫入回呼包裝了其串流寫入,並將任何失敗轉換為 PDFium 的失敗碼,而不是讓其逃逸
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
讀取端也做同樣的事:失敗的讀取會回報零以符合 FPDF_FILEACCESS 協定,而不是跨越邊界引發異常;對於接受過絕不吞掉異常之訓練的 Pascal 程式設計師來說,沒有重新引發的裸 except 看起來是錯誤的,且在一般的 Pascal 中它確實是錯誤的;但在 ABI 邊界上,它是正確的形狀,因為傳回給 C 呼叫者的唯一安全值是它知道如何解釋的狀態碼;失敗仍然會傳播,只是透過傳回值進行,且一旦控制權回到 Pascal 側,函式庫上方的呼叫端程式碼就會將其呈現為 EPdfError
重複釋放隱藏在錯誤路徑上
第四個缺陷是所有權;PDFium 檔案控制代碼由函式庫開啟,且必須精確關閉一次(由 FPDF_CloseDocument 執行);危險在於錯誤路徑會釋放第二個清理程式也擁有的控制代碼;想像一個建立包裝器物件、將新開啟的檔案控制代碼分配給它,然後進行可能失敗的更多設定的常式;如果設定擲出異常,叫用原始控制代碼上 FPDF_CloseDocument 的提前返回處理常式將會關閉它,然後當物件釋放時,包裝器物件自身的解構子將會再次關閉它;控制代碼被釋放兩次,這是未定義的行為,且很可能導致崩潰
稽核在拼版樣式匯入路徑上發現了這一點,該路徑圍繞著一個已開啟的控制代碼建置 TPdf;修正方法是使所有權轉移成為單一事實來源;一旦控制代碼分配給包裝器的欄位,包裝器就擁有它,且錯誤路徑上唯一的清理就是釋放包裝器;包裝器的解構子會為您呼叫 FPDF_CloseDocument,因此第二次顯式關閉會重複釋放同一個檔案;修正後的錯誤處理常式會釋放物件並重新引發異常,且只有一條通往關閉的路徑
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
受管理記錄以及充滿匯出的函式庫都需要顯式卸載
最後一類是關於編譯器代表您管理的記憶體,C 的習慣會靜默地損壞它;此繫結的許多輔助函數返回包含 WideString 或動態陣列的記錄;這些是引用計數欄位,且編譯器會發出隱藏的記帳以維護其計數;從 C 延續下來的直覺是使用 FillChar(Result, SizeOf(Result), 0) 清除新的記錄;這會將零蓋在記錄內受管理的引用上,而沒有先對其進行遞減;編譯器在迴圈反覆運算中重複使用一個用於函數結果的隱藏暫存變數,因此在第二次反覆運算時,FillChar 覆寫了一個從未釋放的活動字串指標,且它指向的字串會洩漏;在迴圈中呼叫該函數處理一千個註釋,您就會洩漏一千個字串
修正方法是讓語言以其知道的方式(使用 Default(T))清除記錄,這會在將其歸零之前釋放任何受管理的欄位
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
相關的所有權問題存在於函式庫載入邊界上;此繫結在 LoadLibrary 之後,使用 GetProcAddress 從 PDFium DLL 解析數百個函數指標;如果遺失了一個必要的匯出,部分繫結的狀態就是危險的:數十個指標有效,其餘為 nil 或過期,且稍後透過其中之一進行的任何呼叫都會跳入可能已經卸載的模組中;繫結透過卸載該函式庫並在必要匯出解析失敗時執行完整的 ClearAllBindings(將每個匯入的指標重設為 nil)來處理此問題;在此之後,沒有函數指標會懸空進入已卸載的模組中,且稍後的呼叫會因 nil 指標檢查而乾淨地失敗,而不是分支進入已釋放的程式碼中
包裝器是四個協定被手動重新說明的地方
這五個缺陷都不可怕;它們是 C API 之上薄 Pascal 層的預期失敗模式,且它們聚集在一起,因為該層正是必須重新宣告四個獨立協定的地方;呼叫慣例必須在每個回呼上拼寫為 cdecl;整數寬度必須在實際變寬的單個目標上匹配 size_t;異常模型必須在跨越 Pascal 的每個回呼處轉換為傳回碼;每個控制代碼和每個受管理欄位的所有權都必須被說明一次,並在每條路徑上遵守,包括直到實際工作環境才有人執行的錯誤路徑;遺漏任何一個,您就會得到一個症狀出現在遠離其根本原因之處的缺陷,這就是此類別代價高昂的原因;稽核的價值與其說在於任何單一的修正,不如說在於將其中每一項視為其自身的規範,以便在整個繫結中進行檢查
如果您想看到繫結在進行實際工作而不是防守邊緣,我們關於轉譯快取和縮放效能的筆記中顯示了轉譯路徑,而在建置 Lazarus 和 FPC 檢視器中的編譯器逐步說明則是此處所述的 Win64 size_t 行為實際發揮作用的地方;兩者都建置在隨 Delphi、Lazarus 和 C++Builder 的 PDFium 元件一起出貨的相同記憶體安全和 ABI 工作之上,並與本部落格其他地方介紹的轉譯、文字擷取和表單 API 搭配