Articolo tecnico

Range check error in Delphi: cause e correzioni

Un programma Delphi si ferma con ERangeError e il messaggio a secco "Range check error", spesso in una sola delle tue due configurazioni di build. Delphi solleva questa eccezione quando codice compilato con il range checking attivo ({$R+}) indicizza un array o una stringa fuori dai suoi limiti, oppure memorizza un valore che non entra nel tipo ordinale di destinazione. La correzione quasi sempre sta dove è stato calcolato l'indice o il valore sbagliato, che raramente è la riga su cui il debugger si ferma

Cosa significa "Range check error" in Delphi?

ERangeError viene dai controlli che il compilatore inserisce quando il range checking è attivo. Con {$R+} (forma lunga {$RANGECHECKS ON}, oppure Project Options › Building › Delphi Compiler › Compiling › Range checking), ogni pedice di array e stringa viene confrontato con i limiti dichiarati, e ogni assegnamento a un ordinale più piccolo, a un subrange o a una variabile enumerata viene confrontato con l'intervallo di quel tipo. Lo stato predefinito è {$R-}, e il codice compilato così non contiene proprio controlli, quindi una scrittura fuori dai limiti atterra semplicemente nella memoria che sta accanto. Un controllo fallito solleva ERangeError con la resource string "Range check error" quando SysUtils è linkato; un programma che non usa mai SysUtils termina con il runtime error 201. L'errore di compilazione E1012 (Constant expression violates subrange bounds) copre solo gli indici che il compilatore vede come costanti; qualsiasi cosa calcolata a runtime dipende da {$R+}. Lo switch funziona anche per unit: le DCU precompilate conservano lo stato con cui sono state costruite, e un {$R-} in testa a una unit scavalca l'opzione di progetto per quella unit

L'overflow checking è uno switch separato con un'eccezione separata. {$Q+} ({$OVERFLOWCHECKS ON}) controlla l'aritmetica intera come +, -, *, Inc e Dec e solleva EIntOverflow quando un risultato non entra; {$R+} controlla indici e memorizzazioni. I due si incontrano sui tipi piccoli. L'aritmetica sui Byte viene valutata come Integer, quindi B := B + 1 con B = 255 produce 256 senza nessun overflow, ed è la riscrittura nel Byte a far fallire il range check. Inc(B) sullo stesso valore è invece un'operazione con controllo di overflow: con {$Q+} solleva EIntOverflow, e con solo {$R+} wrapa silenziosamente a 0. Quali configurazioni abbiano questi switch attivi dipende dalla versione di Delphi e da come è stato creato il progetto. Leggi i valori reali in Project Options per ogni configurazione di build prima di fidarti di una teoria sul perché l'errore compaia in una sola build

Le cause consuete, con repro minimali

La maggior parte dei range check error nel codice Delphi reale cade in una manciata di pattern, ognuno con una correzione piccola e meccanica. Gli array sono il caso più comune. Un array[0..9] statico accetta indici da 0 a 9, un array dinamico o TBytes accetta 0..Length(A) - 1, e le stringhe partono da 1, quindi scrivi i cicli contro Low e High invece di limiti cablati. Il caso vuoto è dove il codice si rompe. High di un array dinamico vuoto è -1, quindi un ciclo for I := 0 to High(A) con contatore Integer correttamente gira zero volte, mentre A[High(A)] o A[Count - 1] usati per prendere "l'ultimo elemento" leggono l'indice -1. Casi imparentati si comportano diversamente:

  • Le classi contenitore controllano i loro indici per conto loro, qualunque sia lo stato di {$R}: TList e TStringList sollevano EListError o EStringListError, e TList<T> solleva EArgumentOutOfRangeException
  • I conteggi che arrivano come Cardinal o DWORD da un header di file o da una API sono rischiosi a zero, perché ciò a cui Count - 1 valuta dipende da come il compilatore tipizza l'espressione; tieni i contatori dei cicli come Integer, valida il conteggio una volta, convertilo, e testa Count > 0 esplicitamente
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
  Last: Byte;
  P: PByte;
  I: Integer;
