技術記事

PDFlibPasのFPC Win32向けOMF→COFFオブジェクトリンク

PDFlibPasは32ビットWindows向けにFree Pascalでビルドできますが、難所は一度もPascal側ではありませんでした。問題はオブジェクトファイルです。DelphiビルドがリンクするAESとOpenJPEGのオブジェクトはOMF形式で、Free Pascalの内部リンカが要求するのはCOFF。この2つを変換すると、セクション名とセクション定義シンボルが、リンカに診断メッセージではなく内部エラーを吐かせる形になってしまいます

CのオブジェクトをPascalライブラリにリンクさせたことのある人なら、この一帯の事情はご存じのはずです。Win64は比較的お行儀がよく、オブジェクト形式は1種、呼び出し規約も1種、名前装飾もありません。一方Win32は、このプラットフォームが積み上げてきた歴史のレイヤーをすべて温存しており、サードパーティ製Cコードを静的リンクするライブラリは、その全部に一度で出くわすことになります

コンパイラのディレクトリ名ではターゲットを判断できない

まずビルドのエントリポイントから確認してください。ここを間違えると、オブジェクトファイルが一切登場しないうちに何時間も無駄になります。Free Pascalのインストールディレクトリ名が示しているのは、メインのコンパイラがどこにあるかであって、何を生成するかではありません。32ビットのホストコンパイラでも、適切なターゲットスイッチを渡せば横に置かれたクロスコンパイラを呼び出して64ビットコードを生成できます。パスからターゲットを推測するのは、誰かがツールチェーンを組み替えるまでたまたま動くだけの当てずっぽうです

信頼できるのはコンパイラ自身に尋ねる方法です。コンパイラの情報系スイッチ経由で実際のターゲットCPUとOSを照会し、よくある2種類のインストールレイアウト、つまりフラットなバイナリディレクトリとバージョン入れ子のディレクトリ、の両方を受け入れてください。インストーラやツールチェーンマネージャによって作られる形が違うからです。どちらかのレイアウトをハードコードしたビルドスクリプトは、きっちり1台のマシンでしか動きません

変換済みオブジェクトはなぜ内部リンカを壊すのか

変換処理はOMFのセクション命名規約をそのまま引き継ぎ、COFFリンカが期待するものと一致しないセクション定義シンボルを合成するからです。OMFオブジェクトをCOFFへ変換するのは必要条件ではあっても十分条件ではありません。変換後のファイルには由緒正しい_TEXT_DATA_BSSというセクション名と、そこから派生したセクション定義シンボル名が残っており、これをFree Pascalの内部リンカに食わせると、セクション名についてのメッセージではなく内部コンパイラエラーが返ってきます

内部エラーはビルド問題の中で最悪の失敗モードです。入力の何が悪かったのか、何も教えてくれないからです。対策は、変換後のCOFFファイルに対する正規化パス、つまりセクション名を期待される形式に書き換え、対応するセクション定義シンボルも一致するように書き換えることです。ただしシンボルインデックス、コードバイト、リロケーションは一切触りません。この最後の制約こそが難所のすべてです。シンボルの番号を振り直したりオフセットをずらしたりする書き換えは、リンクは通るのに実行時にクラッシュするオブジェクトを生み出します

2つのオブジェクトセットのうち片方には、前処理がもう一段必要です。クラシックな32ビットC++コンパイラでビルドされたOpenJPEGオブジェクトは、Delphiプライベートの64ビット整数ヘルパルーチンに依存しています。Free Pascalはこれを提供しないため、形式変換をいくら重ねても使えるようになりません。そこでこのオブジェクトは先にClangベースのコンパイラで再ビルドし(この依存は生成されません)、その後で変換します

