Article technique

Contrôle de plage en Delphi : causes et corrections

Un programme Delphi s'arrête avec ERangeError et le message nu « Range check error », souvent dans une seule de vos deux configurations de build. Delphi lève cette exception quand du code compilé avec la vérification de plage activée ({$R+}) indexe un tableau ou une chaîne hors de ses bornes, ou stocke une valeur qui ne tient pas dans le type ordinal cible. La correction se situe presque toujours à l'endroit où le mauvais index ou la mauvaise valeur a été calculé, qui est rarement la ligne sur laquelle le débogueur s'arrête

Que signifie « Range check error » en Delphi ?

ERangeError vient des contrôles que le compilateur insère quand la vérification de plage est active. Avec {$R+} (forme longue {$RANGECHECKS ON}, ou Project Options › Building › Delphi Compiler › Compiling › Range checking), chaque indice de tableau et de chaîne est comparé aux bornes déclarées, et chaque affectation vers un ordinal plus petit, un sous-intervalle ou une variable énumérée est comparée à l'intervalle de ce type. L'état par défaut est {$R-}, et du code compilé ainsi ne contient aucun contrôle, si bien qu'une écriture hors bornes atterrit simplement dans la mémoire voisine. Un contrôle échoué lève ERangeError avec le message de ressource « Range check error » quand SysUtils est lié ; un programme qui n'utilise jamais SysUtils se termine par l'erreur d'exécution 201. L'erreur de compilation E1012 (Constant expression violates subrange bounds) ne couvre que les index que le compilateur voit comme des constantes ; tout ce qui est calculé à l'exécution dépend de {$R+}. Le commutateur fonctionne aussi par unité : les DCU précompilées conservent l'état de leur build, et un {$R-} en tête d'unité prime sur l'option de projet pour cette unité

La vérification de débordement est un commutateur séparé avec une exception séparée. {$Q+} ({$OVERFLOWCHECKS ON}) contrôle l'arithmétique entière telle que +, -, *, Inc et Dec et lève EIntOverflow quand un résultat ne tient pas ; {$R+} contrôle les index et les stockages. Les deux se rejoignent sur les petits types. L'arithmétique Byte est évaluée en Integer, donc B := B + 1 avec B = 255 produit 256 sans aucun débordement, et c'est le stockage retour dans le Byte qui échoue au contrôle de plage. Inc(B) sur la même valeur est au contraire une opération à contrôle de débordement : avec {$Q+} elle lève EIntOverflow, et avec seulement {$R+} elle boucle silencieusement à 0. Savoir quelles configurations ont ces commutateurs actifs dépend de la version de Delphi et de la façon dont le projet a été créé. Lisez les valeurs réelles dans Project Options pour chaque configuration de build avant de faire confiance à une théorie sur la raison pour laquelle l'erreur n'apparaît que dans un seul build

Les causes habituelles, avec des repros minimaux

La plupart des erreurs de contrôle de plage dans du vrai code Delphi tombent dans une poignée de schémas, chacun avec une petite correction mécanique. Les tableaux sont le cas le plus courant. Un array[0..9] statique accepte les index 0 à 9, un tableau dynamique ou TBytes accepte 0..Length(A) - 1, et les chaînes sont basées sur 1, donc écrivez vos boucles contre Low et High plutôt que des limites codées en dur. Le cas vide est celui qui casse le code. High d'un tableau dynamique vide vaut -1, donc une boucle for I := 0 to High(A) avec un compteur Integer s'exécute correctement zéro fois, tandis que A[High(A)] ou A[Count - 1] utilisé pour récupérer « le dernier élément » lit l'index -1. Des cas apparentés se comportent différemment :

  • Les classes de conteneurs contrôlent leurs propres index, quel que soit l'état de {$R} : TList et TStringList lèvent EListError ou EStringListError, et TList<T> lève EArgumentOutOfRangeException
  • Les compteurs qui arrivent en Cardinal ou DWORD depuis un en-tête de fichier ou une API sont risqués à zéro, car ce que Count - 1 évalue dépend de la façon dont le compilateur type l'expression ; gardez les compteurs de boucle en Integer, validez le compteur une fois, convertissez-le, et testez Count > 0 explicitement
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
  Last: Byte;
  P: PByte;
  I: Integer;
