技術記事

32ビットDelphiからDCC64への_ftolインラインアセンブリの移植

32ビット Delphi の元々の _ftol イディオムが存在するのは、C オブジェクトファイルのせいです。ユニットが 32 ビット C コンパイラの作った .obj ファイルをリンクすると、それらのオブジェクトは float から整数へのキャストのたびにランタイムヘルパーの _ftol を呼びます。値はパラメータとして渡されるのではなく、x87 スタックの頂上 ST(0) に置かれたままです。そのため Delphi 側の代役はダミーの Double パラメータを宣言し、2 行のインラインアセンブリで ST(0) をそこへポップし、それから Pascal で切り捨てます。これは DCC32 の下で長い間問題なくビルドできており、まさにそのせいで、誰にも疑われることなく古いグラフィックスや PDF ユニットの多くに居座ることになったのです

ビルドターゲットを64ビットに切り替えると、コンパイラは 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が拒否するものです。2つのコンパイラには、アセンブリが許可される場所について異なるルールがあり、その境界が重要なのです

DCC64が異なる線を引く理由

DCC32は、通常のPascalルーチン内でのインラインアセンブリを許可します。コンパイラは32ビットの呼び出し規約を認識しており、ローカル変数やパラメーターが存在する場所を推論できるため、名前によってスタックフレームにアクセスするアセンブリフラグメントを許容します。DCC64はより厳格な立場をとります。アセンブリは専用のアセンブラ関数、つまり本体全体がアセンブリであり、呼び出し規約が明示的に処理される関数内に存在する必要があります。Pascalとasmの混在は一切サポートされていません

根本的な理由はアーキテクチャ上のものです。64ビットWindowsの呼び出し規約(Microsoft ABI)では、最初の4つのパラメーターは、整数型の場合は RCX、RDX、R8、R9 に、浮動小数点の場合は XMM0 から XMM3 に到着します。通常のパラメーターの受け渡しにx87 FPUは関与しません。技術的にはx87は利用可能ですが、ABIは引数の転送にそれを使用しません。値が「FPUスタック上」にあると想定するアセンブリは、64ビットABIが決して作成しない状態について推論していることになります

したがって、古いフラグメントには構文上の問題があるだけではありません。DCC64がそれを受け入れたとしても、レジスタの仮定が間違っているのです

Delphi の比較図。Pascal ルーチン内のインラインアセンブリをコンパイルする DCC32 と、E1025 で中断し Win64 ABI 下で純粋アセンブラ関数のみ許可する DCC64 の対比
DCC32 は Pascal ルーチン内の asm ブロックを許しますが、DCC64 は E1025 で応答します。Win64 ABI は引数を x87 スタックではなくレジスターで運ぶためです

適切な64ビットアセンブラバージョンの作成

まず、そもそも 64 ビットの _ftol が必要かどうかを確認します。Win64 の C コンパイラは、MSVC も Clang 系の C++Builder ツールチェーンも、SSE2 命令で float を整数にインライン変換し、_ftol の呼び出しを生成しません。そのため 64 ビットのオブジェクトファイルがこのシンボルを参照することはまれです。バイナリ互換のために cdecl 規約で _ftol シンボルをエクスポートする必要が本当に生じたときは、関数を純粋なアセンブラルーチンとして書く必要があります。64 ビット ABI では Double パラメータは XMM0 に届き、Integer の結果は RAX の下位半分である EAX に返ります。.NOFRAME ディレクティブは、このルーチンが自分でスタックを管理することを DCC64 に伝えます。これほど短いリーフ関数には妥当な指定です:

function _ftol: Integer; cdecl;
// 64-bit ABIではXMM0にDouble値が届く
asm
  .NOFRAME
  cvttsd2si  eax, xmm0   // 32ビット整数へ切り捨て、結果はeax
end;

CVTTSD2SI は、倍精度浮動小数点をゼロに向けて切り捨てて符号付き整数に変換するためのSSE2命令であり、これはまさに _ftol が行うべきことです。これは単一の命令であり、ABIが残した場所からパラメーターを直接受け取り、ABIが期待する場所に結果を配置します。FPUコントロールワードをやりくりする必要はありません