Win32 における PDFlibPas 静的 C オブジェクトの流れ。Lib\thirdparty\Win32 の Delphi OMF から Lib\thirdparty\Win32f の FPC リンク可能 COFF へ。OMF から COFF への変換、シンボルインデックス・コードバイト・リロケーションを一切触らずにセクション名とセクション定義シンボルを書き換える正規化パス、Delphi 64 ビットヘルパを呼ぶ OpenJPEG オブジェクトの Clang 再ビルド
変換は必要条件であって十分条件ではない。名前だけ変えて正規化していないCOFFをFree Pascalの内部リンカに渡すと内部エラーで返ってくるため、変換後のパスでオフセットには触れずに名前とシンボルを修正します
// FPC ターゲット用のオブジェクトは専用ディレクトリに置く。Delphi 用の
// オブジェクトセットを置き換えるものではない。両ツールチェーンとも同じ
// ソースツリーからビルドし、それぞれ固有のリンク入力を必要とするため
//
//   Lib\thirdparty\Win32   Delphi OMF オブジェクト(変更なし)
//   Lib\thirdparty\Win32f  FPC COFF オブジェクト(変換と正規化済み)
//
// ビルドのエントリポイント:
//   build-Win32-Lib-FPC.cmd
//   build-Win64-Lib-FPC.cmd

コンパイラ固有のヘルパは移植できず、その規約も例外ではない

Delphiランタイムは32ビットx86上の64ビット整数演算のためにアセンブリのトランポリンを用意していて、Delphi向けにビルドされた事前コンパイル済みCオブジェクトはそこを呼びます。Free Pascalには独自の仕組みがあるため、これらの参照はリダイレクトではなく別のやり方で解決する必要があります。リダイレクトを不可能にしている細部が呼び出し規約です。画像処理コードが使うタイミングヘルパは4バイトの引数を呼び出し先が片付けますが、64ビット除算ヘルパは16バイトを片付け、結果を古典的なレジスタペアで返します。ヘルパが2種、規約が2種。片方向けに書いたトランポリンは、もう片方ではスタックを静かに壊します

名前装飾が問題の後半を上乗せします。Win32では、Free Pascalは外部Cインポートに自動的にアンダースコアを前置する一方、public name宣言はそのままの形でエクスポートします。つまり同じブリッジでも、インポート側とエクスポート側でルールが違うのです。そのためOpenJPEGが必要とするCランタイムブリッジは、厳密なCシンボル名をエクスポートしなければならず、可変引数のエントリポイントには直接ジャンプではなく32ビットの間接ジャンプが必要になります。言われてみれば珍しい話はひとつもありません。しかし全部、誰も書いていないシンボル名を指すリンクエラーとして顕在化します

なぜWin32実行ファイルはmainに到達する前に死んだのか

原因は検索パス上にあった64ビットDLLです。Free Pascalのzlibユニットが静的リンクではなく動的バインドになっていたために到達しました。症状は、プログラム内のPascalコードが1行も実行される前に、不正イメージのステータスコードで即終了するというものです。実際の過ちは、ローダーが誤ったアーキテクチャに対してインポートを解決していたことなのに、出来たての実行ファイルを疑って眺め回すことになります

教訓はzlibというより前提の置き方にあります。圧縮ライブラリの名前を冠したユニットが、中身を必ずしも持っているとは限りません。実行時に共有ライブラリを期待するバインディングかもしれず、意図していない動的依存は、たまたま解決される場合であってもデプロイ上の負債です。純Pascalのストリーム実装に切り替えれば、両ターゲットとも外部依存ゼロの静的組み込み圧縮経路を得られます。他人のアプリケーションに組み込まれるライブラリなら、本来それがあってしかるべきです

同じ発想は外部JBIG2エンコーダバックエンドにも当てはまります。32ビットターゲットでは外部エンコーダはリンクされないため、要求は組み込みのPascalエンコーダへフォールバックします。これを検証するテストは、エンコードの成功を外部バックエンド存在の証拠と見なすのではなく、現在のターゲットでの登録状態をチェックしなければなりません。動いてしまうフォールバックこそ、欠けている依存を隠すものだからです。この失敗パターンはサイレントなスタブ失敗の診断で扱っています。64ビットの静的リンク作業についてはFPC下でのjbig2enc静的リンクが詳しい

