技術文章

強化 HotPDF 時發現的 Win64 專屬 Delphi bug

Win64 的 Delphi 程式碼可能在同一份原始碼於 Win32 上跑得好好的地方出錯,HotPDF Delphi PDF 元件最近一輪強化就撞上五個這類案例:Power(10, N) 綁到 Single 多載、while 迴圈讀到過期的 TList.Count、進位到 2^63 的 High(Int64) 上界、FPC 上只給 15 位的浮點文字,以及編不過去的測試斷言

只建置、只測 Win32 的話,這些一個都不會現形——它們就是這麼溜進來的。下面的案例來自 HotPDF 的 SVG 與 XPS 匯入器、頁面渲染器與 JSON 工作讀取器,引用的數值結果都以分別為 Win32 與 Win64 建置的小探針程式重現過。要把 Delphi 程式碼庫搬上 64 位元,每一條都值得 grep 一遍

為什麼 Power(10, 100) 只在 Win64 上溢位?

在 Win64 上,帶整數參數的 System.Math.Power(10, N) 解析到 Single 多載,結果以單精度計算並回傳,約 3.4E38 以上的值全數溢位。在 Win32 上,同一個呼叫綁到 Extended 多載、跑在 80 位元精度的 x87 FPU 上,Power(10, 100) 就是乾脆俐落的 1E100

System.Math 為 Extended、Double 與 Single 各宣告了 Power,外加一組對應的 IntPower 家族,指數是整數時 Power 會轉呼叫它。在 Win64 上,Extended 只是 Double 的別名(SizeOf(Extended) = 8),兩個整數參數時編譯器挑的是 Single 版。露出馬腳的是精度、不只是溢位:Win64 上 Power(10, 20) 回傳 1.0000000200408773E20,正是 Single(1E20)。Double 結果會印成 1E20。從 Delphi 10.3 到編譯器版本 37.0,我們試過的每個 Win64 編譯器都出現同一個綁定

接下來發生什麼,取決於浮點例外遮罩。Delphi 12 起預設遮罩所有浮點例外,溢位於是無聲:Power(10, 100) 回傳 +Inf,Power(10, -100) 回傳 0。Delphi 11 與更早讓 exOverflow 保持未遮罩,同一個呼叫丟 EOverflow。自己設遮罩的應用程式,以及載入這類宿主的 DLL,拿到的是宿主選的行為——函式庫因此不能預設任何一種結果

HotPDF 的 Win64 數值陷阱示意圖:帶整數參數的 System.Math Power 綁到 Single 多載,10 的 20 次方回傳 1.0000000200408773E20 而非 1E20,10 的 100 次方在例外被遮罩時得正無窮大、未遮罩時丟 EOverflow
精度損失就是破案線索:10 的冪次回來時若帶著 Single 的雜訊,就是錯的多載贏了——比例尺自己建
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 印 1E20;Win64 印 1.0000000200408773E20(Single 多載)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // 重現 Delphi 11、或 FP 設定嚴格的宿主的行為
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64:EOverflow;Win32:1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

在測試期間解除 exOverflow 與 exInvalidOp 的遮罩,是看見舊編譯器或嚴格宿主所見的最便宜辦法。預設設定的現代編譯器上,這 bug 不崩潰,它產生無窮大與零——這些在測試日誌裡難抓得多。finally 裡記得還原先前的遮罩:遮罩是每執行緒的狀態,測試的其餘部分會繼承您留下的任何東西

這個多載是怎麼進到 HotPDF 的 SVG 與 XPS 匯入的

HotPDF 的 SVG 與 XPS 路徑讀取器共用同一個數字掃描器,那個掃描器讀到指數之後,就用 Power(10, Exponent) 縮放尾數。任何交給 THotPDF.ImportSVGFormXObject 的 SVG(把 SVG 匯入 PDF 成可重複使用的 form XObject 背後的進入點),以及 XPS 與 OpenXPS 轉 PDF 期間處理的任何路徑幾何,都可能把 1e100 或 5e99 這樣的座標餵進那個呼叫

