Ein Delphi-Programm bricht mit ERangeError und der nackten Meldung „Range check error“ ab, oft nur in einer der beiden Build-Konfigurationen. Delphi wirft diese Exception, wenn Code mit eingeschalteter Bereichsprüfung ({$R+}) außerhalb der Grenzen eines Arrays oder Strings indiziert oder einen Wert speichert, der nicht in den Ziel-Ordinaltyp passt. Der Fix gehört fast immer dorthin, wo der schlechte Index oder Wert berechnet wurde, und das ist selten die Zeile, in der der Debugger anhält
Was bedeutet „Range check error“ in Delphi?
ERangeError stammt aus Prüfungen, die der Compiler bei eingeschalteter Bereichsprüfung einfügt. Mit {$R+} (Langform {$RANGECHECKS ON}, oder Project Options › Building › Delphi Compiler › Compiling › Range checking) wird jeder Array- und String-Index gegen die deklarierten Grenzen verglichen, und jede Zuweisung an einen kleineren Ordinaltyp, Subrange oder enumerierten Wert wird gegen den Bereich dieses Typs geprüft. Der Default ist {$R-}, und so kompilierter Code enthält keinerlei Prüfungen, eine Schreibaktion außerhalb der Grenzen landet also einfach in dem Speicher, der nebenan liegt. Eine gescheiterte Prüfung wirft ERangeError mit dem Resourcestring „Range check error“, wenn SysUtils gelinkt ist; ein Programm, das SysUtils nie benutzt, terminiert stattdessen mit Runtime Error 201. Der Compile-Time-Fehler E1012 (Constant expression violates subrange bounds) deckt nur Indizes ab, die der Compiler als Konstanten sieht; alles, was zur Laufzeit berechnet wird, hängt an {$R+}. Der Schalter wirkt auch pro Unit: vorkompilierte DCUs behalten den Zustand, mit dem sie gebaut wurden, und ein {$R-} am Kopf einer Unit überschreibt die Projektoption für diese Unit
Overflow-Prüfung ist ein separater Schalter mit einer separaten Exception. {$Q+} ({$OVERFLOWCHECKS ON}) prüft Integer-Arithmetik wie +, -, *, Inc und Dec und wirft EIntOverflow, wenn ein Ergebnis nicht passt; {$R+} prüft Indizes und Speicherungen. Die beiden treffen sich bei kleinen Typen aufeinander. Byte-Arithmetik wird als Integer ausgewertet, B := B + 1 mit B = 255 produziert also 256 ohne jeden Overflow, und der Rückspeicher in den Byte ist es, der die Bereichsprüfung scheitern lässt. Inc(B) auf demselben Wert ist dagegen eine overflow-geprüfte Operation: mit {$Q+} wirft sie EIntOverflow, und mit nur {$R+} wrappt sie still auf 0. Welche Konfigurationen diese Schalter an haben, hängt von der Delphi-Version und davon ab, wie das Projekt angelegt wurde. Lesen Sie die tatsächlichen Werte in den Project Options für jede Build-Konfiguration, bevor Sie einer Theorie vertrauen, warum der Fehler nur in einem Build auftaucht
Die üblichen Ursachen, mit minimalen Repros
Die meisten Range-Check-Fehler in echtem Delphi-Code fallen in eine Handvoll Muster, jedes mit einem kleinen, mechanischen Fix. Arrays sind der häufigste Fall. Ein statisches array[0..9] akzeptiert Indizes 0 bis 9, ein dynamisches Array oder TBytes akzeptiert 0..Length(A) - 1, und Strings sind 1-basiert, also schreiben Sie Schleifen gegen Low und High statt gegen hart kodierte Grenzen. Der leere Fall ist der, an dem Code zerbricht. High eines leeren dynamischen Arrays ist -1, eine for I := 0 to High(A)-Schleife mit einem Integer-Zähler läuft korrekt null Mal, während A[High(A)] oder A[Count - 1], benutzt um „das letzte Element“ zu holen, den Index -1 liest. Verwandte Fälle verhalten sich anders:
- Container-Klassen prüfen ihre Indizes selbst, egal wie
{$R}steht:TListundTStringListwerfenEListErrorbzw.EStringListError, undTList<T>wirftEArgumentOutOfRangeException - Counts, die als
CardinaloderDWORDaus einem Datei-Header oder einer API ankommen, sind bei null gefährlich, denn woraufCount - 1evaluiert, hängt davon ab, wie der Compiler den Ausdruck typisiert; halten Sie Schleifenzähler alsInteger, prüfen Sie den Count einmal, konvertieren Sie ihn und testen SieCount > 0explizit
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
Last: Byte;
P: PByte;
I: Integer;
begin
// Leeres Array: High(Data) = -1, also liest dies Data[-1]
Last := Data[High(Data)]; // ERangeError unter {$R+}
// Fix: vor dem Fragen nach dem letzten Element die Länge testen
if Length(Data) > 0 then
Last := Data[High(Data)];
// TByteArray ist als array[0..32767] of Byte deklariert, also scheitert
// unter {$R+} jeder Index über 32767, selbst wenn Buf Megabytes hält
I := 40000;
Last := PByteArray(Buf)^[I]; // ERangeError bei I = 40000
// Fix: den echten Buffer indizieren oder PByte-Arithmetik nutzen
P := PByte(Buf);
Inc(P, I);
Last := P^; // keine Bereichsprüfung, also I selbst validieren
end;
Der PByteArray-Fall erwischt erfahrene Entwickler, weil die deklarierte Grenze mit dem Buffer nichts zu tun hat. Delphis SysUtils wie das von Free Pascal deklarieren TByteArray als array[0..32767] of Byte, ein 32-KB-Fenster, das aus 16-Bit-Zeiten stammt. Unter {$R+} vergleicht die Prüfung Ihren Index mit 32767, also wirft ein gültiger Offset in einen 100-KB-Stream ERangeError; unter {$R-} gibt es keine Prüfung, und ein Index hinter dem echten Ende des Buffers wird nie erwischt. Indiziert man die TBytes selbst, bekommt man eine Prüfung gegen die tatsächliche Länge, was sie zum besseren Default macht. PByte-Arithmetik wird gar nicht auf Bereich geprüft. Kombinieren Sie sie mit einem expliziten Test gegen die Buffergröße
Ordinal-Speicherungen sind die andere große Familie. 256 an einen Byte zuweisen, 70000 an ein Word oder 13 an einen 1..12-Subrange scheitert an der Prüfung, und ebenso die Zuweisung eines negativen Integer an einen Cardinal. Delphi wertet gemischte vorzeichenbehaftete und vorzeichenlose 32-Bit-Operanden als Int64 aus, die Arithmetik selbst stimmt also meist, und der Fehler erscheint bei der Speicherung, was auf die Variable zeigt, deren Typ zu schmal ist. Harte Typecasts sind die Lücke in diesem Netz: Byte(I), Cardinal(I) und TMyEnum(I) erzeugen keine Bereichsprüfung und schneiden still ab oder interpretieren um. Wenn ein aus einer Datei gelesener 32-Bit-Wert einen Enum-Eintrag wählt, vergleichen Sie ihn vor dem Cast als Cardinal gegen Ord(High(TMyEnum))
var
B: Byte;
C: Cardinal;
I: Integer;
Addr: Integer;
Obj: TObject;
begin
B := 255;
B := B + 1; // als Integer 256 evaluiert, Speicherung scheitert: ERangeError
I := -1;
C := I; // negativ in Cardinal: ERangeError
C := Cardinal(I); // harter Cast: keine Prüfung, C = 4294967295
// Win64: Pointer sind 8 Bytes, Integer bleibt 4
Obj := TObject.Create;
Addr := NativeInt(Obj); // Integer: scheitert über 2 GB (Cardinal: über 4 GB)
// Fix: pointer-große Werte in NativeInt / NativeUInt halten
end;
// Modulo-2^32-Arithmetik, die sich unter Delphi und FPC gleich verhalten muss
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;
Win64 fügt eine pointer-große Variante desselben Fehlers hinzu. Pointer, NativeInt, TComponent.Tag (ein NativeInt seit XE2), LPARAM und WPARAM sind auf Win64 alle 8 Bytes, während Integer, Longint und Cardinal bei 4 bleiben. Eine Adresse über eine bereichsgeprüfte Zuweisung zu speichern wirft ERangeError aus, sobald die Adresse außerhalb des Bereichs des Zieltyps liegt: über 2 GB für Integer, über 4 GB für Cardinal. Sie über einen harten Cast zu speichern schneidet sie ab und produziert später eine Access Violation. Kleine Testprogramme allokieren oft weit unten im Adressraum, diese Fehlerklasse taucht also eher in langlebigen Prozessen auf als in Unit-Tests. Deklarieren Sie alles, was einen Pointer, ein Handle oder einen Message-Parameter hält, als NativeInt oder NativeUInt. Die Longint- und Pointer-Fallen auf Linux64 folgen derselben Logik für LP64-Ziele, auf denen sich Longint selbst in der Größe ändert
Warum erscheint der Fehler nur im Debug-Build oder nur unter FPC?
Ein Range-Check-Fehler, der nur in einem Build erscheint, ist fast immer ein echter Zugriff außerhalb der Grenzen, den der andere Build nicht prüft. Release-Builds mit {$R-} fahren denselben kaputten Index ohne Murren aus und lesen entweder den Speicher des Nachbarn oder beschädigen ihn, was schlimmer ist als die Exception. Free Pascal bringt eigene Schalter mit. {$mode delphi} ändert Sprachsyntax und -semantik, schaltet aber keine Prüfung ein; Bereichsprüfung unter FPC kommt aus {$R+} oder der Kommandozeilenoption -Cr, Overflow-Prüfung aus -Co, die Lazarus unter Project Options › Compiler Options › Debugging zeigt. FPC wertet Integer-Ausdrücke außerdem in nativer Integergröße oder größer aus, sodass ein Ausdruck, der sich im 32-Bit-Delphi still wrappt, auf einem 64-Bit-FPC-Ziel einen Wert über High(Cardinal) produzieren kann, und die Rückspeicherung in den Cardinal scheitert an der Prüfung. Genau das haben wir in unserem eigenen SHA-256-Code getroffen: die Modulo-232-Additionen als simple Cardinal-Summen geschrieben warfen Range check error in einem geprüften FPC-Build. In UInt64 zu summieren und zu maskieren, wie es AddMod32 oben tut, hält die Ausgabe Byte für Byte identisch auf beiden Compilern, ohne die Prüfungen für die ganze Unit auszuschalten. Weitere Portabilitätsfallen dieser Art sind in Delphi-Code, der unter FPC nur zufällig funktioniert gesammelt
Den Debugger auf der zeilengenauen Stelle anhalten lassen
Der schnellste Weg, einen Range-Check-Fehler zu finden: den IDE-Debugger darauf brechen lassen und den Call Stack hinaufgehen bis zum Code, der den schlechten Wert produziert hat. Die Exception markiert, wo die Prüfung feuerte. Die Ursache sitzt meist ein paar Frames tiefer:
- Fahren Sie die Debug-Konfiguration mit eingeschalteter Bereichsprüfung, und stellen Sie sicher, dass
ERangeErrornicht in der Ignorier-Liste auf der Language Exceptions-Seite der Debugger-Optionen steht - Wenn der Dialog „raised exception class ERangeError with message 'Range check error'“ meldet, wählen Sie Break und öffnen das Call-Stack-Fenster; die Frames unterhalb des obersten zeigen, woher der Index oder Wert kam
- Liegen die obersten Frames in der RTL oder VCL, schalten Sie „Use debug .dcus“ ein, damit diese Frames zu Quelltext auflösen
- Setzen Sie einen bedingten Breakpoint auf die Zuweisung, die den Index berechnet (zum Beispiel
Index < 0), um den Fehler einen Schritt früher zu erwischen als die Exception - Für Fehler, die nur auf einem Kundenrechner auftreten, liefern Sie den Build mit eingeschalteter Bereichsprüfung und einem Stack-Trace-Logger wie madExcept, EurekaLog oder JCL Debug aus, der denselben Call Stack in eine Logdatei schreibt
Sollte man die Bereichsprüfung abschalten?
Halten Sie die Bereichsprüfung in jedem Debug- und CI-Build an und schalten Sie sie nur lokal ab, um einen ausgemessenen Hot Loop, dessen Grenzen Sie selbst validieren. Sie global abzuschalten entfernt den Fehler nicht; es entfernt den Bericht. Ein klassisches Beispiel ist eine Hex-Formatierroutine, die SetLength(Result, 16) aufruft und die Zeichen 17 bis 24 schreibt: unter {$R+} scheitert sie sofort, unter {$R-} schreibt sie bei jedem Aufruf acht Zeichen hinter das Ende des Strings, in den Heap-Speicher, der folgt, und crasht viel später irgendwo Unverwandtes. Ist eine Routine wirklich zu heiß für Prüfungen, scopeen Sie die Änderung korrekt. {$R-} gefolgt von {$R+} sieht ordentlich aus, erzwingt die Prüfung aber für den Rest der Unit, selbst wenn das Projekt sie aus hatte; sichern und restaurieren Sie also den vorherigen Zustand, und halten Sie den ungeprüften Bereich auf eine ganze kleine Routine beschränkt, deren Aufrufer die Grenzen erzwingen
{$IFOPT R+}
{$DEFINE RANGECHECKS_WERE_ON}
{$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
I: Integer;
begin
// Aufrufer garantieren, dass Count in beide Buffers passt; einmal außerhalb der Schleife geprüft
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}
Range-Check-Fehler in PDF-Parsing-Code
Code, der PDF-Dateien parst, ist besonders exponiert, denn nahezu jede Zahl, die er als Index benutzt, stammt aus der Datei selbst. Byte-Offsets in der Cross-Reference-Tabelle, /Length-Werte von Streams, Feldbreiten im /W-Array eines Cross-Reference-Streams (ISO 32000-1 §7.5.8), Objektanzahlen und /ByteRange-Einträge in Signaturen sind in einer fehlerhaften oder feindseligen Datei alle unvertrauenswürdig. Eine naive Absicherung wie if A + B > Length(Data) scheitert an einem handwerklich gebauten /ByteRange [9223372036854775807 1 0 0]: mit ausgeschalteter Overflow-Prüfung wrappt A + B in ein negatives Int64, der Test läuft durch, und Data[A] scheitert später. Prüfen Sie jeden Wert gegen die verbleibende Länge ((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)), damit keine Addition wrappen kann. Compound Guards verstecken eine weitere Falle. Delphi wertet and per Short-Circuit von links nach rechts aus, aber ein reiner Obergrenzentest weist einen negativen Index nicht zurück, denn -1 < Length(FPages) ist True; die vorzeichenbehaftete Untergrenze muss vor der Obergrenze geprüft werden
// Sieht sicher aus, aber ein negativer PageIndex passiert den Obergrenzentest:
// 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]);
Der Guard verhindert den Absturz, erklärt aber nicht, warum der Index falsch war, und in Seiten-Handling-Code ist der übliche Grund eine Diskrepanz zwischen zwei Ordnungen. ISO 32000-1 §7.7.3 definiert die Seitenreihenfolge über die Kids-Arrays des Seitenbaums; Objektnummern und Byte-Offsets sagen nichts darüber aus, und Objekt 20 kann Seite 1 halten, während Objekt 3 Seite 2 hält. Code, der seine Seitenliste durch Scannen der Objekte in Dateireihenfolge aufbaut, funktioniert bei jedem Dokument, dessen Generator Seiten sequenziell schrieb, und geht bei jedem anderen schief, etwa einer editierten Datei mit nachträglich eingefügtem Deckblatt. Indizes, die Aufrufer in Anzeigereihenfolge berechnen, landen auf der falschen Seite oder hinter dem Ende der Liste. Die Fallstudie zur PDF-Seitenreihenfolge zeigt, wie man die Diskrepanz mit qpdf bestätigt und die Seitenliste aus dem Seitenbaum neu aufbaut
Um solche Fehler aus Ihrem eigenen Parser herauszuholen, bauen Sie ihn mit {$R+} und {$Q+} und fahren ihn über ein abwechslungsreiches Korpus: Dateien aus Word, LaTeX, Scanner-Firmware, Split-and-Merge-Utilities und ein paar absichtlich abgeschnittene. Jede Datei, die eine Exception auslöst, ist ein Datenpunkt über eine Annahme, die Ihr Code trifft; beheben Sie die Annahme einmal, statt einen Generator-spezifischen Sonderfall zu ergänzen, der bei der nächsten Quelle zur falschen Zeit feuert. Für Code, der eine PDF-Bibliothek aufruft, behandeln Sie die Seitenzahl der Bibliothek als Autorität. In der HotPDF Delphi PDF component liefert LoadFromFile die Seitenzahl des geladenen Dokuments, und LoadedPageCount meldet sie danach; Seitenindizes folgen der Anzeigereihenfolge des Seitenbaums, 0..LoadedPageCount - 1 ist also der sichere Bereich für jeden Index, den Sie aus externen Daten ableiten