技術記事

DelphiのPDFライブラリにおけるRange Check Error:根本原因

DelphiのプログラムがERangeErrorと、素っ気ないメッセージ「Range check error」で止まることがあります。多くの場合、2つのビルド構成のうち片方だけです。範囲チェックを有効にして({$R+})コンパイルされたコードが配列や文字列を境界外にインデックスしたり、ターゲットの順序型に収まらない値を代入したりすると、Delphiはこの例外を上げます。修正はほぼ常に、悪いインデックスや値が計算された場所に属します。デバッガが止まる行は、めったにそこではありません

Delphiの「Range check error」は何を意味するのか

ERangeErrorは、範囲チェックがオンのときにコンパイラが挿入するチェックから来ます。{$R+}(長い形式は{$RANGECHECKS ON}、またはプロジェクトオプションのProject Options › Building › Delphi Compiler › Compiling › Range checking)では、すべての配列と文字列の添字が宣言された境界と比較され、より小さい順序型、サブレンジ、列挙型変数への代入はすべてその型の範囲と比較されます。デフォルトの状態は{$R-}で、そのようにコンパイルされたコードにはチェックがまったく含まれないため、境界外の書き込みは隣にあるメモリにそのまま着地します。チェックに失敗すると、SysUtilsがリンクされているプログラムではリソース文字列「Range check error」付きのERangeErrorが上がります。SysUtilsをまったく使わないプログラムは、代わりにランタイムエラー201で終了します。コンパイル時エラーのE1012(Constant expression violates subrange bounds)がカバーするのは、コンパイラが定数と見なせるインデックスだけです。実行時に計算されるものはすべて{$R+}次第です。このスイッチはユニットごとにも機能します。プリコンパイル済みDCUはビルド時の状態を保持し、ユニット先頭の{$R-}はそのユニットに対してプロジェクトオプションを上書きします

オーバーフローチェックは別のスイッチで、別の例外です。{$Q+}({$OVERFLOWCHECKS ON})は+、-、*、Inc、Decのような整数演算をチェックし、結果が収まらないときEIntOverflowを上げます。{$R+}はインデックスと代入をチェックします。2つが交わるのは小さい型です。Byteの演算はIntegerとして評価されるので、B = 255のときのB := B + 1はオーバーフローなしで256を生成し、範囲チェックに失敗するのはByteへの書き戻しだけです。同じ値へのInc(B)は代わりにオーバーフローチェック済みの演算になります。{$Q+}ならEIntOverflowを上げ、{$R+}だけなら黙って0へラップします。どの構成でこれらのスイッチがオンかは、Delphiのバージョンとプロジェクトの作り方次第です。エラーが片方のビルドだけで出る理由についての説を信じる前に、ビルド構成ごとにプロジェクトオプションで実際の値を読んでください

よくある原因と最小の再現コード

現実のDelphiコードでの範囲チェックエラーのほとんどは、いくつかのパターンに分類でき、それぞれに小さく機械的な修正があります。配列が最も一般的なケースです。静的array[0..9]はインデックス0から9を受け入れ、動的配列やTBytesは0..Length(A) - 1を受け入れ、文字列は1始まりなので、ループはハードコードした限界ではなくLowとHighに対して書きます。コードが壊れるのは空のケースです。空の動的配列のHighは-1なので、Integerカウンタのfor I := 0 to High(A)ループは正しく0回実行されますが、「最後の要素」を取るつもりで使ったA[High(A)]やA[Count - 1]はインデックス-1を読みます。関連するケースは挙動が異なります:

  • コンテナクラスは{$R}の状態に関係なく自分のインデックスをチェックします。TListとTStringListはEListErrorやEStringListErrorを上げ、TList<T>はEArgumentOutOfRangeExceptionを上げます
  • ファイルヘッダやAPIからCardinalやDWORDで届くカウントは、ゼロで危険です。Count - 1が何に評価されるかは、コンパイラが式をどう型付けするか次第だからです。ループカウンタはIntegerに保ち、カウントは一度検証してから変換し、Count > 0を明示的にテストします
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
  Last: Byte;
  P: PByte;
  I: Integer;