v2.770.91 早已把指數封頂在 100、並拒收會超過 1E300 的值,看起來夠了:1E100 離約 1.8E308 的 Double 極限遠得很。在 Win64 上還是溢位了,因為計算根本沒在 Double 裡發生過。從 v2.770.155 起,掃描器自己建 10 的冪次,1e-100 這類數字、或長尾數配大負指數,都讀出真實的值,而不是塌成 0

有界指數的安全 10 冪次

指數有界時,最安全的 10 冪次是用 Double 乘法自己疊出來的那個。最多 100 次乘法的迴圈,跟掃描它周圍的文字相比不值一提,中間值永遠不會大過最終的比例尺,而且在 Win32、Win64 與 Free Pascal 上行為一致

const
  MaxDecimalExponent = 100;

function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
  out Scaled: Double): Boolean;
var
  Scale: Double;
  I: Integer;
begin
  Scaled := 0;
  Result := False;
  if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
    Exit;
  // 拒收會跑出 Double 範圍的結果
  if (Exponent > 0) and (Value <> 0) and
     (Log10(Abs(Value)) + Exponent > 300) then
    Exit;
  Scale := 1.0;
  for I := 1 to Abs(Exponent) do
    Scale := Scale * 10.0;       // 永不超過 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // 用除的:1E-100 沒有精確的 Double
  Result := True;
end;

扛住重量的是三個細節。範圍檢查用兩次比較、而不是 Abs(Exponent) <= 100,因為 Abs(Low(Integer)) 仍是負數、會一路暢行無阻。負指數除以比例尺、而不是乘上預先算好的 1E-100——後者沒有精確的 Double、會多一步捨入。Log10 前置檢查則在乘法有機會溢位之前,拒收跑出 Double 範圍的結果

想清楚這個迴圈放棄了什麼。1E22 以內的 10 冪次在 Double 裡是精確的;再往上,每次乘法都有捨入,疊滿 100 次之後,比例尺離正確捨入的 1E100 差最後幾個位。畫座標的話這不可見。通用文字轉 double、要求逐位元重現每個值的場合,這就不夠了,得換成正確捨入的轉換演算法

dcc64 在 while 迴圈裡讀到過期的 TList.Count 時

我們觀察到 Win64 編譯器(dcc64,編譯器版本 37.0)為 while List.Count > Start do 產生的程式碼,會從清單尾端刪除、卻拿堆疊暫存值做比較、而不重讀 Count。修好它的改寫是 for ... downto 迴圈——定義上邊界只求值一次

這個迴圈隨 v2.769.3 進來:它讓渲染器的透明群組程式碼,把群組內建立的 soft mask 保活過兩趟渲染、事後再釋放。清理程式坐在一或兩趟 for 迴圈之後的 finally 區塊裡,位於每個 tile 的迴圈之內。化約成形狀,改前改後長這樣:

// 我們觀察到被 dcc64(編譯器版本 37.0)編錯的形狀
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
  while Masks.Count > Start do
  begin
    TObject(Masks[Masks.Count - 1]).Free;
    Masks.Delete(Masks.Count - 1);
  end;
end;

// 替代寫法:邊界只求值一次,沒有會過期的暫存
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // Delphi 12 起 TList.Count 是 NativeInt
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

產生的 Win64 程式碼裡,迴圈條件裡的 Count 與迴圈主體裡讀的 Count 共用同一個堆疊槽位。條件在進入時就對那個槽位比較——那時什麼都還沒寫進去——而 Delete 之後也沒有任何東西重整它。群組自己沒建立任何 soft mask 時,主體照樣執行、向空清單要第 -1 項,於是 64 位元建置裡,每個含這種透明群組的頁面都以 EListError 失敗。同一份原始碼的 Win32 程式碼是對的,v2.770.1 換掉了那個迴圈

HotPDF 渲染器清理裡的 Win64 程式碼產生陷阱示意圖:重讀 TList.Count 的 while 迴圈讓條件與主體共用一個堆疊槽位,dcc64 在 Delete 之後從不重整它,空的透明群組釋放了第 -1 項並丟出 EListError,修法是邊界只求值一次的 for downto 迴圈
務實的教訓比根因便宜:邊界固定的 downto 迴圈不會過期,而渲染器的工作在 dcc64 跑完整套測試之前都不算完

