技術記事

Delphi Linux64 LP64 の罠:Longint、ポインタ、WIN64

Delphi の Linux64 ターゲットは LP64 データモデルに従うため、Longint と LongWord はそこでは 8 バイトになります。一方、Windows 上の Delphi(Win32 と Win64)も Free Pascal も全ターゲットで 4 バイトのままです。Delphi 用 PDF Library である PDFlibPas を dcclinux64 に対応させる過程で、この差には 3 つの別個の形で遭遇します。さらにポインタ幅の罠もあり、ポインタサイズの整数型を選ぶための {$IFDEF WIN64} 判定は、Windows 以外のすべての 64 ビットターゲットで黙って誤った幅を選んでしまいます

どの罠も珍しいものではありません。どれも、Windows ではたまたま安全になってしまう習慣です。RTL が Longint と宣言している場所に Integer と書く、C の構造体を Longint フィールドで写し取る、あるいは本題が「ポインタは 8 バイトか」なのに「Win64 か」と尋ねてしまう。Win32、Win64、FPC では 2 つの綴りが同じ意味なので、コードはこの習慣を何年も問題なく抱えたまま過ごせます。ところが Delphi Linux64 では同じ意味ではなくなり、最初に気づくのはコンパイラです

以下はすべて、コンパイラが捕捉してくれる類の話です。PDFlibrary.pas は 1 つのソースツリーから dcc32、dcc64、dcclinux64 でビルドできており、ここで扱うのは、Windows 専用の習慣とその 3 つ目のターゲットの間に立ちはだかる問題です

Delphi が Linux64 をターゲットにすると何が変わるのか

Delphi Linux64 で幅が変わる整数型は、まさに Longint と LongWord の 2 つです。Integer と Cardinal はどの Delphi プラットフォームでも 4 バイトのまま、NativeInt はポインタに追従します。Windows は LLP64 で、64 ビットで大きくなるのはポインタだけです。Delphi の 64 ビット POSIX ターゲット(Linux64、macOS と iOS の各 64 ビットコンパイラ)は LP64 で、C の long も大きくなり、Embarcadero は Pascal の Longint をそれに追従させました

型Delphi Win32Delphi Win64Delphi Linux64Free Pascal
Integer / Cardinal4444
Longint / LongWord448全ターゲットで 4
NativeInt / Pointer488ターゲットのポインタサイズ
C の long(プラットフォーム ABI)448OS の ABI に従う

クロスコンパイラのライブラリにとって罠なのは Free Pascal の列です。PDFlibPas はすでに FPC 経由で Linux ビルドを提供しており、FPC は Linux x86-64 でも Longint を 32 ビットに保ちます。つまり「Linux で動く」は真実でしたが、Delphi Linux64 については何も語っていなかったわけです。ポートの正しい土台は結局、FPC の非 Windows バックエンドでしたが、その下の型サイズは異なります

PDFlibPas PDF ライブラリの Delphi Win32 / Win64 / Linux64 と Free Pascal 各ターゲットにおける Integer、Longint、NativeInt、C long の幅を並べたマトリクス
Delphi Linux64 で幅が広がるのは Longint、LongWord、C の long のみで、FPC は全ターゲットで 4 バイトのまま

WIN64 判定はなぜ Linux でポインタを切り詰めるのか

短く答えるなら、WIN64 は「64 ビット Windows」を意味するのであって「64 ビット」を意味しない、という話です。この 2 つを同義語として扱うと、誤った整数幅を選ぶことになります。24 ビットのビットマップをチャネルあたり少数のレベルへ落とす Floyd-Steinberg 誤差拡散ディザラである PDFlibDither は、3 本の誤差行を生のポインタ演算でたどります。この演算の背後にあるポインタサイズの整数は、OS ではなく CPU の幅に追従しなければなりません。素朴な条件コンパイルはこう間違えます

// 素朴な書き方:幅を OS で決めるため、Windows 以外の 64 ビットターゲットはすべて Integer になる
{$IFDEF WIN64}
Type
  OSPtr= IntPtr;
{$ELSE}
Type
  OSPtr= Integer;
{$ENDIF}

// 蛇行走査では誤差行のポインタを 1 要素ずつ進める
Inc(OSPtr(ErrorR), SizeOf(TErrorTerm));  // FPC x86-64: 下位 32 ビットだけを書き換える
Dec(OSPtr(ErrorG), SizeOf(TErrorTerm));  // dcclinux64: E2064 左側は代入できない

// 正しい書き方:どこでも同じポインタサイズの型を使う
Type
  OSPtr= TPLIntPtr;  // PDFlibTypes: Windows 以外は NativeInt、Win64 / Win32 は IntPtr / Integer