begin
  // Array vuoto: High(Data) = -1, quindi qui si legge Data[-1]
  Last := Data[High(Data)];               // ERangeError sotto {$R+}

  // Correzione: testa la lunghezza prima di chiedere l'ultimo elemento
  if Length(Data) > 0 then
    Last := Data[High(Data)];

  // TByteArray è dichiarato come array[0..32767] of Byte, quindi sotto {$R+}
  // qualsiasi indice sopra 32767 fallisce anche quando Buf contiene megabyte
  I := 40000;
  Last := PByteArray(Buf)^[I];            // ERangeError a I = 40000

  // Correzione: indicizza il buffer reale, oppure usa l'aritmetica su PByte
  P := PByte(Buf);
  Inc(P, I);
  Last := P^;                             // nessun range check, quindi valida I per conto tuo
end;

Il caso PByteArray becca anche sviluppatori esperti, perché il limite dichiarato non c'entra nulla con il buffer. Sia SysUtils di Delphi che quello di Free Pascal dichiarano TByteArray come array[0..32767] of Byte, una finestra da 32 KB che risale al codice a 16 bit. Sotto {$R+} il controllo confronta il tuo indice con 32767, quindi un offset valido dentro uno stream da 100 KB solleva ERangeError; sotto {$R-} non c'è controllo, e un indice oltre la fine reale del buffer non viene mai beccato. Indicizzare direttamente il TBytes ti dà un controllo contro la lunghezza vera, il che lo rende la scelta predefinita migliore. L'aritmetica su PByte non è per niente soggetta a range check. Abbinala a un test esplicito contro la dimensione del buffer

Le memorizzazioni su ordinali sono l'altra grande famiglia. Assegnare 256 a un Byte, 70000 a un Word, o 13 a un subrange 1..12 fallisce il controllo, e così assegnare un Integer negativo a un Cardinal. Delphi valuta operandi misti con e senza segno a 32 bit come Int64, quindi l'aritmetica in sé di solito è corretta e l'errore compare alla memorizzazione, il che ti punta alla variabile il cui tipo è troppo stretto. I typecast forzati sono il buco della rete: Byte(I), Cardinal(I) e TMyEnum(I) non generano nessun range check e troncano o reinterpretano in silenzio. Quando un valore a 32 bit letto da un file seleziona un membro di un enum, confrontalo come Cardinal con Ord(High(TMyEnum)) prima del cast

var
  B: Byte;
  C: Cardinal;
  I: Integer;
  Addr: Integer;
  Obj: TObject;
begin
  B := 255;
  B := B + 1;            // valutato come Integer 256, la scrittura fallisce: ERangeError

  I := -1;
  C := I;                // negativo dentro Cardinal: ERangeError
  C := Cardinal(I);      // cast forzato: nessun controllo, C = 4294967295

  // Win64: i puntatori sono 8 byte, Integer resta 4
  Obj := TObject.Create;
  Addr := NativeInt(Obj);  // Integer: fallisce sopra 2 GB (Cardinal: sopra 4 GB)
  // Correzione: tieni i valori larghi come un puntatore in NativeInt / NativeUInt
end;

// Aritmetica modulo 2^32 che deve comportarsi uguale sotto Delphi e FPC
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
  Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;

Win64 aggiunge una variante con la larghezza del puntatore dello stesso errore. Pointer, NativeInt, TComponent.Tag (un NativeInt da XE2 in poi), LPARAM e WPARAM sono tutti 8 byte su Win64, mentre Integer, Longint e Cardinal restano 4. Memorizzare un indirizzo attraverso un assegnamento con range check solleva ERangeError appena l'indirizzo esce dall'intervallo del tipo destinazione: sopra 2 GB per Integer, sopra 4 GB per Cardinal. Memorizzarlo attraverso un cast forzato lo tronca e produce un access violation più avanti. I programmini di test spesso allocano in basso nello spazio degli indirizzi, quindi questa classe di bug tende a comparire in processi long-running piuttosto che negli unit test. Dichiara NativeInt o NativeUInt qualsiasi cosa contenga un puntatore, un handle o un parametro di messaggio. Le trappole di Longint e dei puntatori su Linux64 seguono la stessa logica per i target LP64, dove Longint cambia lui stesso dimensione