begin
  // 空の配列:High(Data) = -1 なので Data[-1] を読む
  Last := Data[High(Data)];               // {$R+} 下では ERangeError

  // 修正:最後の要素を求める前に長さをテストする
  if Length(Data) > 0 then
    Last := Data[High(Data)];

  // TByteArray は array[0..32767] of Byte と宣言されているため、{$R+} 下では
  // Buf がメガバイトを保持していても 32767 を超えるインデックスは失敗する
  I := 40000;
  Last := PByteArray(Buf)^[I];            // I = 40000 で ERangeError

  // 修正:実バッファをインデックスするか、PByte の算術を使う
  P := PByte(Buf);
  Inc(P, I);
  Last := P^;                             // 範囲チェックなし。I は自分で検証する
end;

PByteArrayのケースは経験豊富な開発者も引っかけます。宣言された境界がバッファと何の関係もないからです。DelphiのSysUtilsもFree Pascalのものも、TByteArrayをarray[0..32767] of Byteと宣言しています。16ビット時代にさかのぼる32 KBの窓です。{$R+}の下ではチェックはインデックスを32767と比較するので、100 KBのストリームへの正当なオフセットがERangeErrorを上げます。{$R-}の下ではチェックがなく、バッファの実際の終端を過ぎるインデックスは決して捕捉されません。TBytes自体をインデックスすれば実際の長さに対するチェックが得られるため、こちらをデフォルトにすべきです。PByteの算術はまったく範囲チェックされません。バッファサイズに対する明示的なテストと組み合わせてください

順序型への代入がもう1つの大きなファミリーです。Byteに256、Wordに70000、1..12サブレンジに13を代入するとチェックに失敗します。負のIntegerをCardinalに代入するのも同じです。Delphiは符号付きと符号なしの混在する32ビット演算をInt64として評価するので、演算自体はたいてい正しく、エラーは代入時に現れます。これにより、型が狭すぎる変数が指し示されます。この網の穴がハードタイプキャストです。Byte(I)、Cardinal(I)、TMyEnum(I)は範囲チェックを生成せず、黙って切り詰めたり再解釈したりします。ファイルから読んだ32ビット値で列挙型メンバーを選ぶときは、キャストの前にOrd(High(TMyEnum))とCardinalとして比較してください

var
  B: Byte;
  C: Cardinal;
  I: Integer;
  Addr: Integer;
  Obj: TObject;
begin
  B := 255;
  B := B + 1;            // Integer 256 として評価され、代入が失敗:ERangeError

  I := -1;
  C := I;                // Cardinal への負の値:ERangeError
  C := Cardinal(I);      // ハードキャスト:チェックなし、C = 4294967295

  // Win64:ポインタは8バイト、Integerは4のまま
  Obj := TObject.Create;
  Addr := NativeInt(Obj);  // Integer:2 GB超で失敗(Cardinal:4 GB超)
  // 修正:ポインタサイズの値は NativeInt / NativeUInt に保つ
end;

// DelphiとFPCで同じ挙動を保つ必要がある 2^32 のモジュロ演算
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
  Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;

Win64は同じ間違いのポインタサイズ版を追加します。Pointer、NativeInt、TComponent.Tag(XE2以降はNativeInt)、LPARAM、WPARAMはWin64ではすべて8バイトですが、Integer、Longint、Cardinalは4のままです。範囲チェック付きの代入でアドレスを保存すると、アドレスがターゲット型の範囲外になった時点でERangeErrorが上がります。Integerなら2 GB超、Cardinalなら4 GB超です。ハードキャスト経由で保存すると切り詰められ、後でアクセス違反になります。小さなテストプログラムはアドレス空間の低い場所に割り当てることが多いので、この種のバグはユニットテストより長時間動くプロセスで顔を出しがちです。ポインタ、ハンドル、メッセージパラメータを保持するものはすべてNativeIntかNativeUIntとして宣言してください。Linux64でのLongintとポインタの罠は、LP64ターゲットでLongint自体のサイズが変わる件について、同じ論理に従っています