我們沒有把它化約成最小重現案例,DropMasksWhile 這種小的獨立迴圈很可能編譯正確;周圍的 try/finally 與巢狀迴圈看來有影響。把它當成我們在一個編譯器版本上觀察到的程式碼產生行為,而不是每個 Win64 編譯器的已知缺陷。務實的教訓比根因便宜:條件重讀集合數量、主體卻在縮減同一集合的迴圈,值得改寫成邊界固定的 for ... downto;渲染器的改動需要完整的 Win64 測試輪,不能只跑 Win32

定位只在最佳化 Win64 建置上現形的崩潰

故障只在最佳化的 Win64 建置上重現,定位只能靠 IDE 之外的工具。一個小的探針程式用 AddVectoredExceptionHandler 註冊向量化例外處理器,在第一個例外時以 RtlCaptureStackBackTrace 擷取堆疊,再用連結器以 -GD 寫出的詳細 map 檔把回傳位址翻成函式名稱。反組譯那個函式,便看到比較讀的是一個只在迴圈主體內被寫過的堆疊槽位 [rbp+0x298]。指責編譯器之前,您要的就是這種等級的證據——而它花的時間比單步追 release 建置還少

為什麼 High(Int64) 不是 Double 的安全上界?

Double 表示不了 High(Int64):把 9223372036854775807 轉成 Double,會向上捨入到恰好 2^63——比最大的 Int64 多一。Win64 上這個轉換就發生在比較本身之中,所以 D = 2^63 時 D <= High(Int64) 是 True,隨後的 Round 或 Trunc 就溢位了

Win32 藏住這一點,跟它藏住 Power 問題是同一個原因。比較在帶 64 位元尾數的 80 位元 Extended 精度下進行,那裡 High(Int64) 是精確的、2^63 也正確地比較為更大。Win64 沒有更寬的型別可退。超出範圍的轉換也不體面:我們的 Win64 測試裡,不管 exInvalidOp 有沒有被遮罩,Round(2^63) 都回傳 Low(Int64)——一次無聲的變號。Win32 遮罩時回傳同一個值、未遮罩時丟 EInvalidOp

HotPDF 的 Int64 邊界陷阱示意圖:Double 表示不了 High(Int64),Win64 的比較把上界進位到 2^63,D 等於 2^63 通過檢查、Round 無聲回傳 Low(Int64);Win32 在 80 位元 Extended 下比較、邊界精確、同一比較為 False
一個轉換就是整個 bug:上界捨入到您正要排除的那個值,所以天花板要寫成字面常數、配嚴格小於
運算式Win32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100),例外被遮罩(Delphi 12+ 預設)1E100+Inf
Power(10, 100),exOverflow 未遮罩1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63),exInvalidOp 未遮罩EInvalidOpLow(Int64)

HotPDF 在文件工作值背後的 JSON 讀取器裡撞上這個。JSON 對數字沒有任何範圍限制,舊的序列化器把任何 Frac(Value) = 0 的值用 Round 轉成整數,於是完全合法的 1e19 變成錯的整數或一個例外,看遮罩臉色。從 v2.770.169 起,整數值只在裝得進 Int64 時才寫成整數,其他一切保留浮點文字,而整數 getter 對超出範圍的值回傳呼叫方的預設值、而不是迴繞後的值

const
  TwoPow63 = 9223372036854775808.0;   // 2^63,在 Double 與 Extended 裡皆精確