Perché l'errore compare solo in Debug, o solo sotto FPC?

Un range check error che compare in una sola build è quasi sempre un accesso fuori dai limiti reale che l'altra build non sta controllando. Le build Release con {$R-} fanno girare lo stesso indice difettoso senza protestare e o leggono la memoria del vicino o la corrompono, il che è peggio dell'eccezione. Free Pascal aggiunge i suoi switch. {$mode delphi} cambia sintassi e semantica del linguaggio ma non attiva alcun controllo; il range checking sotto FPC viene da {$R+} o dall'opzione a riga di comando -Cr, e l'overflow checking da -Co, che Lazarus mostra sotto Project Options › Compiler Options › Debugging. FPC valuta anche le espressioni intere nella dimensione dell'intero nativo o superiore, così un'espressione che wrapa in silenzio a 32 bit in Delphi può produrre un valore sopra High(Cardinal) su un target FPC a 64 bit, e riscriverlo nel Cardinal fallisce il controllo. Noi abbiamo beccato esattamente questo nel nostro codice SHA-256: le addizioni modulo 232 scritte come somme Cardinal semplici sollevavano Range check error in una build FPC controllata. Sommare in UInt64 e mascherare, come fa AddMod32 qui sopra, tiene l'output byte per byte identico su entrambi i compilatori senza spegnere i controlli per tutta la unit. Altre trappole di portabilità di questo tipo sono raccolte in codice Delphi che funziona per caso sotto FPC

Fermare il debugger sulla riga che ha sollevato l'errore

Il modo più veloce per trovare un range check error è lasciare che il debugger dell'IDE si fermi su di esso e risalire lo stack delle chiamate fino al codice che ha prodotto il valore sbagliato. L'eccezione marca dove il controllo è scattato. La causa di solito siede qualche frame più in basso:

  1. Fai girare la configurazione Debug con il range checking attivo, e assicurati che ERangeError non sia nella lista degli ignorati sulla pagina Language Exceptions delle opzioni del debugger
  2. Quando il dialogo segnala "raised exception class ERangeError with message 'Range check error'", scegli Break e apri la finestra Call Stack; i frame sotto il primo mostrano da dove viene l'indice o il valore
  3. Se i frame in cima sono dentro la RTL o la VCL, attiva "Use debug .dcus" così quei frame si risolvono al sorgente
  4. Metti un breakpoint condizionale sull'assegnamento che calcola l'indice (per esempio Index < 0) per beccare l'errore un passo prima di quanto faccia l'eccezione
  5. Per errori che compaiono solo sulla macchina di un cliente, spedi la build con range checking attivo e un logger di stack trace come madExcept, EurekaLog o JCL Debug, che scrive lo stesso call stack su un file di log

Conviene spegnere il range checking?

Tieni il range checking attivo in ogni build Debug e CI, e spegnilo solo localmente, attorno a un ciclo caldo misurato i cui limiti validi tu per conto tuo. Spegnerlo globalmente non rimuove il bug; rimuove il report. Un esempio classico è una routine di formattazione esadecimale che chiama SetLength(Result, 16) e scrive i caratteri da 17 a 24: sotto {$R+} fallisce immediatamente, sotto {$R-} scrive otto caratteri oltre la fine della stringa a ogni chiamata, nella memoria heap che viene dopo, e crasha da qualche parte senza relazione molto più tardi. Quando una routine è davvero troppo calda per i controlli, delimita la modifica correttamente. {$R-} seguito da {$R+} sembra ordinato ma forza i controlli attivi per il resto della unit anche se il progetto li aveva spenti, quindi salva e ripristina lo stato precedente, e tieni la regione senza controlli dentro una intera routine piccola i cui chiamanti fanno rispettare i limiti

