建構在 C 函式庫之上的 Pascal 綁定讀起來就像普通的 Pascal。您呼叫一個方法,獲得一個記錄作為回傳,然後釋放您分配的記憶體。麻煩在於 PDFium 是一個 C 和 C++ 函式庫,它有自己的呼叫約定(calling convention)、自己的整數寬度,以及關於誰擁有記憶體和誰負責釋放的獨特規則。這一切都不會自動跨越語言邊界。這些契約的每一項都必須在 Pascal 宣告中手動重述,而只要一個詞寫錯,就會把一個看起來很乾淨的呼叫變成堆疊損毀、截斷的偏移量或雙重釋放。在針對 PDFium Component 綁定進行的 v1.61.0 稽核中,每種缺陷都發現了一個。它們值得我們深入探討,因為它們並非此綁定所獨有。它們是在 Delphi 或 Lazarus 中包裝任何 C API 時會面臨的常態性危險
cdecl 是函式類型的一部分,而不是裝飾品
PDFium 經過 C 編譯。在 Win32 上,其匯出項目以及更重要的、它呼叫的回呼(callbacks)使用了 cdecl 呼叫約定。在 cdecl 之下,呼叫者會在呼叫返回後清理堆疊。Delphi 原生的預設值是 register,而在某些函式庫中,Win32 C 的回呼標準是 stdcall,此模式下改由被呼叫者進行清理。當一個結構體將一個函式指標交給 PDFium,而您忘記在該指標的類型上加上 cdecl 時,雙方就會對誰來調整堆疊指標產生分歧。要不是雙方都調整,就是雙方都不調整,結果在每次呼叫時,堆疊指標都會偏移引數的大小
這個缺陷難以發現的原因在於損壞是非局部的。損壞的呼叫返回並看起來一切正常。未對齊的問題會在稍後出現在某個不相關的函式中,其框架現在位於偏移了幾個位元組的堆疊指標上,它表現為盲目讀取、錯誤的返回位址,或是崩潰時的堆疊追蹤指向與您實際弄錯的回呼毫不相干的地方。表單填寫是這種問題反咬一口的經典之處,因為表單填寫介面是一個充滿了 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 參數。一個 64 位元的偏移量來到一個 32 位元的參數會失去它的上半部分。對於小型檔案,每個偏移量都能放入 32 位元中,一切都沒問題。對於大型檔案,當偏移量跨越 4 GB 界線的那一刻,被截斷的值會完全指向其他地方,PDFium 會詢問錯誤的位元組範圍是否可用,而漸進式載入會停滯或讀取到垃圾資料。在檔案夠大,且目標平台確實將 size_t 加寬之前,這個缺陷是看不見的
Pascal 例外絕不能透過 C 框架展開 (unwind)
第三類是關於例外模型,這是 C 語言所沒有的。當 PDFium 呼叫您的一個回呼時,您的 Pascal 程式碼在對 Delphi 的例外機制一無所知的 C 和 C++ 框架堆疊內執行。如果您的回呼引發並讓例外傳播,它將會穿過那些從未被設計成用來展開的框架。PDFium 自身的清理工作無法執行,其內部的不變式(invariants)處於半更新狀態,而處理程序現在處於函式庫從未預料到的狀態中。這些回呼的契約是一個回傳代碼,而不是一個例外
兩個回呼讓這點變得具體。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 的契約,而不是跨越邊界引發例外。一個沒有重新引發(re-raise)的純粹 except 對受過訓練絕不吞噬例外的 Pascal 程式設計師來說看起來是錯的,而且在普通的 Pascal 中它的確是錯的。但在 ABI 邊界上它是正確的形狀,因為唯一可以安全交回給 C 呼叫者的值是它懂得如何解釋的狀態代碼。失敗仍然會傳播,只是透過傳回值,而當控制權回到柵欄的 Pascal 端後,函式庫上方的呼叫程式碼會將它作為 EPdfError 浮現出來
雙重釋放隱藏在錯誤路徑上
第四個缺陷是擁有權。一個 PDFium 檔案控制代碼由函式庫開啟,且必須精確地透過 FPDF_CloseDocument 關閉一次。危險在於一條錯誤路徑釋放了第二次清理也同樣擁有的控制代碼。想像一個常式(routine),它建立一個包裝物件,將一個新開啟的檔案控制代碼指派給它,然後進行可能會失敗的更多設定。如果設定拋出例外,一個在原始控制代碼上呼叫 FPDF_CloseDocument 的提早返回處理常式將關閉它,然後當包裝物件被釋放時,該物件自身的解構函式將再次關閉它。控制代碼被釋放了兩次,這是未定義的行為,很可能會導致崩潰
稽核在一個圍繞著已經開啟的控制代碼建置 TPdf 的拼版風格(imposition-style)匯入路徑上發現了這個問題。修復方法是讓擁有權轉移成為唯一的真相來源。一旦控制代碼指派給了包裝器的欄位,包裝器就擁有了它,錯誤路徑上的唯一清理工作就是釋放該包裝器。包裝器的解構函式會為您呼叫 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 或動態陣列的記錄。這些是參考計數(reference-counted)的欄位,編譯器會發出隱藏的簿記資訊來維持它們的計數。從 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 行為實際發揮作用的地方。這兩者都建置在同樣的記憶體安全與 ABI 工作之上,這些工作與本網誌其他地方涵蓋的渲染、文字萃取和表單 API 一起隨附於 Delphi、Lazarus 和 C++Builder 的 PDFium Component中