技术文章

加固 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。我们试过的每个 Win64 编译器都是这个绑定,从 Delphi 10.3 到编译器版本 37.0

接下来发生什么取决于浮点异常掩码。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 导入 PDF 作为可复用 form XObject背后的入口)的任何 SVG,以及 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 块里,又在每瓦片循环之内。压缩成形状,改前改后长这样:

// 我们观察到被 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 问题是同一个原因。比较在 80 位 Extended 精度、64 位尾数下进行,那里 High(Int64) 是精确的,2^63 正确地大于它。Win64 没有更宽的类型可退。越界转换也不体面:在我们的 Win64 测试里,无论 exInvalidOp 屏不屏蔽,Round(2^63) 返回的都是 Low(Int64)——无声的符号翻转。Win32 在屏蔽时返回同样的值,未屏蔽时抛 EInvalidOp

HotPDF 的 Int64 边界陷阱:Double 表示不了 High(Int64),Win64 的比较把上界进位到 2^63,等于 2^63 的 D 通过检查、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 失败:「Couldn't infer generic type argument from different argument types」,因为动态数组的 Length 在 Win64 上返回 NativeInt。一边是 Integer 字面量、另一边是 64 位 NativeInt,DUnitX 的泛型 Assert.AreEqual<T> 定不下唯一的 T,构建就此停下

Delphi 12 起 TList.Count 触发同样的错误——属性变成了 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 组件页有下载与完整功能清单