技術文章

將 _ftol 內嵌組語從 32 位元 Delphi 移植至 DCC64

在 32 位元 Delphi 中,原始的 _ftol 寫法看起來像是個聰明的單行程式碼:一個 Pascal 函式包裝,並直接落入內嵌組語 (inline assembly) 以操作 x87 FPU 控制字組 (control word)、截斷 (truncate) 在 FPU 堆疊上的數值,然後將結果彈出。它在 DCC32 下能順利編譯好長一段時間,而這正是為什麼它會出現在如此多早期的圖形與 PDF unit 之中,卻沒有人質疑它的原因

將建置目標切換到 64 位元時,編譯器會以 E1025 Unsupported language feature: '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 位元的呼叫慣例 (calling convention),且能推斷區域變數與參數的位置,因此它容許能透過名稱觸及堆疊框架的組語片段。DCC64 則採取了更嚴格的立場:組語必須位於專屬的組譯函式中,即整個本體都是組語,並且明確處理呼叫慣例。完全不支援混合 Pascal 加上 asm

根本原因是架構上的不同。在 64 位元 Windows 的呼叫慣例 (Microsoft ABI) 中,前四個參數如果是整數型別會從 RCXRDXR8R9 傳入,如果是浮點數則會從 XMM0XMM3 傳入。正常的參數傳遞過程完全沒有 x87 FPU 的參與;技術上 x87 是可用的,但 ABI 並不使用它來傳遞引數。假設某個數值在「FPU 堆疊上」的組語,是在推論一個 64 位元 ABI 從未創造過的狀態

因此,舊的片段不僅有語法上的問題。即使 DCC64 接受了它,對暫存器的假設也會是錯誤的

撰寫適當的 64 位元組譯版本

當您確實需要為了二進位相容性而使用 cdecl 慣例匯出 _ftol 符號時,該函式必須撰寫為純組譯常式。在 64 位元 ABI 下,一個 Double 參數會來到 XMM0,且整數結果返回時必須在 RAX 之中。.NOFRAME 指令告訴 DCC64 該常式會自行管理堆疊,這對於這麼短的葉節點函式 (leaf function) 來說是很合適的:

function _ftol: Integer; cdecl;
// 依據 64 位元 ABI,預期 Double 數值在 XMM0 之中
asm
  .NOFRAME
  cvttsd2si  rax, xmm0   // 截斷為整數 (truncate-to-integer),結果存於 rax
end;

CVTTSD2SI 是一個將雙精確度浮點數轉換為帶號整數 (向零截斷) 的 SSE2 指令,而這正是 _ftol 應該做的事。這只是一個指令,它直接從 ABI 留下的位置取得參數,並將結果放在 ABI 預期的位置。不需要去耍弄 FPU 控制字組

請注意,如果輸入值超出了 32 位元帶號整數的範圍,CVTTSD2SI 會傳回整數的未定義值 (integer indefinite value,即 $80000000)。這與 x87 的 fistp 在遇到超出範圍的輸入時的行為相同。在宣告移植完成之前,值得去確認您的呼叫端是否會產生這樣的數值

何時 Trunc 會是更好的解答

上述的組譯版本只在您有實際的二進位相容性需求時才值得撰寫:某些外部呼叫端預期以特定的呼叫慣例存取名為 _ftol 的符號,且您無法修改這些呼叫端。這種情況並不常見。大多數時候,_ftol 只是僅在同一個 unit 內使用的私有輔助函式,根本沒有對其名稱或慣例的外部相依性

若是這種情況,請以純 Pascal 來取代它:

function _ftol(f: Double): Integer; cdecl;
begin
  Result := Trunc(f);
end;

Trunc 會向零截斷,這與當 x87 控制字組設定為截斷模式時 _ftol 所做的事相符。它在 DCC32 與 DCC64 上無需修改即可編譯。編譯器會為每個目標環境產生適當的指令:在 x64 上,它通常還是會發出 CVTTSD2SI,與手寫版本的指令相同。您能獲得完全一致的行為、無需平台條件式編譯,也無需維護任何組語

有一個值得檢查的語意差異:在 Delphi 的預設設定下,當輸入值為 NaN 或無限大時,Trunc 會引發 EInvalidOp 例外狀況。而原始程式碼中的 x87 fistp 只是寫入一個位元模式而不會引發任何問題。如果您的程式碼會餵給這個函式不尋常的浮點數值,且舊的行為是默默吞下,那麼在呼叫 Trunc 之前,請先使用 Math 提供的 IsNaNIsInfinite 進行防護

當兩個目標環境都需保持活躍時的條件式編譯

有些專案必須繼續發布 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 位元的分支就可以完全移除了

如果這個函式出現在被多個 unit 所使用的元件中,請先搜尋整個程式碼基底中的 _ftol,然後再決定該如何移植。同名的符號可能會宣告在不只一個地方;連結器會挑選其中一個並默默忽略其他的,這意味著您可能修好了一份複本,卻仍連結到另一份尚未修改的版本