技術記事

HotPDFにおけるFree Pascal Win32のCシンボル装飾

Win32のFree Pascalは、cdecl; externalのインポートすべてに自動的に先頭アンダースコアを付ける一方、public nameは書いた文字列を1文字も変えずにエクスポートします。HotPDFはこの2つの規約を同じソースツリーの中で両方満たさなければなりません。Delphiビルドが、アンダースコアを手書きしたインポート宣言をすでに出荷しているからです。この非対称を取り違えると、誰も書いていないシンボル名を指すリンクエラーが発生します

DelphiライブラリをFree Pascalへ広げる作業は移植性の問題として語られがちで、Win64では実際にだいたいそうですが、Win32は違います。32ビットx86 WindowsのABIには、Cシンボルの綴り方、スタックを片付けるのは誰か、翻訳単位がどのコンパイラ固有ヘルパを仮定してよいか、という30年分の累積した慣習が載っており、そのどれもが、言語については一致している2つのPascalコンパイラが、オブジェクトファイルについてはなお食い違い得る場所です

同じシンボルがWin64では解決しWin32では失敗する理由

アンダースコア前置きは、Free Pascalがインポートには適用するがエクスポートには適用しない、32ビットの慣習だからです。function deflate(...): Integer; cdecl; external;と宣言すると、FPCはWin32ではオブジェクトファイルから_deflateを、Win64ではdeflateを探します。これは正しい挙動で、Cコンパイラの出力とも一致します。罠は橋の反対側にあります。public name 'deflate'を付けたルーチンは、両ターゲットとも前置きなしで、正確にdeflateをエクスポートするのです

話を具体的にする歴史的細部をもう1つ重ねます。Delphiビルドは、これらのエントリポイントの一部について、アンダースコアを名前に織り込んだ宣言をすでにしています。自分のオブジェクトファイルの中身がそうだからです。同じ宣言をWin32のFPCに渡すと、コンパイラは律儀にももう1度前置きし、リンカは__deflate、つまり誰もエクスポートしないシンボルを探し回ります。直感的な対策である「あらゆる箇所にアンダースコアを1つ足す」は、すでに正しく綴られていたインポートを壊します

うまくいくのは、前置き定数を1つではなく1組にする方法です。HPDFFPCZLibHPDFFPCCodecStubsは、素のCインポート用と、Delphi側の前置きをすでに持つインポート用に、別々の前置きを使います。Win64では両定数とも空なので、既存のリンク名は一切変わらずに済みます。定数1つを2つにする、それが対策の全体であり、インポートのルールとエクスポートのルールを切り離して初めて自明に見えるものです

同一の C シンボル宣言が Win64 と Win32 で Free Pascal と Delphi のそれぞれにどう解決されるかの比較。cdecl インポートは 32 ビットターゲットでのみアンダースコアを獲得し、アンダースコア込みで綴られた Delphi 宣言は __deflate になってリンクに失敗する一方、public name のエクスポートは両アーキテクチャともリテラルのままである
1つの前置き定数では両方のルールを賄えません。素のcdeclインポートと、Delphiのアンダースコアをすでに持つインポートは、Win32のFPCの下では違う装飾になるため、HotPDFは2つを保持し、Win64では両方を空にします
// 前置き定数は1つではなく2つ。素のCインポートと、手書きのDelphi前置きを
// すでに持つインポートは、FPC/Win32 の下では違う装飾になる
const
{$IF DEFINED(FPC) and DEFINED(CPU32)}
  CPrefix     = '_';   // cdecl external には FPC が自前で付ける
  DelphiCName = '';    // ソースでアンダースコア込みに綴済み
{$ELSE}
  CPrefix     = '';
  DelphiCName = '';
{$IFEND}

// エクスポート側:'public name' はどのターゲットでもリテラル
procedure hpdf_codec_free(P: Pointer); cdecl;
  public name 'hpdf_codec_free';

WIN32が教えるのはアーキテクチャであってABIではない

条件コンパイルのミスの中で最もデバッグの尻尾が長いのがこれで、はっきり言っておく価値があります。WIN32WIN64が記述しているのはターゲットアーキテクチャであり、どのコンパイラ固有ランタイムヘルパが存在するかについては何も語りません。Free PascalはDelphiとまったく同様に、対応するWindowsターゲットで両シンボルを定義します。だからDelphiランタイムヘルパを呼ぶコードを{$IFDEF WIN32}で括ると、FPCではコンパイルが通り、リンク時点で落ちます

具体的に言うと、この罠には3系統のコードが落ちます。System.@_llヘルパ経由で辿り着くDelphiの64ビット整数トランポリン、MSVCのWin32アセンブリサポートルーチン、そしてそれに付随するインポートスロットは、いずれもDelphiビルドがリンクする事前コンパイル済みCオブジェクトに仕えるために存在しています。Free Pascalはそれらのオブジェクトをリンクしないため、この機構は一切不要であり、それへの参照はすべて消えなければなりません。微妙なのは、宣言と実装を一緒に除外する必要がある点です。片方だけ除外すると、コンパイラは何にも一致させられない識別子について、役に立たない報告を上げてきます

導かれるルールは短い。問いがABIやランタイムサポートに関するものならコンパイラで分岐し、ポインタ幅やレジスタ数に関するものならアーキテクチャで分岐する。そして一方を他方の代役には決して立てない

宣言と実装を一緒にガードする