32 ビットの格納先には意味があります。入力が 32 ビット符号付き整数の範囲を超えると、格納先を EAX とした CVTTSD2SI は整数不定値($80000000)を返します。代わりに RAX と書くと 64 ビット変換になり、231 から 263 の間の入力は、下位 32 ビットがたまたま持つ値として返ってきます。これは範囲外入力に対する x87 fistp と同じ挙動です。呼び出し側がそういう値を生み出しうるかどうかは、移行完了を宣言する前に確認する価値があります

Delphi: データパス図。Double パラメーターが XMM0 に到着し、1 つの CVTTSD2SI 命令で切り捨て整数に変換されて EAX に返る。範囲外の値は整数不定パターンを生む
DCC64 では double は XMM0 に届き、CVTTSD2SI が切り捨てた 32 ビット結果を 1 つの SSE2 命令で EAX に置きます

Truncがより良い答えである場合

上記のアセンブラバージョンは、実際のバイナリ互換性の要件がある場合にのみ作成する価値があります。つまり、外部の呼び出し側が特定の呼び出し規約で _ftol シンボルを期待しており、それらの呼び出し側を変更できない場合です。そのような状況はまれです。ほとんどの場合、_ftol は同じユニット内でのみ使用されるプライベートなヘルパーであり、その名前や規約に対する外部依存関係はまったくありません

その場合は、プレーンなPascalに置き換えます:

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

Trunc はゼロに向けて切り捨てます。これは、x87コントロールワードを切り捨てモードに設定して _ftol が行っていたことと一致します。DCC32とDCC64で変更なしでコンパイルされます。コンパイラはターゲットごとに適切な命令を生成します。x64では、いずれにせよ通常は手書きバージョンと同じ命令である CVTTSD2SI を出力します。同一の動作が得られ、プラットフォームの条件文もなく、保守するアセンブリもありません

確認する価値のある1つのセマンティックな違いがあります。入力がNaNまたは無限大の場合、Delphiのデフォルト設定では Trunc は EInvalidOp 例外を発生させます。元のコードのx87 fistp は、何も発生させずに単にビットパターンを書き込んでいました。コードがこの関数に異常な浮動小数点値を供給しており、古い動作が静かであった場合は、Trunc を呼び出す前に Math の IsNaN と IsInfinite で保護してください

_ftol 移植の Delphi 分岐図。外部呼び出し側が cdecl シンボルを必要としなければ素の Trunc に置き換え、そうでなければ純粋アセンブラエクスポートを維持し、NaN や無限大入力を EInvalidOp から保護
外部の呼び出し元がエクスポートされたシンボルに依存していない限り素の Trunc へ移行し、どちらの場合でも NaN や無限大の入力を EInvalidOp から守ります

両方のターゲットがアクティブなままの場合の条件付きコンパイル

一部のプロジェクトでは、32ビットと64ビットの両方のバイナリを出荷し続ける必要があります。32ビット用に元のアセンブラバージョンを保持し、64ビット用に新しい実装を提供する必要がある場合は、CPUX64 条件文を使用します:

function _ftol(f: Double): Integer; cdecl;
begin
{$IFDEF CPUX64}
  Result := Trunc(f);
{$ELSE}
  // 32-bit path:DCC32はinline asmを受け付ける
  asm
    lea   eax, f
    fstp  qword ptr [eax]
  end;
  Result := Trunc(f);
{$ENDIF}
end;

これが最小限の機械的な修正であり、一時的なものとして扱う価値があります。唯一の目的が浮動小数点から整数への切り捨てであるヘルパーに、アーキテクチャ固有のアセンブリを抱えるコードベースは、不要な負債を抱え込んでいることになります。古い実装のFPUの副作用に依存するものがないことを確認できれば、32ビットの分岐は完全になくすことができます

関数が複数のユニットで使用されるコンポーネント内にある場合は、コードベース全体で _ftol を検索してから、移行方法を決定してください。その名前のシンボルは複数の場所で宣言される可能性があります。リンカーは1つを選択し、他を暗黙のうちに無視します。つまり、1つのコピーを修正しても、手つかずの別のコピーにリンクされる可能性があるということです