{$IFOPT R+}
  {$DEFINE RANGECHECKS_WERE_ON}
  {$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
  I: Integer;
begin
  // I chiamanti garantiscono che Count entri in entrambi i buffer; verificato una volta fuori dal ciclo
  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 error nel codice di parsing dei PDF

Il codice che parserizza file PDF è esposto in modo speciale, perché quasi ogni numero che usa come indice viene dal file stesso. Gli offset di byte nella tabella cross-reference, i valori /Length degli stream, le larghezze di campo nell'array /W di uno stream cross-reference (ISO 32000-1 §7.5.8), i conteggi di oggetti e le voci /ByteRange delle firme sono tutti non affidabili in un file malformato o ostile. Una guardia ingenua come if A + B > Length(Data) fallisce contro un /ByteRange [9223372036854775807 1 0 0] costruito ad hoc: con l'overflow checking spento, A + B wrapa in un Int64 negativo, il test passa, e Data[A] fallisce più avanti. Testa ogni valore contro la lunghezza rimanente ((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)) così nessuna addizione può wrapare. Le guardie composte nascondono un'altra trappola. Delphi valuta and in corto circuito da sinistra a destra, ma un solo test del limite superiore non respinge un indice negativo, perché -1 < Length(FPages) è True; il limite inferiore con segno va testato prima di quello superiore

// Sembra sicuro, ma un PageIndex negativo passa il test del limite superiore:
//   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]);
Diagramma di flusso di un range check error Delphi in cui un DestIndex negativo bypassa un controllo dei limiti a senso unico e solleva ERangeError all'accesso all'array, a confronto con l'ordine corretto delle guardie che va in corto circuito in sicurezza
Sotto $R+ ogni pedice viene controllato a runtime, così un indice negativo sopravvive a un test nudo del limite superiore e scatta all'accesso all'array stesso. Testare prima il limite inferiore con segno lascia che la valutazione in corto circuito lo respinga prima che qualsiasi elemento venga toccato

La guardia ferma il crash ma non spiega perché l'indice era sbagliato, e nel codice di gestione delle pagine il motivo consueto è una discrepanza tra due ordinamenti. ISO 32000-1 §7.7.3 definisce l'ordine delle pagine attraverso gli array Kids dell'albero pagine; i numeri di oggetto e gli offset di byte non dicono nulla a riguardo, e l'oggetto 20 può contenere la pagina 1 mentre l'oggetto 3 contiene la pagina 2. Il codice che costruisce la sua lista pagine scannerizzando gli oggetti in ordine di file funziona su ogni documento il cui generatore ha scritto le pagine in sequenza, e va in errore su qualsiasi documento che non l'ha fatto, come un file modificato con una copertina aggiunta dopo. Gli indici che i chiamanti calcolano in ordine di visualizzazione atterrano sulla pagina sbagliata o oltre la fine della lista. Lo studio di caso sull'ordine delle pagine PDF mostra come confermare la discrepanza con qpdf e ricostruire la lista pagine dall'albero pagine

Diagramma Delphi che contrappone l'ordine di memorizzazione degli oggetti PDF scoperto da una scansione cross-reference all'ordine di visualizzazione recuperato percorrendo l'array Kids dell'albero pagine dal catalogo
Le scansioni sequenziali numerano le pagine secondo l'ordine che il writer ha scelto, mentre i viewer seguono l'array Kids dell'albero pagine. Costruire l'array pagine dalla traversata del catalogo tiene gli indici dei chiamanti allineati e dentro l'intervallo

Per scuotere questi errori dal tuo parser, compilalo con {$R+} e {$Q+} e fallo girare su un corpus vario: file di Word, LaTeX, firmware di scanner, utility di split-and-merge e qualcuno troncato apposta. Ogni file che solleva un'eccezione è un punto dati su un'assunzione che il tuo codice fa, quindi correggi l'assunzione una volta invece di aggiungere un caso speciale per generatore che scatterà al momento sbagliato sulla prossima sorgente. Per il codice che chiama una libreria PDF, tratta il conteggio pagine della libreria come l'autorità. Nel componente Delphi PDF HotPDF, LoadFromFile restituisce il numero di pagine del documento caricato e LoadedPageCount lo riporta dopo; gli indici di pagina seguono l'ordine di visualizzazione dell'albero pagine, quindi 0..LoadedPageCount - 1 è l'intervallo sicuro per qualsiasi indice tu derivi da dati esterni