同樣的 Object Pascal 原始碼在 Delphi 和 FPC/Lazarus 下,可能會在四個方面表現出不同的行為,且這四點一再地反咬 PDFium 元件的程式碼:FPC 會在 in 成員測試還沒讀取完畢前,就處置掉函式回傳紀錄的暫時變數 (temporary variables);dcc32 預設關閉了範圍檢查,所以超出邊界的陣列索引會默默地讀取垃圾資料;只有 Delphi 13 接受將匿名的 array of Byte 指派給 TBytes 而不需要轉型 (cast);而 Delphi 的 AnsiString 串接,可能會透過一個隱藏的字碼頁 (code-page) 往返轉換,破壞掉 $80 或以上的位元組。這當中的每一項都會產生一套在一種編譯器上是綠燈,在另一種編譯器上卻是紅燈(或者更糟的是,默默出錯)的測試套件
如果您是第一次設定雙編譯器專案,Lazarus 和 FPC 檢視器演練涵蓋了順利的路線:套件、搜尋路徑,以及讓渲染視窗出現在螢幕上。這篇文章是教學的相反。它是我們在順利路線運作之後碰到的事物清單,當時 CI 在 FPC 下是綠燈,在 Delphi 下也是綠燈,然後一個在其中一邊通過的變更,卻在另一邊引爆。下面每一個陷阱,都來自 PDFiumPas 測試套件或其示範程式中真實的失敗案例,並將 commit 等級的鑑識結果,濃縮成一個最小的重現範例、根本原因,以及我們標準化的修復方式
為什麼一個集合在 FPC 下讀取是空的,但在 Delphi 中卻不是?
一句話總結:FPC 可能會在讀取函式回傳紀錄的某個欄位的表達式完成之前,就把保存該回傳紀錄的暫時變數給終結 (finalize) 掉,所以 X in Func().Issues 可能會對一個已經釋放的集合進行成員測試,而同等的 Delphi 表達式卻能正常運作。我們的 PDF/E 一致性測試在第一個版本就踩到了這個坑。驗證器會回傳一個紀錄,其 Issues 欄位是一個違規旗標的集合 (set),而斷言 (assertions) 則是將呼叫行內化 (inlined)
// 在 FPC 下不可靠:函式回傳紀錄的暫時變數
// 可能會在 'in' 測試讀取 Issues 之前就被釋放
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// 在兩種編譯器上都可靠:先將結果固定到區域變數
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
行內化的形式在 FPC 下讀取到的集合是空的,所以每個預期要有旗標的斷言都失敗了,而完全一樣的 Delphi 建置卻通過了。根本原因是兩種編譯器在管理大型表達式內部的函式回傳暫時變數生命週期的方式不同:Delphi 會讓暫時變數存活到敘述 (statement) 結束,而 FPC 處置紀錄暫時變數的動作,可能會與仍在讀取它的集合成員運算子 (set-membership operator) 發生競爭 (race)。我們之前就已經在 PDF/A 測試單元的 FlagPresent 輔助函式註解中記錄過一次同樣的行為,然後在從頭撰寫新測試時還是重新引入了這個錯誤,這告訴您這個壞掉的寫法看起來有多自然。修復方式是機械化的,且值得採用為一項全面性的規則:永遠不要將欄位存取或集合測試直接串接在回傳紀錄的函式呼叫上;先將結果指派給一個區域變數,然後再讀取欄位。這只需要一行程式碼,就能消除一整個類別與編譯器相依的不穩定性
為什麼 Delphi 接受了一個 FPC 拒絕編譯的陣列索引?
一句話總結:dcc32 會編譯一個超出固定邊界陣列範圍的索引,並且在其預設的範圍檢查關閉的情況下,在執行階段默默讀取或寫入相鄰的記憶體而沒有任何錯誤,而 FPC 在編譯時就會拒絕同一個索引。PDFium 元件將 QuadPoints 宣告為一個 1-based 陣列,TQuadrilateralPoint = array [1..4] of TPdfPoint,這與 PDF 的 QuadPoints 條目通常的編號方式一致。一個用直覺的 0-based 迴圈來填滿它的示範程式,在 Delphi 下運作了好幾個月
var
I: Integer;
begin
for I := 0 to 3 do // 錯誤:陣列是 [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 預設:編譯通過,索引 0
// 默默觸碰相鄰記憶體
// FPC:編譯期範圍檢查錯誤
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // 在兩種編譯器上都正確
end;
Delphi 的建置是一個偽陽性 (false positive):在範圍檢查關閉(dcc32 預設值)的情況下,索引 0 落在了紀錄中排在該陣列前面的任何欄位上,而示範程式看起來有在執行。將同一個示範程式移植到 Lazarus 時,FPC 立即產生了編譯期的範圍檢查錯誤,而在修復索引後,就暴露出程式庫註解路徑中第二個更深層的錯誤,這個錯誤一直被垃圾資料讀取所掩蓋,也就是quad-points 註解文章中所剖析的那個。這次事件帶來了兩個教訓。首先,只要陣列類型在構造上不是 0-based,就盡量使用 Low() 和 High(),而不是字面常數 (literal) 邊界。其次,把一次 FPC 編譯,或者至少一次開啟了 {$R+} 的 Delphi 建置,當作任何新示範程式或測試的強制性首輪關卡:dcc32 的預設值不會告訴您這類錯誤,而一個會跑的程式不能作為它正確的證據
只有 Delphi 13 接受的 TBytes 指派
一句話總結:將宣告為匿名 array of Byte 的欄位指派給一個 TBytes 變數,在 Delphi 13 (編譯器版本 37.0) 上能編譯,但在 Delphi 12 Athens 以及任何更早的版本上都會失敗,出現 E2010 不相容的類型:'TArray<Byte>' 與 '動態陣列'。這與其說是 Delphi 對決 FPC 的分歧,不如說是 Delphi 對決它自己過去的分歧,但它以同樣的方式咬傷了同一個多編譯器程式碼庫:最新版的編譯器悄悄地接受了一個其他所有版本都拒絕的語法結構
type
TValidator = class
private
FBuffer: array of Byte; // 匿名的動態陣列類型
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // 僅限 Delphi 13;在 Delphi 12
// Athens 及更早版本會出現 E2010
OrigBytes := TBytes(FBuffer); // 到處都能編譯;相同的位元組配置,
// 安全的強制轉型
end;
我們在一個驗證常式中原封不動地釋出了這段程式碼,那是在 Delphi 13 上進行本機開發和測試的,它默默地接受了隱式轉換 (implicit conversion)。提供完整原始碼的安裝程式服務了大量使用 Delphi 12 及更舊版本的使用者,而對他們來說,這個單元根本無法編譯。結構性的修復方式,要麼是如上所示的強制轉型(這是安全的,因為匿名的 array of Byte 和 TBytes 共用相同的動態陣列配置),或者更好的做法,是一開始就將該欄位宣告為具名類型,例如 TBytes,這樣就不會發生轉換。流程上的修復更為重要:一個能在您最新的工具鏈上編譯的語法結構,並不能證明它在您的使用者實際執行的舊版編譯器上也能編譯,而在您針對每個支援的版本進行建置之前,這類的退化 (regression) 是看不見的。我們的發佈指令碼現在會針對整個編譯器矩陣編譯程式庫,正是因為本機的 37.0 建置無法抓出只有 13 版才有的寬容度
在中文 Windows 機器上消失的 AnsiString 位元組
一句話總結:在 Delphi 中,將一個 $80 或以上的原始位元組透過 + 串接進一個 AnsiString 中,可能會默默地將該位元組替換為 ? ($3F),因為這個表達式會透過系統字碼頁,經歷一個從 AnsiString 到 UnicodeString 再回到 AnsiString 的隱式往返轉換。我們是在一個 PDF/A 測試中發現這個問題的,該測試建構了一個包含獨立 $FE 位元組(這永遠不會是合法的 UTF-8 前導位元組)的名稱,以驗證驗證器會標記出不符合 ISO 19005-2 條款 6.1.8 的非合法 UTF-8 名稱
var
BadName: AnsiString;
begin
// 在具有多位元組系統字碼頁(在 CP936 上觀察到)的 Delphi 上,
// 串接操作會透過 UnicodeString 進行往返轉換,而 $FE,
// 不是一個有效的 CP936 序列,回來後會變成 '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// 安全做法:先用一個 ASCII 佔位符來建置,然後就地修補該位元組;
// 對一個已定型的 AnsiString 進行索引指派不會發生往返轉換
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
在一個執行字碼頁 936 的中文 Windows 系統上,串接後的字串裡面根本就沒有包含 $FE,所以程式庫正確地沒有回報任何問題,然後測試就亮了紅燈,看起來就像是程式庫的錯誤。程式庫從來就沒有錯:一個 FPC 測試架構(harness)餵入了一個真正包含 $FE 位元組的 PDF,並如預期地拿到了旗標。資料損壞是發生在 Delphi 測試執行檔內部,在評估字串表達式的時候,因為 Delphi 以 Unicode 為優先的字串模型會透過 UnicodeString 轉換混合的 AnsiString 表達式,而 $FE 在 CP936 中不是一個有效的前導位元組,所以往返轉換就把它替換掉了。這裡的邊界要老實說:在像 CP1252 這樣的單位元組西方字碼頁上,同樣的表達式通常能倖存下來,這正是為什麼這個錯誤在大多數的開發機器上會隱藏起來,而只會在東亞系統或在地化的 CI 執行器上浮出水面。我們採用的規則是:永遠不要透過 AnsiString 串接來建置包含 $80 或以上位元組的二進位測試向量;要麼在字串定型後就地修補位元組(如上所示),要麼一開始就用 TBytes 來建構向量
雙編譯器工作流程預設應該檢查什麼
四個陷阱,一個模式:每一個編譯器都會告訴您一組不同的錯誤。FPC 的編譯期範圍分析抓出了一個 dcc32 默默跑了好幾個月的越界索引,而 dcc32 的 Unicode 字串模型則暴露了一個純位元組導向的 FPC 建置永遠不會觸發的字碼頁相依性。實際的結果是,任何一條綠色的管線單獨來看都是不夠的。交叉編譯不只是一個可移植性的核取方塊,它是套用於同一份原始碼的第二個靜態分析器和第二個執行階段模型,其精神就如同ABI 與記憶體安全強化文章中所述的防禦性邊界檢查
從這些事件中衍生出來的常設規則短到可以背下來。在讀取欄位之前,將函式回傳的紀錄固定在一個區域變數。使用 Low() 和 High() 來走訪固定邊界的陣列,並且在信任任何新示範程式之前,至少執行一次開啟範圍檢查的編譯或是 FPC 建置。對匿名的動態陣列欄位進行明確的強制轉型,或者使用具名類型宣告它們,並在發佈前建置完整的編譯器矩陣。完全不要將原始的高位元組放入 AnsiString 串接中。一旦這些成為習慣,都不會花費可衡量的精力,而每一項都會封閉一個單一編譯器工作流程在結構上無法看見的失敗模式
這四個問題都是在維護 PDFium 元件 的過程中發現並修復的,該元件為 Delphi、C++Builder 和 FPC/Lazarus 提供了相同的 Object Pascal 原始碼,並在每一個工具鏈上執行其一致性和退化測試套件,因此本文中的陷阱是由測試所防範的,而不是靠記憶