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,得到的是宿主选的行为——所以库不能假设任何一种结果
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 换掉了那个循环
我们没有把它缩成最小复现,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
| 表达式 | Win32 | Win64 |
|---|---|---|
Power(10, N),N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100),异常被屏蔽(Delphi 12+ 默认) | 1E100 | +Inf |
Power(10, 100),exOverflow 未屏蔽 | 1E100 | EOverflow |
D <= High(Int64),D = 2^63 | False | True |
Round(2^63),exInvalidOp 未屏蔽 | EInvalidOp | Low(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 组件页有下载与完整功能清单