Program w Delphi zatrzymuje się z ERangeError i gołym komunikatem „Range check error”, często tylko w jednej z dwóch konfiguracji builda. Delphi podnosi ten wyjątek, gdy kod skompilowany z włączonym sprawdzaniem zakresu ({$R+}) indeksuje tablicę albo łańcuch poza jego granicami albo zapisuje wartość, która nie mieści się w docelowym typie porządkowym. Poprawka prawie zawsze należy w miejsce, gdzie zły indeks albo wartość zostały policzone, a to rzadko jest linia, na której zatrzymuje się debugger
Co znaczy „Range check error” w Delphi?
ERangeError pochodzi z kontroli, które kompilator wstawia, gdy sprawdzanie zakresu jest włączone. Przy {$R+} (długiej formie {$RANGECHECKS ON} albo Project Options › Building › Delphi Compiler › Compiling › Range checking) każdy indeks tablicy i łańcucha jest porównywany z zadeklarowanymi granicami, a każde przypisanie do mniejszego typu porządkowego, podzakresu albo zmiennej wyliczeniowej jest porównywane z zakresem tego typu. Stan domyślny to {$R-}, a kod tak skompilowany nie zawiera żadnych kontroli, więc zapis poza zakresem ląduje po prostu w dowolnej sąsiedniej pamięci. Nieudana kontrola podnosi ERangeError z resource stringiem „Range check error”, gdy SysUtils jest dolinkowany; program, który nigdy nie używa SysUtils, kończy się zamiast tego błędem wykonania 201. Błąd kompilacji E1012 (Constant expression violates subrange bounds) obejmuje tylko indeksy, które kompilator widzi jako stałe; cokolwiek liczonego w czasie wykonania zależy od {$R+}. Przełącznik działa też per unit: prekompilowane DCU zachowują stan, z którym zostały zbudowane, a {$R-} na początku unitu nadpisuje opcję projektu dla tego unitu
Sprawdzanie przepełnienia to osobny przełącznik z osobnym wyjątkiem. {$Q+} ({$OVERFLOWCHECKS ON}) sprawdza arytmetykę całkowitą taką jak +, -, *, Inc i Dec i podnosi EIntOverflow, gdy wynik się nie mieści; {$R+} sprawdza indeksy i zapisy. Oba spotykają się w małych typach. Arytmetyka na Byte jest wyliczana jako Integer, więc B := B + 1 przy B = 255 produkuje 256 bez żadnego przepełnienia, a to zapis z powrotem do Byte zawodzi sprawdzanie zakresu. Inc(B) na tej samej wartości to zamiast tego operacja sprawdzana pod kątem przepełnienia: z {$Q+} podnosi EIntOverflow, a z samym {$R+} po cichu zawija do 0. Które konfiguracje mają te przełączniki włączone, zależy od wersji Delphi i od tego, jak projekt został utworzony. Przeczytaj rzeczywiste wartości w Project Options dla każdej konfiguracji builda, zanim uwierzysz w teorię, dlaczego błąd pojawia się tylko w jednym buildzie
Zwykłe przyczyny, z minimalnymi reproami
Większość błędów sprawdzania zakresu w prawdziwym kodzie Delphi wpada w garść wzorców, każdy z małą, mechaniczną poprawką. Tablice to najczęstszy przypadek. Statyczna array[0..9] przyjmuje indeksy od 0 do 9, tablica dynamiczna albo TBytes przyjmuje 0..Length(A) - 1, a łańcuchy są liczone od 1, więc pisz pętle względem Low i High zamiast sztywnych limitów. Pusty przypadek to tam, gdzie kod się łamie. High pustej tablicy dynamicznej to -1, więc pętla for I := 0 to High(A) z licznikiem Integer poprawnie wykonuje się zero razy, podczas gdy A[High(A)] albo A[Count - 1] użyte, by pobrać „ostatni element”, czytają indeks -1. Pokrewne przypadki zachowują się inaczej:
- Klasy kontenerów sprawdzają własne indeksy, niezależnie od stanu
{$R}:TListiTStringListpodnosząEListErroralboEStringListError, aTList<T>podnosiEArgumentOutOfRangeException - Liczności przychodzące jako
CardinalalboDWORDz nagłówka pliku albo z API są ryzykowne przy zerze, bo to, na co wyjdzieCount - 1, zależy od tego, jak kompilator otypuje wyrażenie; trzymaj liczniki pętli jakoInteger, zwaliduj liczbę raz, skonwertuj ją i testujCount > 0wprost
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
Last: Byte;
P: PByte;
I: Integer;
begin
// Pusta tablica: High(Data) = -1, więc to czyta Data[-1]
Last := Data[High(Data)]; // ERangeError przy {$R+}
// Poprawka: przetestuj długość, zanim poprosisz o ostatni element
if Length(Data) > 0 then
Last := Data[High(Data)];
// TByteArray jest zadeklarowane jako array[0..32767] of Byte, więc przy {$R+}
// każdy indeks powyżej 32767 zawodzi, nawet gdy Buf trzyma megabajty
I := 40000;
Last := PByteArray(Buf)^[I]; // ERangeError przy I = 40000
// Poprawka: indeksuj prawdziwy bufor albo użyj arytmetyki PByte
P := PByte(Buf);
Inc(P, I);
Last := P^; // bez sprawdzania zakresu, więc zwaliduj I sam
end;
Przypadek PByteArray łapie doświadczonych programistów, bo zadeklarowana granica nie ma nic wspólnego z buforem. Zarówno SysUtils Delphi, jak i Free Pascala deklarują TByteArray jako array[0..32767] of Byte, okno 32 KB sięgające czasów kodu 16-bitowego. Przy {$R+} kontrola porównuje twój indeks z 32767, więc poprawny offset w strumień 100 KB podnosi ERangeError; przy {$R-} kontroli nie ma, a indeks za prawdziwym końcem bufora nigdy nie zostaje złapany. Indeksowanie samego TBytes daje kontrolę względem rzeczywistej długości, co czyni je lepszym domyślnym wyborem. Arytmetyka PByte nie jest w ogóle sprawdzana pod kątem zakresu. Paruj ją z jawnym testem względem rozmiaru bufora
Zapisy do typów porządkowych to druga duża rodzina. Przypisanie 256 do Byte, 70000 do Word albo 13 do podzakresu 1..12 zawodzi kontrolę, i to samo robi przypisanie ujemnego Integer do Cardinal. Delphi wylicza mieszane operand ze znakiem i bez znaku 32-bitowe jako Int64, więc sama arytmetyka jest zwykle poprawna, a błąd pojawia się przy zapisie, co wskazuje zmienną, której typ jest za wąski. Twarde rzutowania to dziura w tej sieci: Byte(I), Cardinal(I) i TMyEnum(I) nie generują sprawdzania zakresu i po cichu obcinają albo reinterpretują. Gdy 32-bitowa wartość przeczytana z pliku wybiera człon wyliczenia, porównaj ją jako Cardinal z Ord(High(TMyEnum)) przed rzutowaniem
var
B: Byte;
C: Cardinal;
I: Integer;
Addr: Integer;
Obj: TObject;
begin
B := 255;
B := B + 1; // wyliczone jako Integer 256, zapis zawodzi: ERangeError
I := -1;
C := I; // ujemne do Cardinal: ERangeError
C := Cardinal(I); // twarde rzutowanie: bez kontroli, C = 4294967295
// Win64: wskaźniki mają 8 bajtów, Integer zostaje 4
Obj := TObject.Create;
Addr := NativeInt(Obj); // Integer: zawodzi powyżej 2 GB (Cardinal: powyżej 4 GB)
// Poprawka: trzymaj wartości rozmiaru wskaźnika w NativeInt / NativeUInt
end;
// Arytmetyka modulo 2^32, która musi zachowywać się tak samo pod Delphi i FPC
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;
Win64 dokłada wariant o rozmiarze wskaźnika tego samego błędu. Pointer, NativeInt, TComponent.Tag (od XE2 to NativeInt), LPARAM i WPARAM mają na Win64 po 8 bajtów, podczas gdy Integer, Longint i Cardinal zostają przy 4. Zapis adresu przez przypisanie objęte sprawdzaniem zakresu podnosi ERangeError, gdy tylko adres wylądował poza zakresem typu docelowego: powyżej 2 GB dla Integer, powyżej 4 GB dla Cardinal. Zapis przez twarde rzutowanie go obcina i produkuje access violation później. Małe programy testowe często alokują nisko w przestrzeni adresowej, więc ta klasa błędów pokazuje się raczej w długo działających procesach niż w testach jednostkowych. Deklaruj cokolwiek, co trzyma wskaźnik, uchwyt albo parametr komunikatu, jako NativeInt albo NativeUInt. Pułapki Longint i wskaźników na Linux64 podążają za tą samą logiką dla celów LP64, gdzie sam Longint zmienia rozmiar
Dlaczego błąd pojawia się tylko w Debugu albo tylko pod FPC?
Błąd sprawdzania zakresu pojawiający się tylko w jednym buildzie prawie zawsze jest prawdziwym dostępem poza zakresem, którego drugi build nie sprawdza. Build Release z {$R-} wykonuje ten sam wadliwy indeks bez protestu i albo czyta cudzą pamięć, albo ją uszkadza, co jest gorsze niż wyjątek. Free Pascal dokłada własne przełączniki. {$mode delphi} zmienia składnię i semantykę języka, ale nie włącza żadnych kontroli; sprawdzanie zakresu pod FPC pochodzi z {$R+} albo z opcji wiersza poleceń -Cr, a sprawdzanie przepełnienia z -Co, którą Lazarus pokazuje pod Project Options › Compiler Options › Debugging. FPC wylicza też wyrażenia całkowite w natywnym rozmiarze liczby całkowitej lub większym, więc wyrażenie, które po cichu zawija w 32-bitowym Delphi, może dać wartość powyżej High(Cardinal) na 64-bitowym celu FPC, a zapis jej z powrotem do Cardinal zawodzi kontrolę. Trafiliśmy dokładnie na to we własnym kodzie SHA-256: dodawania modulo 232 zapisane jako zwykłe sumy Cardinal podnosiły Range check error w kontrolowanym buildzie FPC. Sumowanie w UInt64 z maskowaniem, jak robi AddMod32 powyżej, zachowuje wyjście bajt w bajt identyczne na obu kompilatorach bez wyłączania kontroli dla całego unitu. Więcej pułapek przenośności tego rodzaju zebrał artykuł kod Delphi działający pod FPC tylko przez przypadek
Jak zmusić debugger, by zatrzymał się na linii, która go podniosła
Najszybszym sposobem na znalezienie błędu sprawdzania zakresu jest pozwolenie debuggerowi IDE zatrzymać się na nim i przejście po stosie wywołań w górę do kodu, który wyprodukował złą wartość. Wyjątek znaczy miejsce, gdzie odpaliła kontrola. Przyczyna zwykle siedzi kilka ramek niżej:
- Uruchom konfigurację Debug ze włączonym sprawdzaniem zakresu i upewnij się, że
ERangeErrornie jest na liście ignorowanych na stronie Language Exceptions opcji debuggera - Gdy okno dialogowe zgłasza „raised exception class ERangeError with message 'Range check error'”, wybierz Break i otwórz okno Call Stack; ramki poniżej najwyższej pokazują, skąd wziął się indeks albo wartość
- Jeśli najwyższe ramki są wewnątrz RTL albo VCL, włącz „Use debug .dcus”, żeby te ramki rozwiązywały się do źródeł
- Ustaw breakpoint warunkowy na przypisaniu liczącym indeks (na przykład
Index < 0), by złapać pomyłkę o krok wcześniej niż wyjątek - W przypadku błędów występujących tylko u klienta wysyłaj build ze włączonym sprawdzaniem zakresu i loggerem śladu stosu takim jak madExcept, EurekaLog albo JCL Debug, który zapisuje ten sam stos wywołań do pliku dziennika
Czy wyłączać sprawdzanie zakresu?
Trzymaj sprawdzanie zakresu włączone w każdym buildzie Debug i CI, a wyłączaj je tylko lokalnie, wokół wyzmierzonej gorącej pętli, której granice walidujesz sam. Globalne wyłączenie nie usuwa błędu; usuwa raport o nim. Klasyczny przykład to procedura formatująca hex, która woła SetLength(Result, 16) i pisze znaki od 17 do 24: przy {$R+} zawodzi natychmiast, przy {$R-} pisze osiem znaków za końcem łańcucha przy każdym wywołaniu, w dowolną pamięć sterty znajdującą się obok, i wybucha gdzieś niezwiązanym dużo później. Gdy procedura naprawdę jest za gorąca na kontrole, ogranicz zmianę poprawnie. {$R-} po którym następuje {$R+} wygląda schludnie, ale wymusza włączenie sprawdzania dla reszty unitu, nawet gdy projekt miał je wyłączone, więc zapisz i przywróć poprzedni stan i zostaw niesprawdzany region całej małej procedurze, której wołający egzekwują granice
{$IFOPT R+}
{$DEFINE RANGECHECKS_WERE_ON}
{$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
I: Integer;
begin
// Wołający gwarantują, że Count mieści się w obu buforach; sprawdzane raz poza pętlą
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}
Błędy sprawdzania zakresu w kodzie parsującym PDF
Kod parsujący pliki PDF jest szczególnie wystawiony, bo prawie każda liczba, której używa jako indeksu, pochodzi z samego pliku. Bajtowe offsety w tablicy odwołań krzyżowych, wartości /Length strumieni, szerokości pól w tablicy /W strumienia odwołań krzyżowych (ISO 32000-1 §7.5.8), liczniki obiektów i wpisy /ByteRange w podpisach — wszystko to jest niezaufane w zniekształconym albo wrogim pliku. Naiwna straż typu if A + B > Length(Data) zawodzi wobec podrobionego /ByteRange [9223372036854775807 1 0 0]: przy wyłączonym sprawdzaniu przepełnienia A + B zawija do ujemnego Int64, test przechodzi, a Data[A] wybucha później. Testuj każdą wartość względem pozostałej długości ((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)), żeby żadne dodawanie nie mogło zawinąć. Złożone straże kryją inną pułapkę. Delphi skraca and od lewej do prawej, ale samo sprawdzenie górnej granicy nie odrzuca ujemnego indeksu, bo -1 < Length(FPages) to True; znakowaną dolną granicę trzeba sprawdzić przed górną
// Wygląda bezpiecznie, ale ujemny PageIndex przechodzi test górnej granicy:
// 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]);
Straż zatrzymuje awarię, ale nie wyjaśnia, dlaczego indeks był zły, a w kodzie obsługującym strony zwykłym powodem jest niezgodność między dwoma porządkami. ISO 32000-1 §7.7.3 definiuje kolejność stron przez tablice Kids drzewa stron; numery obiektów i bajtowe offsety nie mówią o niej nic, a obiekt 20 może trzymać stronę 1, podczas gdy obiekt 3 trzyma stronę 2. Kod, który buduje listę stron skanując obiekty w kolejności pliku, działa na każdym dokumencie, którego generator zapisał strony po kolei, i psuje się na każdym, który tego nie zrobił, na przykład na edytowanym pliku ze wstawioną później stroną tytułową. Indeksy, które wołający liczą w kolejności wyświetlania, lądują na złej stronie albo za końcem listy. Studium przypadku kolejności stron PDF pokazuje, jak potwierdzić niezgodność przez qpdf i przebudować listę stron z drzewa stron
Żeby wytrzepać te błędy z własnego parsera, buduj go z {$R+} i {$Q+} i przepuszczaj przez zróżnicowany korpus: pliki z Worda, LaTeXa, firmware skanerów, narzędzi do dzielenia i scalania oraz kilka celowo obciętych. Każdy plik, który podnosi wyjątek, to punkt danych o założeniu, którego twój kod dokonuje, więc napraw to założenie raz, zamiast dodawać specjalny przypadek per generator, który odpali w złym momencie na kolejnym źródle. Dla kodu wołającego bibliotekę PDF traktuj liczbę stron biblioteki jako autorytet. W komponencie HotPDF Delphi PDF LoadFromFile zwraca liczbę stron wczytanego dokumentu, a LoadedPageCount raportuje ją po fakcie; indeksy stron podążają za kolejnością wyświetlania drzewa stron, więc 0..LoadedPageCount - 1 to bezpieczny zakres dla każdego indeksu wyprowadzonego z danych zewnętrznych