インターフェースセクションの条件ブロックには気づかずに足を踏み入れやすく、そのとき出るエラーメッセージは原因以外のどこかを指します。クラスインターフェースにメソッド宣言を追加するなら、自然な置き場所は関連メソッドの隣です。隣り合わせのメソッドがたまたま既存の{$IFDEF}ブロックの中に座っているまでは、それで問題ありません。条件ディレクティブはインデントされないため、40行上で開いたブロックは、周囲の宣言を読んでいる間は事実上見えません

次に起こるのは、片方のツールチェーンでは成功し、もう片方では滝のように文句が出るコンパイルです。周囲のガードがFree Pascalが満たさないDelphiバージョンチェックだと、FPCでは宣言だけが消え、無条件の実装は残り、コンパイラは見つからなかったメソッド識別子について長大な苦情リストを並べます。どのメッセージも、原因となった条件ブロックには触れません

この一連の失敗を防ぐ習慣は2つです。インターフェースセクションへ挿入する前に、見た目のグルーピングを信用せず、上方向へ最も近い開いた条件を探すこと。そして緑のDelphiテストスイートはDelphiについての証拠にすぎないと扱うこと。Free Pascalのライブラリビルドは別のゲートであり、それを通ることを知る唯一の方法は、build-Win32-Lib-FPC.cmdbuild-Win64-Lib-FPC.cmdを同じ変更の一部として実行することです

32ビットの算術コードで壊れるもの

1つの言語制限が、最も変更を嫌うコードにちょうど現れます。32ビットFree Pascalは、UInt64forループの制御変数として受け付けないのです。X25519とX448を担う楕円曲線ユニットでは、リム配列を歩くループが64ビットカウンタで書かれていました。ファイル内の他のすべてが64ビットだから、というそれだけの理由で

対策は外科的に行う必要があります。体の算術では変数の幅が正しさの議論の一部だからです。ループインデックスはIntegerへ変更します。リム配列の要素は一握りで、インデックスが32ビット範囲に近づくことはないからです。一方、算術に参加するもの、リムそのもの、キャリー伝播、マスクはUInt64のままです。これらをどれか狭めると、体の素数を法とする結果が黙って変わってしまうからです

// 32 ビット FPC は UInt64 のループ変数を拒否する。狭めるのは
// インデックスだけ。リム、マスク、キャリーの幅を保たないと体の演算が変わる
var
  I: Integer;                 // 以前は UInt64
  Carry, Mask: UInt64;
begin
  Carry := 0;
  for I := 0 to High(Limbs) do
  begin
    Limbs[I] := Limbs[I] + Carry;
    Carry := Limbs[I] shr 51;
    Limbs[I] := Limbs[I] and Mask;
  end;
end;

この種の変更の検証は、ラウンドトリップテストであってはなりません。壊れた実装で暗号化して復号すれば、当たり前に自己整合します。だからこそ既知回答ベクトルがここでは譲れない条件です。公開されているX25519とX448のテストベクトルを実行し、出力バイトを厳密に比較する。正しい実装と自己整合なだけの誤った実装を見分けられる唯一のチェックであり、Free PascalのdeflateとAESコーデック境界で論じた対称暗号プリミティブにも等しく当てはまります

HotPDF の Win32 Free Pascal ビルドにおける 2 つの破綻点。Delphi ランタイムヘルパを括る {$IFDEF WIN32} ガードはコンパイルは通るが、宣言と実装を一緒に除外しない限りリンクで落ちること。そして X25519 と X448 のリム走査で UInt64 のループ変数を Integer へ狭めつつ、リム、キャリー、マスクは幅を保つこと
問いがABIやランタイムサポートならコンパイラで、ポインタ幅ならアーキテクチャで分岐します。そして算術の変更は、ラウンドトリップテストではなく公開済み既知回答ベクトルで証明を

Win32 Free Pascalビルドの価値

実務上の見返りは、32ビットWindowsを狙うLazarusアプリケーションが、Delphi版と同じドキュメントエンジンを、別のバイナリ契約を維持することなしに得られることです。これが最も効くのは、あまり語られない類のデプロイ先です。産業用コントローラ、POS端末、長寿の基幹業務ソフトウェア。そこでは32ビットランタイムはレガシーな選択ではなくハードウェアの制約です

Win64の話は先に出ており、Win64でのFree PascalとLazarusサポートで説明しています。Win32はその再演ではありません。Win64には呼び出し規約が1種しかなく、名前装飾もなく、回避すべきDelphiプライベート整数ヘルパもないため、この記事のほぼすべては32ビットターゲット固有の話です。ループ変数の変更が必要だった算術ユニットは、NIST曲線上のMontgomery算術で説明したのと同じユニットであり、幅の規律についてはそちらでより深く掘り下げています

一般的な教訓は、クロスコンパイラの移植作業は、第一には言語機能の話ではないということです。ここでは両コンパイラとも同じObject Pascalを受け付けます。違うのはオブジェクトファイルです。シンボルの綴り方、ランタイムが提供を仮定されるヘルパルーチンの種類、リンクに含まれる事前コンパイル済みオブジェクト。HotPDFはFree PascalとLazarusパッケージをDelphiやC++Builderのパッケージと並べて、HotPDF Delphi PDFコンポーネントとして出荷しています。コンパイラごとにフォークするのではなく、同じソースツリーがすべてのツールチェーンを養う形です