在 32 位 Delphi 中,原本的 _ftol 习惯用法看起来像是聪明的一行代码:一个 Pascal 函数包装器,它陷入内联汇编以操作 x87 FPU 控制字,截断 FPU 堆栈上的值,然后弹出结果。它在 DCC32 下编译良好运行了很长一段时间,这正是它在许多较旧的图形和 PDF 单元中最终被使用而未受到任何人质疑的原因。
将构建目标切换到 64 位,编译器会以 E1025 Unsupported language feature: 'ASM'(E1025 不支持的语言特性:‘ASM’)终止。该错误不是兼容性警告。它意味着 DCC64 完全不会编译该例程,不管汇编以前运行得多好。
32 位原版通常看起来像这样:
function _ftol(f: Double): Integer; cdecl;
begin
asm
lea eax, f
fstp qword ptr [eax]
end;
Result := Trunc(f);
end;
这种在 Pascal begin...end 结构体内部包含 asm 块的情况正是 DCC64 所拒绝的。两个编译器对于允许放置汇编的位置有着不同的规则,而边界是很重要的。
为什么 DCC64 划出的界线不同
DCC32 允许在普通的 Pascal 例程中使用内联汇编。该编译器了解 32 位调用约定,并且能够推理出局部变量和参数存放在何处,所以它容许汇编片段通过名称深入栈帧。DCC64 采取了更为严格的立场:汇编必须存在于专用的汇编函数中,在这样的函数里,整个函数体都是汇编语言,并且会显式处理调用约定。完全不支持将 Pascal 和汇编混合使用。
深层的原因是体系结构上的。在 64 位 Windows 调用约定(Microsoft ABI)中,前四个整数类型的参数是通过 RCX、RDX、R8 和 R9 传递的,或者浮点类型参数是通过 XMM0 到 XMM3 传递的。在正常的参数传递中,并没有 x87 FPU 的参与;x87 在技术上是可用的,但 ABI 不会利用它来进行参数传送。假设值在“FPU 栈上”的汇编代码正在针对一种 64 位 ABI 永远不会创建的状态进行推理。
所以旧的代码片段并不仅仅是存在语法问题。就算 DCC64 接受了它,针对寄存器的假设也会是错的。
编写一个恰当的 64 位汇编版本
当您确实需要为了二进制兼容性导出一个带有 cdecl 约定的 _ftol 符号时,该函数必须被编写为一个纯汇编例程。在 64 位 ABI 规范下,Double 参数传入在 XMM0 中,并且在返回时,整数结果必须位于 RAX 中。.NOFRAME 指令告诉 DCC64 这个例程管理自己的栈,这对于像这样短小的叶子函数是合适的:
function _ftol: Integer; cdecl;
// 根据 64 位 ABI 预期在 XMM0 中的 Double 值
asm
.NOFRAME
cvttsd2si rax, xmm0 // 截断为整数,结果在 rax 中
end;
CVTTSD2SI 是用于将双精度浮点数转换为向零截断的带符号整数的 SSE2 指令,而这正是 _ftol 应该执行的操作。这是一条单指令,它直接从 ABI 留下参数的地方获取参数,并将结果放置在 ABI 期望的位置。不需要去摆弄 x87 控制字。
请注意,如果输入超出了 32 位带符号整数的范围,CVTTSD2SI 会返回整数的未定值($80000000)。这与 x87 的 fistp 对越界输入的行为相同。在宣布迁移完成前,弄清楚你的调用方是否能生成这些值是非常值得去确认的。
什么时候 Trunc 是更好的答案
上面的汇编版本只有在你确实有二进制兼容要求时才值得编写:一些外部调用者期望一个名为 _ftol 具有特定调用约定的符号,而你无法改变那些调用者。这种情况并不常见。大多数时候,_ftol 是仅在同一单元中使用的私有辅助函数,根本不存在对其名称或约定的外部依赖。
对于这种情况,可以将其替换为普通 Pascal 语言:
function _ftol(f: Double): Integer; cdecl;
begin
Result := Trunc(f);
end;
Trunc 向零截断,这与 _ftol 在 x87 控制字被设置为截断模式时所做的一致。在 DCC32 和 DCC64 下它均无需修改即可编译。编译器会为各个目标生成合适的指令:在 x64 上它通常仍会输出 CVTTSD2SI,这跟手工编写版本的指令是一样的。你可以得到完全相同的行为,没有平台条件,也不需要维护汇编。
值得检查的一个语义差异是:在 Delphi 的默认配置下,当输入为 NaN 或无穷大时,Trunc 会引发一个 EInvalidOp 异常。原代码中的 x87 fistp 只是写入一个位模式,什么异常都不会引发。如果你的代码向这个函数输入了不寻常的浮点数,且原先的行为是静默的,那么在调用 Trunc 前,使用 Math 中的 IsNaN 和 IsInfinite 进行防范。
当双目标保持活跃时的条件编译
有些项目必须继续交付 32 位和 64 位的二进制文件。如果原汇编版本必须留给 32 位,并且要为 64 位提供新实现,使用 CPUX64 条件分支:
function _ftol(f: Double): Integer; cdecl;
begin
{$IFDEF CPUX64}
Result := Trunc(f);
{$ELSE}
// 32 位路径:DCC32 接受内联汇编
asm
lea eax, f
fstp qword ptr [eax]
end;
Result := Trunc(f);
{$ENDIF}
end;
这是最基础的机械修复手段,应当把它看作是临时方案。在一个专门把浮点数截断成整数的辅助工具中保留特定架构汇编代码,这种代码库背负着不必要的技术债。一旦您确认没有别的什么东西在依赖旧实现的 FPU 副作用,32 位分支就可以完全去除了。
如果该函数出现在多个单元使用的组件中,在决定如何迁移前对整个代码库进行 _ftol 搜索。名为该名称的符号可能在多于一个地方被声明;链接器只会选择其中一个并静默忽略其它的,这意味着你可能修复了一个副本,但仍然在链接另一个尚未被触及的。