Free Pascal で main に到達する前に終了する Win32 実行ファイルの診断フロー。ローダーがユニット初期化の実行中にインポートを解決し、zlib バインディングが検索パス上の 64 ビット DLL を見つけ、Pascal の文が 1 つも実行される前に不正イメージのステータスでプロセスが死ぬ。そのため PDFlibPas は静的に組み込む純 Pascal 圧縮経路へ移行した
過ちは一度も出来たての実行ファイル側にはなかった。zlib という名のユニットは誤ったアーキテクチャに対して解決される実行時バインディングであり、組み込み JBIG2 エンコーダのような「動いてしまう」フォールバックは欠けた依存を隠します

メモリストリーム上の32ビット演算

バッファサイズをポインタ幅の符号なし演算で扱うコードは、Win64では正しく、Win32では大きな画像1枚で桁あふれ一歩手前です。JPEG 2000コーデックに供給するメモリ内ストリームは倍増で成長し、加算で前進します。32ビットターゲットでは、この2つの演算はどちらも、大きくはあっても完全に正当な入力でラップし得ます

そのため書き込み、スキップ、シーク、初期確保のすべてが、計算の前にチェックを行います。容量の上限は符号付きポインタ幅の最大値です。ブロック移動ルーチンとコールバック戻り値が表現できる範囲に合わせた選択です。要求を拒否する際の振る舞いの要件は間違えやすい点です。拒否にあたってストリーム位置も長さも変更してはならない。部分的な変更の後にエラーを返すと、呼び出し側が推論できない状態にストリームが置かれ、次の操作でさらに悪化します

// 計算の前にチェックする。Win32 では次の 2 か所とも、大きな JPEG 2000
// 画像が正当に生み出す入力で桁あふれが起きる
if Needed > NativeUInt(High(NativeInt)) - FPosition then
  Exit(False);                    // 拒否する。位置もサイズも変更しない

NewCapacity := FCapacity;
while (NewCapacity < FPosition + Needed) do
begin
  if NewCapacity > NativeUInt(High(NativeInt)) shr 1 then
    Exit(False);                  // 倍増すると桁あふれする
  NewCapacity := NewCapacity shl 1;
end;

移植後も生き残るビルド出力の罠2つ

テスト用とサンプル用の実行ファイルをターゲットアーキテクチャ別に出力ディレクトリへ分けるのは明らかに正しいのですが、ディレクトリ階層を上へ数えてテストデータの場所を特定していたものが即座に壊れます。対策は固定の深さを決め打ちせず、アセットディレクトリを上方向に探索すること。ただし意図的な制限を1つ設けます。署名サンプルが証明書のフォールバックを受け入れるのは自分のプロジェクトディレクトリからだけで、任意の祖先ディレクトリからは決してありません。ツリーを上がった先で見つかった同名証明書は、便利さではなくセキュリティ上の驚きだからです

2つ目の罠はどの移植でも生き残り、あらゆるFPCプロジェクトに持ち回る価値があります。コンパイラをアップグレードした後、古いPPUファイルをコンパイラに拒否させるだけでは不十分です。リンカは、ロードしたPPUが正しいディレクトリから来ていても、ユニット検索パスに残置されたオブジェクトファイルを優先し、明示的なオブジェクト出力パスの追加でもこの優先は上書きできません。唯一確実な答えは、ビルドラウンドごとに新しい一時ユニットディレクトリを使うことです。それを怠ると2つのコンパイラバージョンからリンクされたバイナリが出来上がり、ソースのバグにしか見えない形で壊れます

最後のピースはプラットフォーム条件文です。どの軸で条件分けするかは、見た目以上に重要になります。正しい問いは通常、特定のウィジェットライブラリがあるかどうかではなく、そのコードがWindows固有かどうかです。EMFベクタインポートとプラットフォーム条件文でのメタファイル変換作業が示すように、ガードをコントロールライブラリ条件からプラットフォーム条件へ切り替えたことで、リライト想定だった作業がディレクティブ1行の変更になりました。両Windowsターゲット向けのFree PascalとLazarusサポートは、DelphiやC++Builderパッケージと同じソースからビルドされ、PDFlibPas Delphi PDFライブラリに同梱されています