この条件の下では、FPC の Linux x86-64、macOS x64、ARM64 のいずれでも OSPtr は 4 バイトの Integer に解決され、Inc(OSPtr(ErrorR), ...) は 8 バイトポインタの先頭 4 バイトを整数として読み直して増やします。リトルエンディアンの CPU では、それがアドレスの下位半分です。上位半分は一切触られないため、この間違いはよく隠れます。下位 32 ビットがラップするまで結果は正しく、その瞬間ポインタは 4 GiB 後ろへ静かに飛び戻ります。FPC は変数型キャストを警告なしにコンパイルします。一方 Delphi は、代入の左側にある別サイズの型への変数型キャストを拒むため、dcclinux64 は最初の Inc で E2064 で止まります

ここには有用な非対称性があります。型キャストを拒むコンパイラは、別のコンパイラが黙って受け入れるポインタ幅の間違いを、コードが新しいターゲットで 1 行も走る前に見つけられるのです。PDFlibPas はこの目的に PDFlibTypes の TPLIntPtr を使います。これはまず OS_WINDOWS を判定し、その上で Win64 と Win32 を区切るため、非 Windows ターゲットは常に NativeInt を得ます

持ち帰った規則はこうです。OS の define からポインタ幅を推測しない。ポインタサイズの整数が必要なら NativeInt / NativeUInt(Delphi にも FPC にもあります)か、それを基にしたプロジェクト内のエイリアスを使い、条件コンパイル自体をなくすのが正道です。どうしても Delphi コードで幅で分岐が必要なら、実際に問いたい問いに答える define は CPU64BITS のほうです

PDFlibPas の Delphi コードにおけるポインタ幅型の判断フロー。WIN64 define は非 Windows 64 ビットターゲットで 4 バイト Integer を選び、NativeInt での防護が要る
WIN64 define が問っているのは OS のことであり、ポインタ幅型は NativeInt か CPU64BITS 判定から取るしかない

TStream オーバーライド:RTL のシグネチャを 1 文字も変えずに写す

Delphi Linux64 の TStream 派生クラスは、Read、Write、SetSize を RTL とまったく同じ Longint パラメータで宣言・実装しなければなりません。Integer はもはや同じ型ではないからです。PDFlibPas には Unicode ファイル名向けの独自の TPDFlibWideFileStream があり、クラス宣言は Longint を使っていました。ところが実装部は Integer だったのです

Type
  TPDFlibWideFileStream= Class(TStream)
  Protected
    Procedure SetSize(NewSize: Longint); Override;
  Public
    Function Read(Var Buffer; Count: Longint): Longint; Override;
    Function Write(Const Buffer; Count: Longint): Longint; Override;
  End;

// 修正前:Integer = Longint の Win32、Win64、FPC では何年も受け付けられていた
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Integer): Longint;

// 修正後:実装ヘッダも宣言と寸分違わず同じ型を繰り返す
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Longint): Longint;
Begin
  Result:= FileRead(FHandle, Buffer, Count);
  If Result= - 1 Then
    Result:= 0;
End;

このライブラリがそれまで出会ったすべてのコンパイラでは、Integer と Longint は同じ 4 バイト型だったため、一致しないヘッダは一致するヘッダと区別がつきませんでした。Delphi Linux64 では実装が自前の宣言と一致しなくなり、ユニットはコンパイルを通らなくなります。見た目以上に痛いのは、PDFlibExtra がライブラリの大半が依存する基盤ユニットだという点です。1 つの不一致ヘッダが、ほとんどのユニットのコンパイルを止めます

新しいコードでは、同じ理屈が逆方向に働きます。RTL のメソッドをオーバーライドするときは、2 つの型が同じだと「知って」いる場合でも、パラメータ型をそのまま写してください。Delphi Linux64 は、その知識が成り立たなくなる最初のターゲットにすぎません

どのレコードは Longint ではなく Integer にすべきで、どのレコードは Longint のままにすべきか

ファイル形式や Windows 構造体を写したレコードは固定幅の型(Integer、Cardinal、Int32)を使い、C で long と宣言された構造体を写したレコードは Longint / LongWord を保つべきです。ソース上では 2 つは同じように見えるのに、求められる答えは正反対になります。Windows の LONG は常に 32 ビットであり、C の long はプラットフォーム ABI の言う通りだからです

LP64 における Delphi レコードフィールドの判断マトリクス。ディスク上のバイトには固定幅 Integer、PDFlibPas の C long バインディングは Longint を維持
バイトを写すレコードは固定幅を選び、Longint を維持するのは C の long のバインディングだけで、1 行のサイズアサーションがそれを裏付けます
Type
  // Uniscribe の GOFFSET:Windows の LONG 2 つで合計 8 バイト
  TGoffset= Packed Record
    du: Integer;  // 元は Longint:Delphi Linux64 ではレコードが 16 バイトに膨らむ
    dv: Integer;
  End;

  // EMF の LOGCOLORSPACE:ヘッダはレコードデータからの固定 68 バイト Move で埋める
  TPLEMFLogColorSpace= Packed Record
    Signature, Version, Size, ColorType, Intent: Cardinal;
    Endpoints: Array[0..8] Of Integer;  // 元は Longint:36 バイトが 72 バイトになる
    Gamma: Array[0..2] Of Cardinal;
    Filename: Array[0..259] Of WideChar;
  End;

  // PDFium の FPDF_FILEACCESS:m_FileLen は C の unsigned long なので LongWord が正しい
  FPDF_FILEACCESS= Record
    m_FileLen: LongWord;  // Windows では 4 バイト、LP64 では 8 バイト、C 側と同じ
    m_GetBlock: TFPDF_GetBlock;
    m_Param: Pointer;
  End;