begin
  // Tableau vide : High(Data) = -1, donc ceci lit Data[-1]
  Last := Data[High(Data)];               // ERangeError sous {$R+}

  // Correction : tester la longueur avant de demander le dernier élément
  if Length(Data) > 0 then
    Last := Data[High(Data)];

  // TByteArray est déclaré comme array[0..32767] of Byte, donc sous {$R+}
  // tout index au-dessus de 32767 échoue même quand Buf contient des mégaoctets
  I := 40000;
  Last := PByteArray(Buf)^[I];            // ERangeError à I = 40000

  // Correction : indexer le vrai tampon, ou utiliser l'arithmétique PByte
  P := PByte(Buf);
  Inc(P, I);
  Last := P^;                             // pas de contrôle de plage, donc validez I vous-même
end;

Le cas PByteArray piège les développeurs expérimentés parce que la borne déclarée n'a rien à voir avec le tampon. SysUtils de Delphi comme celui de Free Pascal déclarent TByteArray comme array[0..32767] of Byte, une fenêtre de 32 Ko qui date du code 16 bits. Sous {$R+} le contrôle compare votre index à 32767, donc un décalage valide dans un flux de 100 Ko lève ERangeError ; sous {$R-} il n'y a aucun contrôle, et un index au-delà de la vraie fin du tampon n'est jamais repéré. Indexer le TBytes lui-même vous donne un contrôle sur la longueur réelle, ce qui en fait le meilleur choix par défaut. L'arithmétique PByte n'est pas contrôlée du tout. Accompagnez-la d'un test explicite contre la taille du tampon

Les stockages ordinaux sont l'autre grande famille. Affecter 256 à un Byte, 70000 à un Word, ou 13 à un sous-intervalle 1..12 échoue au contrôle, et affecter un Integer négatif à un Cardinal aussi. Delphi évalue des opérandes 32 bits signés et non signés mélangés en Int64, donc l'arithmétique elle-même est généralement correcte et l'erreur apparaît au stockage, ce qui pointe vers la variable dont le type est trop étroit. Les casts forcés sont la brèche dans ce filet : Byte(I), Cardinal(I) et TMyEnum(I) ne génèrent aucun contrôle de plage et tronquent ou réinterprètent silencieusement. Quand une valeur 32 bits lue dans un fichier sélectionne un membre d'énumération, comparez-la en Cardinal contre Ord(High(TMyEnum)) avant le cast

var
  B: Byte;
  C: Cardinal;
  I: Integer;
  Addr: Integer;
  Obj: TObject;
begin
  B := 255;
  B := B + 1;            // évalué comme Integer 256, le stockage échoue : ERangeError

  I := -1;
  C := I;                // négatif dans Cardinal : ERangeError
  C := Cardinal(I);      // cast forcé : pas de contrôle, C = 4294967295

  // Win64 : les pointeurs font 8 octets, Integer reste à 4
  Obj := TObject.Create;
  Addr := NativeInt(Obj);  // Integer : échoue au-dessus de 2 Go (Cardinal : au-dessus de 4 Go)
  // Correction : garder les valeurs de la taille d'un pointeur dans NativeInt / NativeUInt
end;

// Arithmétique modulo 2^32 qui doit se comporter pareillement sous Delphi et FPC
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
  Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;

Win64 ajoute une variante par la taille des pointeurs de la même erreur. Pointer, NativeInt, TComponent.Tag (un NativeInt depuis XE2), LPARAM et WPARAM font tous 8 octets en Win64, tandis que Integer, Longint et Cardinal restent à 4. Stocker une adresse via une affectation à contrôle de plage lève ERangeError dès que l'adresse sort de l'intervalle du type cible : au-dessus de 2 Go pour Integer, au-dessus de 4 Go pour Cardinal. La stocker via un cast forcé la tronque et produit une violation d'accès plus tard. Les petits programmes de test allouent souvent bas dans l'espace d'adressage, donc cette classe de bug tend à apparaître dans des processus longs plutôt que dans des tests unitaires. Déclarez tout ce qui porte un pointeur, un handle ou un paramètre de message en NativeInt ou NativeUInt. Les pièges Longint et pointeur sous Linux64 suivent la même logique pour les cibles LP64, où Longint change lui-même de taille