エラーがDebugだけ、あるいはFPCの下だけで出るのはなぜか

片方のビルドでだけ現れる範囲チェックエラーは、ほぼ常に、もう片方のビルドがチェックしていない本物の境界外アクセスです。{$R-}のReleaseビルドは同じ悪いインデックスを何も言わずに実行し、隣人のメモリを読むか壊します。例外より悪いことです。Free Pascalには独自のスイッチがあります。{$mode delphi}は言語の構文と意味を変えますが、どのチェックもオンにしません。FPCの範囲チェックは{$R+}かコマンドラインオプションの-Crから、オーバーフローチェックは-Coから来ます。LazarusではProject Options › Compiler Options › Debuggingの下にあります。FPCは整数式をネイティブの整数サイズ以上で評価するので、32ビットのDelphiで黙ってラップする式が、64ビットのFPCターゲットではHigh(Cardinal)を超える値を生成することがあり、それをCardinalに書き戻すとチェックに失敗します。私たち自身のSHA-256コードでまさにこれに当たりました。素のCardinalの和として書かれた232のモジュロ加算が、チェック付きのFPCビルドでRange check errorを上げたのです。上のAddMod32のようにUInt64で和を取ってマスクすれば、ユニット全体のチェックを切らずに、両コンパイラでバイト単位で同一の出力を保てます。この種の移植性の罠は他にもFPCの下で偶然だけ動くDelphiコードに集めています

例外を上げた行でデバッガを止めさせる

範囲チェックエラーを見つける最速の道は、IDEデバッガに例外でブレークさせ、悪い値を作ったコードまでコールスタックを遡ることです。例外はチェックが発火した場所を示します。原因は通常、数フレーム下にあります:

  1. 範囲チェックをオンにしてDebug構成を実行し、デバッガオプションのLanguage Exceptionsページの無視リストにERangeErrorが入っていないことを確認します
  2. ダイアログが「raised exception class ERangeError with message 'Range check error'」と報告したら、Breakを選び、Call Stackウィンドウを開きます。先頭の下のフレームが、インデックスや値がどこから来たかを示します
  3. 先頭のフレームがRTLやVCLの内側なら、「Use debug .dcus」を有効にして、それらのフレームがソースに解決されるようにします
  4. インデックスを計算する代入(たとえばIndex < 0)に条件付きブレークポイントを設定すれば、例外より一歩早く間違いを捕捉できます
  5. 顧客のマシンでだけ起こるエラーには、範囲チェックをオンにしたビルドと、madExcept、EurekaLog、JCL Debugのようなスタックトレースロガーを出荷します。同じコールスタックをログファイルに書いてくれます

範囲チェックを切るべきか

すべてのDebugとCIのビルドで範囲チェックをオンに保ち、切るのはローカルだけにします。自分で境界を検証する、実測されたホットループの周辺です。グローバルに切ってもバグは消えません。報告が消えるだけです。古典的な例は、SetLength(Result, 16)を呼んで17から24番目の文字を書き込むhexフォーマットルーチンです。{$R+}の下では即座に失敗し、{$R-}の下では呼び出しごとに文字列の終端を8文字超えて書き込み、その先のヒープメモリに入り、ずっと後で無関係な場所でクラッシュします。ルーチンが本当にチェックには熱すぎるときは、変更のスコープを正しく切ってください。{$R-}の後に{$R+}は整然と見えますが、プロジェクトがオフにしていてもユニットの残りにチェックを強制します。以前の状態を保存して復元し、チェックなし領域は、呼び出し側が境界を強制する小さなルーチン丸ごと1つに留めてください