function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
  R := 0;
  Result := not IsNan(Value) and not IsInfinite(Value) and
    (Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
  if Result then
    R := Trunc(Value);
end;

function JsonNumberText(const Value: Double): string;
var
  R: Int64;
begin
  // 呼叫方先拒掉 NaN 與無窮大:JSON 沒有它們的拼寫
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 的 ffGeneral 停在 15 位
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

上界是字面常數 9223372036854775808.0 配嚴格的 <。那個常數是 2^63,在 Double 與 Extended 裡都精確,所以比較在每個平台上意思相同。下界可以用 >=,因為 -2^63 恰好是 Low(Int64)。先用短路求值測 IsNan 與 IsInfinite,讓 NaN 與無窮大碰不到 Frac 與那些比較——宿主解除遮罩時,它們可能丟 EInvalidOp

Win64 上浮點轉文字到底給您幾位?

比您要的少,三個編譯器裡有兩個如此。Free Pascal 3.3.1 的 FloatToStrF(Value, ffGeneral, 17, 0) 在 Win64 上停在 15 位有效數字,1/3 回來變成 0.333333333333333,兩個不同的 Double 值可以序列化成同一串文字。Str(Value:24, Text) 加 Trim 產生科學記號的 17 位有效數字——同一個值得到 3.3333333333333331E-001——而且不管地區設定如何,一律以句點當小數分隔符。FPC 上的 HotPDF 在您的建置矩陣裡的話,HotPDF 的 Free Pascal 與 Lazarus Win64 支援筆記涵蓋其餘平台差異

Delphi 收下 17 位的要求,但兩個 Delphi 目標的輸出仍不一致:FloatToStrF(0.1, ffGeneral, 17, 0) 在 Win32 給 0.10000000000000001、在 Win64 給 0.1。Win64 的 RTL 在格式化與剖析兩端都可能引入最後一位的捨入誤差,所以更多位數只是縮小差距,保證不了每個 Double 位元樣式都熬得過文字往返。HotPDF 的文件不做這種承諾,您的也不該做——除非您自己出貨正確捨入的格式化器與剖析器。記得傳 TFormatSettings.Invariant,或在舊版 Delphi 上自己替換分隔符,免得德文或法文地區設定把逗號寫進 JSON

為什麼 Assert.AreEqual 在 Win64 上編不過?

對動態陣列寫 Assert.AreEqual(3, Length(Arr)),Win32 編得過、Win64 以 E2532「無法從不同引數型別推斷泛型型別引數」失敗,因為動態陣列的 Length 在 Win64 上回傳 NativeInt。一邊是 Integer 字面值、另一邊是 64 位元的 NativeInt,DUnitX 的泛型 Assert.AreEqual<T> 定不下單一的 T,建置就此停擺

TList.Count 從 Delphi 12 起也觸發同樣的錯誤——該屬性變成了 NativeInt;Delphi 11 仍宣告為 Integer。string 的 Length 在兩個平台都回傳 Integer、不受影響,所以錯誤出現在某些測試單元、又不在另一些。把型別引數寫明白:Assert.AreEqual<NativeInt>(3, Length(Arr)),並在提交之前用 dcc64 編譯測試專案。只為 Win32 建置過的測試套件,不會主動告訴您它的 Win64 建置已經壞了,要等別人去試

Delphi 數值程式碼的 Win64 移植檢查清單

  • 搜尋帶整數參數的 Power( 與 IntPower( 呼叫;傳入 Double 型別的值,或自己建有界的 10 冪次
  • 數值測試至少有一輪用 SetExceptionMask 解除 exOverflow 與 exInvalidOp,Win32 與 Win64 都要
  • Int64 上界寫成 < 9223372036854775808.0、絕不寫 <= High(Int64),並在任何比較之前拒掉 NaN 與無窮大
  • 別只因為 Frac 是 0 就把剖析出的數字轉成 Int64;JSON 數字可以大得多
  • 邊刪邊重讀 Count 的 while 迴圈,改寫成邊界固定的 for ... downto 迴圈
  • FPC Win64 上需要 15 位以上有效數字時,用 Str(Value:24, Text)
  • Length 與 Count 的斷言用 Assert.AreEqual<NativeInt>,提交前用 dcc64 編譯測試
  • 剖析器或渲染器有任何改動之後,Win32 與 Win64 都要跑完整迴歸套件,不能只跑一邊

這裡描述的函式庫側修正,全部自 v2.770.169 起進入 HotPDF,SVG 匯入、XPS 轉換、透明渲染與 JSON 工作處理,在 Win64 上如今與 Win32 行為一致。您若用 Delphi 或 C++Builder 為兩個平台產生或處理 PDF 檔,HotPDF Delphi PDF component 頁面上有下載與完整功能清單