Pourquoi l'erreur n'apparaît qu'en Debug, ou seulement sous FPC ?

Une erreur de contrôle de plage qui n'apparaît que dans un seul build est presque toujours un vrai accès hors bornes que l'autre build ne contrôle pas. Les builds Release en {$R-} exécutent le même index défectueux sans mot dire et lisent soit la mémoire du voisin, soit la corrompent, ce qui est pire que l'exception. Free Pascal ajoute ses propres commutateurs. {$mode delphi} change la syntaxe et la sémantique du langage mais n'active aucun contrôle ; la vérification de plage sous FPC vient de {$R+} ou de l'option de ligne de commande -Cr, et la vérification de débordement de -Co, que Lazarus affiche sous Project Options › Compiler Options › Debugging. FPC évalue aussi les expressions entières dans la taille entière native ou plus, donc une expression qui boucle silencieusement en 32 bits sous Delphi peut produire une valeur au-dessus de High(Cardinal) sur une cible FPC 64 bits, et son stockage retour dans le Cardinal échoue au contrôle. Nous avons rencontré exactement cela dans notre propre code SHA-256 : les additions modulo 232 écrites comme des sommes Cardinal ordinaires levaient Range check error dans un build FPC contrôlé. Sommer en UInt64 et masquer, comme le fait AddMod32 ci-dessus, garde la sortie identique octet pour octet sur les deux compilateurs sans désactiver les contrôles pour toute l'unité. D'autres pièges de portabilité de ce genre sont rassemblés dans du code Delphi qui ne fonctionne que par accident sous FPC

Faire s'arrêter le débogueur sur la ligne qui a levé l'erreur

La façon la plus rapide de trouver une erreur de contrôle de plage est de laisser le débogueur de l'IDE s'arrêter dessus et de remonter la pile d'appels vers le code qui a produit la mauvaise valeur. L'exception marque l'endroit où le contrôle a tiré. La cause se trouve généralement quelques trames plus bas :

  1. Exécutez la configuration Debug avec la vérification de plage active, et assurez-vous que ERangeError n'est pas dans la liste des exceptions ignorées sur la page Language Exceptions des options du débogueur
  2. Quand le dialogue signale « raised exception class ERangeError with message 'Range check error' », choisissez Break et ouvrez la fenêtre Call Stack ; les trames sous la première montrent d'où vient l'index ou la valeur
  3. Si les premières trames sont dans la RTL ou la VCL, activez « Use debug .dcus » pour que ces trames se résolvent en source
  4. Posez un point d'arrêt conditionnel sur l'affectation qui calcule l'index (par exemple Index < 0) pour attraper l'erreur un pas plus tôt que l'exception
  5. Pour les erreurs qui ne surviennent que chez un client, livrez le build avec la vérification de plage active et un journalisateur de pile d'appels tel que madExcept, EurekaLog ou JCL Debug, qui écrit la même pile dans un fichier de log

Faut-il désactiver la vérification de plage ?

Gardez la vérification de plage active dans chaque build Debug et CI, et ne la coupez que localement, autour d'une boucle chaude mesurée dont vous validez vous-même les bornes. La couper globalement ne supprime pas le bug ; elle supprime le rapport. Un exemple classique est une routine de formatage hexadécimal qui appelle SetLength(Result, 16) et écrit les caractères 17 à 24 : sous {$R+} elle échoue immédiatement, sous {$R-} elle écrit huit caractères après la fin de la chaîne à chaque appel, dans la mémoire du tas qui suit, et plante quelque part sans rapport bien plus tard. Quand une routine est vraiment trop chaude pour les contrôles, cadrez le changement correctement. {$R-} suivi de {$R+} a l'air propre mais force la vérification pour le reste de l'unité même si le projet l'avait coupée, donc sauvegardez et restaurez l'état précédent, et gardez la zone non contrôlée dans une petite routine entière dont les appelants garantissent les bornes

{$IFOPT R+}
  {$DEFINE RANGECHECKS_WERE_ON}
  {$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
  I: Integer;
begin
  // Les appelants garantissent que Count tient dans les deux tampons ; vérifié une fois hors de la boucle
  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}