{$IFOPT R+}
  {$DEFINE RANGECHECKS_WERE_ON}
  {$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
  I: Integer;
begin
  // 呼び出し側が Count が両バッファに収まると保証する。ループの外で一度だけチェック
  for I := 0 to Count - 1 do
    Dst[I] := Dst[I] xor Src[I];
end;
{$IFDEF RANGECHECKS_WERE_ON}
  {$UNDEF RANGECHECKS_WERE_ON}
  {$R+}
{$ENDIF}

PDFパースコードにおける範囲チェックエラー

PDFファイルをパースするコードは特に露出しています。インデックスとして使う数のほぼすべてがファイル自体から来るからです。相互参照テーブルのバイトオフセット、ストリームの/Length値、相互参照ストリームの/W配列のフィールド幅(ISO 32000-1 §7.5.8)、オブジェクトカウント、署名の/ByteRangeエントリは、不正あるいは敵意のあるファイルではすべて信頼できません。if A + B > Length(Data)のような素朴なガードは、細工された/ByteRange [9223372036854775807 1 0 0]に対して失敗します。オーバーフローチェックがオフだと、A + Bは負のInt64へラップし、テストは通り、Data[A]が後で失敗します。各値を残り長さに対してテストしてください((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A))。そうすれば加算がラップすることはありません。複合ガードには別の罠も隠れています。Delphiはandを左から右へ短絡評価しますが、上限テストだけでは負のインデックスを拒否しません。-1 < Length(FPages)はTrueだからです。符号付きの下限を上限より先にテストしなければなりません

// 安全に見えるが、負の PageIndex は上限テストを通過する:
//   if FDocOpen and (PageIndex < Length(FPages)) and
//      (FPages[PageIndex].PageObj <> nil) then ...

if (PageIndex >= 0) and (PageIndex < Length(FPages)) then
begin
  if FDocOpen and (FPages[PageIndex].PageObj <> nil) then
    Result := FPages[PageIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [PageIndex, Length(FPages) - 1]);
PDF ライブラリにおける Delphi 範囲チェックエラーのフロー図。負の DestIndex が片側境界チェックをすり抜け配列アクセスで ERangeError を発生させる様子と、安全に短絡する修正済みガード順の比較
$R+ の下では RTL が添字を実行時に検証するため、負のインデックスは上限だけのテストをすり抜け、配列アクセス自体で発火します。符号付き下限を先にテストすれば、短絡評価により要素に触れる前に拒否できます

ガードはクラッシュを止めますが、インデックスがなぜ間違っていたかは説明しません。ページを扱うコードでは、よくある理由が2つの順序の不一致です。ISO 32000-1 §7.7.3はページ順をページツリーのKids配列を通じて定義します。オブジェクト番号とバイトオフセットはそれについて何も語らず、オブジェクト20がページ1を保持し、オブジェクト3がページ2を保持することもあります。ファイル順にオブジェクトをスキャンしてページリストを組み立てるコードは、ジェネレーターがページを順番に書いたドキュメントではすべて動き、そうでない1つで壊れます。後から表紙ページを足された編集済みファイルなどです。表示順で計算した呼び出し側のインデックスは、間違ったページに着地するか、リストの終端を超えます。PDFページ順序のケーススタディは、qpdfで不一致を確認し、ページツリーからページリストを組み立て直す方法を示しています

Delphi の図。クロスリファレンス走査で発見する PDF オブジェクトの格納順と、カタログからページツリー Kids 配列をたどって復元する表示順の対比
逐次走査はライターが選んだ順序のままページ番号を付けますが、ビューアーはページツリーの Kids 配列に従います。カタログ走査からページ配列を組み立てれば、呼び出し側のインデックスは整合し範囲内に保たれます

自分のパーサーからこれらのエラーを洗い出すには、{$R+}と{$Q+}でビルドし、さまざまなコーパスを走らせます。Word、LaTeX、スキャナーのファームウェア、分割・マージユーティリティからのファイル、そしていくつかの意図的に切り詰めたファイル。例外を上げるファイルはそれぞれ、コードが抱く想定についてのデータポイントです。次のソースで間違ったタイミングで発火するジェネレーター別の特例を足す代わりに、想定を一度直してください。PDFライブラリを呼び出すコードは、ライブラリのページ数を権威として扱ってください。HotPDF Delphi PDFコンポーネントでは、LoadFromFileがロードしたドキュメントのページ数を返し、LoadedPageCountがその後それを報告します。ページインデックスはページツリーの表示順に従うので、外部データから導くインデックスの安全な範囲は0..LoadedPageCount - 1です