EMF のケースは、この手の失敗がいかに静かに起きるかを示しています。インポータは TPLEMFLogColorSpace に 68 バイトをコピーします。ヘッダ 20 バイト、CIE エンドポイント 36 バイト、ガンマ 12 バイトです。LP64 でエンドポイントが Longint のままだと、同じ 68 バイトは 72 バイトのエンドポイント配列に収まり、ガンマ値は永遠に書き込まれず、エラーも何も上がりません。両レコードとも Windows 専用コードパスに置かれていますが、それでも固定幅型を使います。ディスク上のバイトを写すレコードは、どのコンパイラでビルドされるかに依存すべきではないからです

逆の例が PDFium バインディングです。その m_FileLen は C の unsigned long を写しており、これは Windows では 32 ビット、LP64 では 64 ビットで、LongWord はその両方に正確に追従します。整理のついでに Cardinal へ書き直すと、PDFium ホストが POSIX ローダーを得た瞬間に壊れます。PDFium ホストは Windows 専用なので、これは将来へのメモです。C バインディングの呼び出し規約側にも独自の罠があり、PDF Library の DLL、ActiveX、dylib バインディングに関する記事で扱っています

バイトを写すレコードには安上がりな防護があります。初期化部で Assert(SizeOf(TGoffset) = 8) のようにサイズを 1 回アサートしておくのです。コストはゼロで、静かなレイアウト変化を新しいターゲットの最初の実行での即座の失敗へ変えます

条件 define を正しく整える:DELPHI_LINUX、Android、Types

型の問題が表面化する前に、Linux 上の Delphi は Windows のふりをやめる必要がありました。PDFlibDefines.inc は Delphi Linux 向けに何も定義していなかったため、コンパイラは Windows / VCL の既定値に直落ちし、最初のエラーは Vcl.Graphics が見つからないというものでした。define は、Delphi Linux を、FPC が非 Windows ターゲットで使うのと同じバックエンド、つまり Cairo レンダリング、fontconfig、実行時にロードする OpenSSL と libcurl へと導きます

{$IFDEF LINUX}
{$IFNDEF FPC}
{$IFNDEF ANDROID}
{$DEFINE DELPHI_LINUX}
{$DEFINE NOVCL}
{$DEFINE MD5_MAC}
{$DEFINE NOCRYPTAPI}
{$DEFINE USE_Cairo}
{$ENDIF}
{$ENDIF}
{$ENDIF}

このブロックには見落としやすい細部が 2 つあります。ANDROID の除外があるのは、Delphi が Android ターゲットでも LINUX を定義するためで、このバックエンドは Android をカバーしていません。そして新しい define は、既存の Delphi macOS 分岐(FPC なしの OS_MAC)とは意図的に別になっています。あちらは Macapi ユニットに依存するのに対し、Linux 用 define は FPC の非 Windows パスの上に築かれます

もう 1 つの定番エラーは、TPoint と TRect が未宣言になることでした。Windows ではこれらの型は Windows ユニット経由で間接的にほとんどのユニットへ届きます。Delphi Linux の uses 節には Windows ユニットがないため、PDFlibFont や PDFlibPageTree などのユニットは DELPHI_LINUX の下で Types を明示的に列挙するようになりました。自分のコードが、Windows が System.Types を引き込んでくれるだけで Windows で通っているなら、同じ壁にぶつかると思ってください。偶然 1 つのコンパイラでしか動かないコードの一般パターンは、FPC が暴き出すまで偶然でしか動かない 5 つの Delphi 習慣の話題です

コンパイラが助けにならなくなる場所

上に挙げた LP64 の問題は、コンパイラが見つけられる類のものです。見つけられない類、たとえばサイズが誤っているのに型キャストを一切通らないレコードなどは、テストが実際にターゲット上で走ったときに初めて姿を現します。まさにそのために、上述のサイズアサーションがあるのです

1 ビット出力そのものが目的でディザリングコードにたどり着いたのなら、レンダリング済みページの正規のルートは別にあります。レンダラの外側で行う GDI ハーフトーンへのダウン変換で、PDF ページを 1 ビットモノクロームビットマップにレンダリングする記事で説明しています

ここで説明したクロスコンパイラ対応の土台は、PDF Library for Delphi に組み込まれています。現在は Windows 上の Delphi と C++Builder に加え、Linux と macOS のヘッドレス Free Pascal ビルドをターゲットにしており、対応コンパイラとプラットフォームの全一覧は製品ページにあります