Erreurs de contrôle de plage dans le code d'analyse PDF

Le code qui analyse des fichiers PDF est particulièrement exposé, car presque chaque nombre qu'il utilise comme index vient du fichier lui-même. Les décalages d'octets dans la table de références croisées, les valeurs /Length des flux, les largeurs de champ dans le tableau /W d'un flux de références croisées (ISO 32000-1 §7.5.8), les compteurs d'objets et les entrées /ByteRange des signatures sont tous non fiables dans un fichier malformé ou hostile. Une garde naïve telle que if A + B > Length(Data) échoue contre un /ByteRange [9223372036854775807 1 0 0] forgé : avec la vérification de débordement coupée, A + B boucle en un Int64 négatif, le test passe, et Data[A] échoue plus tard. Testez chaque valeur contre la longueur restante ((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)) pour qu'aucune addition ne puisse boucler. Les gardes composées cachent un autre piège. Delphi court-circuite and de gauche à droite, mais un test de borne supérieure seul ne rejette pas un index négatif, car -1 < Length(FPages) est True ; la borne inférieure signée doit être testée avant la supérieure

// Semble sûr, mais un PageIndex négatif passe le test de borne supérieure :
//   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]);
Diagramme de flux d'une erreur de contrôle de plage Delphi où un DestIndex négatif contourne un contrôle de bornes à sens unique et déclenche ERangeError à l'accès au tableau, comparé à l'ordre de garde corrigé qui court-circuite en sécurité
Sous $R+ chaque indice est contrôlé à l'exécution, si bien qu'un index négatif survit à un test nu de borne supérieure et se déclenche à l'accès au tableau lui-même. Tester d'abord la borne inférieure signée laisse l'évaluation en court-circuit le rejeter avant tout accès à un élément

La garde arrête le plantage mais n'explique pas pourquoi l'index était faux, et dans le code de gestion des pages la raison habituelle est un décalage entre deux ordres. ISO 32000-1 §7.7.3 définit l'ordre des pages par les tableaux Kids de l'arborescence des pages ; les numéros d'objets et les décalages d'octets n'en disent rien, et l'objet 20 peut porter la page 1 tandis que l'objet 3 porte la page 2. Du code qui construit sa liste de pages en balayant les objets dans l'ordre du fichier marche sur chaque document dont le générateur a écrit les pages en séquence, et se trompe sur celui qui ne l'a pas fait, comme un fichier édité avec une page de couverture ajoutée après coup. Les index que les appelants calculent en ordre d'affichage tombent sur la mauvaise page ou après la fin de la liste. L'étude de cas sur l'ordre des pages PDF montre comment confirmer le décalage avec qpdf et reconstruire la liste des pages depuis l'arborescence

Diagramme Delphi contrastant l'ordre de stockage des objets PDF découvert par un scan de références croisées avec l'ordre d'affichage reconstitué en parcourant depuis le catalogue le tableau Kids de l'arbre des pages
Les balayages séquentiels numérotent les pages selon l'ordre choisi par le rédacteur, tandis que les visualiseurs suivent le tableau Kids de l'arborescence de pages. Construire le tableau de pages depuis le parcours du catalogue garde les index de l'appelant alignés et dans les bornes

Pour secouer ces erreurs hors de votre propre analyseur, compilez-le avec {$R+} et {$Q+} et exécutez-le sur un corpus varié : des fichiers de Word, LaTeX, du firmware de scanners, d'utilitaires de découpage-fusion et quelques-uns délibérément tronqués. Chaque fichier qui lève une exception est un point de données sur une hypothèse que votre code fait, donc corrigez l'hypothèse une fois au lieu d'ajouter un cas spécial par générateur qui se déclenchera au mauvais moment sur la source suivante. Pour du code qui appelle une bibliothèque PDF, traitez le nombre de pages de la bibliothèque comme l'autorité. Dans le composant PDF HotPDF pour Delphi, LoadFromFile renvoie le nombre de pages du document chargé et LoadedPageCount le signale ensuite ; les index de pages suivent l'ordre d'affichage de l'arborescence des pages, donc 0..LoadedPageCount - 1 est l'intervalle sûr pour tout index dérivé de données externes