Программа на Delphi останавливается с ERangeError и голым сообщением «Range check error», причём часто лишь в одной из двух конфигураций сборки. Delphi возбуждает это исключение, когда код, скомпилированный с включённой проверкой диапазона ({$R+}), индексирует массив или строку за пределами границ либо сохраняет значение, не влезающее в целевой порядковый тип. Исправление почти всегда принадлежит тому месту, где был вычислен неверный индекс или значение, а это редко та строка, на которой останавливается отладчик
Что означает «Range check error» в Delphi?
ERangeError порождается проверками, которые компилятор вставляет при включённой проверке диапазона. С {$R+} (длинная форма {$RANGECHECKS ON}, или Project Options › Building › Delphi Compiler › Compiling › Range checking) каждый индекс массива и строки сравнивается с объявленными границами, а каждое присваивание меньшему порядковому типу, поддиапазону или перечислению — с диапазоном этого типа. Состояние по умолчанию — {$R-}, и код, скомпилированный так, вообще не содержит проверок, поэтому запись за границей просто попадает в соседнюю память. Провалившаяся проверка возбуждает ERangeError с ресурсной строкой «Range check error», если подключен SysUtils; программа, которая никогда не использует SysUtils, завершается с runtime error 201. Ошибка времени компиляции E1012 (Constant expression violates subrange bounds) покрывает только индексы, которые компилятор видит как константы; всё, что вычисляется во время выполнения, зависит от {$R+}. Переключатель работает и на уровне юнита: предкомпилированные DCU хранят состояние, с которым были собраны, а {$R-} в начале юнита переопределяет опцию проекта для этого юнита
Проверка переполнения — отдельный переключатель с отдельным исключением. {$Q+} ({$OVERFLOWCHECKS ON}) проверяет целочисленную арифметику вроде +, -, *, Inc и Dec и возбуждает EIntOverflow, когда результат не влезает; {$R+} проверяет индексы и присваивания. В малых типах они встречаются. Арифметика Byte вычисляется как Integer, поэтому B := B + 1 при B = 255 даёт 256 без всякого переполнения, и проверку диапазона проваливает само сохранение обратно в Byte. Inc(B) на том же значении — операция с проверкой переполнения: с {$Q+} она возбуждает EIntOverflow, а при одном лишь {$R+} молча заворачивается в 0. Какие конфигурации имеют эти переключатели включёнными, зависит от версии Delphi и от того, как создан проект. Прочитайте фактические значения в Project Options для каждой конфигурации сборки, прежде чем доверять теории о том, почему ошибка появляется только в одной сборке
Типичные причины с минимальными репродукторами
Большинство ошибок проверки диапазона в реальном коде Delphi укладываются в несколько шаблонов, у каждого из которых есть маленькое механическое исправление. Массивы — самый частый случай. Статический array[0..9] принимает индексы от 0 до 9, динамический массив или TBytes — 0..Length(A) - 1, а строки индексируются с единицы, поэтому пишите циклы через Low и High вместо захардкоженных пределов. Ломается код на пустом случае. High пустого динамического массива равен -1, поэтому цикл for I := 0 to High(A) со счётчиком Integer корректно не выполнится ни разу, тогда как A[High(A)] или A[Count - 1] ради «последнего элемента» читают индекс -1. Смежные случаи ведут себя иначе:
- Контейнерные классы проверяют свои индексы сами, в любом состоянии
{$R}:TListиTStringListвозбуждаютEListErrorилиEStringListError, аTList<T>—EArgumentOutOfRangeException - Счётчики, приходящие как
CardinalилиDWORDиз заголовка файла или API, опасны при нуле: то, во что вычисляется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)]; // ERangeError при {$R+}
// Исправление: проверьте длину, прежде чем просить последний элемент
if Length(Data) > 0 then
Last := Data[High(Data)];
// TByteArray объявлен как array[0..32767] of Byte, поэтому при {$R+}
// любой индекс выше 32767 проваливается, даже если Buf держит мегабайты
I := 40000;
Last := PByteArray(Buf)^[I]; // ERangeError при I = 40000
// Исправление: индексируйте реальный буфер или используйте арифметику PByte
P := PByte(Buf);
Inc(P, I);
Last := P^; // проверки диапазона нет, валидируйте I сами
end;
Случай с PByteArray ловит даже опытных разработчиков, потому что объявленная граница не имеет ничего общего с буфером. И SysUtils Delphi, и Free Pascal объявляют TByteArray как array[0..32767] of Byte — окно в 32 КБ, оставшееся от 16-битных времён. При {$R+} проверка сравнивает ваш индекс с 32767, поэтому валидное смещение в поток на 100 КБ возбуждает ERangeError; при {$R-} проверки нет, и индекс за реальным концом буфера не ловится никогда. Индексация самого TBytes даёт проверку по фактической длине, что делает её лучшим выбором по умолчанию. Арифметика PByte проверкой диапазона не покрыта вообще — сопровождайте её явной проверкой размера буфера
Присваивания порядковых значений — вторая большая семья. Присваивание 256 в Byte, 70000 в Word или 13 в поддиапазон 1..12 проваливает проверку, как и присваивание отрицательного Integer в Cardinal. Delphi вычисляет смешанные знаковые и беззнаковые 32-битные операнды как Int64, поэтому сама арифметика обычно корректна, а ошибка проявляется на сохранении, что указывает на переменную со слишком узким типом. Жёсткие приведения типов — дыра в этой сети: Byte(I), Cardinal(I) и TMyEnum(I) не генерируют проверку диапазона и молча обрезают или переинтерпретируют значение. Когда 32-битное значение из файла выбирает член перечисления, сравните его как Cardinal с Ord(High(TMyEnum)) до приведения
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 ГБ (Cardinal: выше 4 ГБ)
// Исправление: держите значения размером с указатель в NativeInt / NativeUInt
end;
// Арифметика по модулю 2^32, одинаково ведущая себя в Delphi и FPC
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 — все 8 байтов на Win64, тогда как Integer, Longint и Cardinal остаются 4. Сохранение адреса через проверяемое присваивание возбуждает ERangeError, как только адрес выходит за диапазон целевого типа: выше 2 ГБ для Integer, выше 4 ГБ для Cardinal. Сохранение через жёсткое приведение обрезает адрес и позже даёт access violation. Небольшие тестовые программы часто выделяют память низко в адресном пространстве, поэтому этот класс ошибок обычно всплывает в долгоживущих процессах, а не в юнит-тестах. Объявляйте всё, что хранит указатель, хэндл или параметр сообщения, как NativeInt или NativeUInt. Ловушки Longint и указателей на Linux64 развивают ту же логику для LP64-целей, где размер меняет сам Longint
Почему ошибка появляется только в Debug или только под FPC?
Ошибка проверки диапазона, появляющаяся лишь в одной сборке, почти всегда — реальный выход за границы, который другая сборка не проверяет. Release-сборки с {$R-} прогоняют тот же ошибочный индекс без жалоб и либо читают чужую память, либо портят её, что хуже исключения. Free Pascal добавляет свои переключатели. {$mode delphi} меняет синтаксис и семантику языка, но не включает никаких проверок; проверка диапазона под FPC приходит из {$R+} или опции командной строки -Cr, а проверка переполнения — из -Co, которую Lazarus показывает под Project Options › Compiler Options › Debugging. FPC к тому же вычисляет целочисленные выражения в родном целом размере или больше, поэтому выражение, молча заворачивающееся в 32-битном Delphi, может дать значение выше High(Cardinal) на 64-битной FPC-цели, и сохранение его обратно в Cardinal проваливает проверку. Мы наткнулись ровно на это в собственном коде SHA-256: сложения по модулю 232, записанные как простые суммы Cardinal, возбуждали Range check error в проверяемой FPC-сборке. Суммирование в UInt64 с маскированием, как делает AddMod32 выше, сохраняет вывод побайтно идентичным на обоих компиляторах, не выключая проверки для всего юнита. Больше таких ловушек переносимости собрано в статье о коде Delphi, который работает под FPC лишь по случайности
Заставляем отладчик остановиться на виновной строке
Быстрейший способ найти ошибку проверки диапазона — дать IDE-отладчику остановиться на ней и пройти вверх по стеку вызовов к коду, породившему неверное значение. Исключение отмечает, где сработала проверка. Причина обычно сидит на несколько фреймов ниже:
- Запустите конфигурацию Debug с включённой проверкой диапазона и убедитесь, что
ERangeErrorне попал в список игнорируемых на странице Language Exceptions настроек отладчика - Когда диалог сообщает «raised exception class ERangeError with message 'Range check error'», выберите Break и откройте окно Call Stack; фреймы под верхним показывают, откуда пришёл индекс или значение
- Если верхние фреймы внутри RTL или VCL, включите «Use debug .dcus», чтобы эти фреймы разрешились в исходники
- Поставьте условную точку останова на присваивании, вычисляющем индекс (например
Index < 0), чтобы поймать ошибку на шаг раньше исключения - Для ошибок, возникающих только на машине клиента, поставляйте сборку с включённой проверкой диапазона и логгером стека вроде madExcept, EurekaLog или JCL Debug, который пишет тот же стек вызовов в лог-файл
Стоит ли выключать проверку диапазона?
Держите проверку диапазона включённой в каждой Debug- и CI-сборке и выключайте только локально, вокруг замеренного горячего цикла, чьи границы вы валидируете сами. Глобальное выключение не убирает баг — оно убирает отчёт о нём. Классический пример — процедура hex-форматирования, вызывающая SetLength(Result, 16) и пишущая символы с 17-го по 24-й: при {$R+} она падает сразу, при {$R-} пишет восемь символов за концом строки при каждом вызове, в какую-то кучную память следом, и падает где-то постороннем гораздо позже. Когда процедура действительно слишком горячая для проверок, ограничьте изменение правильно. {$R-} с последующим {$R+} выглядит аккуратно, но включает проверку до конца юнита, даже если в проекте она была выключена, поэтому сохраняйте и восстанавливайте прежнее состояние и держите непроверяемый регион целой маленькой процедурой, чьи вызывающие обеспечивают границы
{$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]);
Проверка останавливает падение, но не объясняет, почему индекс был неверным, а в коде обработки страниц обычная причина — несовпадение двух порядков. ISO 32000-1 §7.7.3 определяет порядок страниц через массивы Kids дерева страниц; номера объектов и байтовые смещения о нём ничего не говорят, и объект 20 может держать страницу 1, тогда как объект 3 — страницу 2. Код, строящий список страниц сканом объектов в порядке файла, работает на каждом документе, чей генератор записал страницы по порядку, и ломается на любом другом — например, на отредактированном файле с добавленной позже титульной страницей. Индексы, вычисленные вызывающими в порядке отображения, попадают не на ту страницу или за конец списка. Кейс про порядок страниц PDF показывает, как подтвердить несовпадение через qpdf и перестроить список страниц из дерева страниц
Чтобы вытрясти такие ошибки из собственного парсера, соберите его с {$R+} и {$Q+} и прогоните по разнообразному корпусу: файлы из Word, LaTeX, прошивок сканеров, утилит разрезания и склейки плюс несколько намеренно обрезанных. Каждый файл, возбуждающий исключение, — точка данных о предположении, которое делает ваш код, поэтому исправьте предположение один раз вместо добавления специального случая под конкретный генератор, который выстрелит не вовремя на следующем источнике. Для кода, вызывающего PDF-библиотеку, считайте количество страниц библиотеки авторитетным. В HotPDF Delphi PDF component LoadFromFile возвращает количество страниц загруженного документа, а LoadedPageCount сообщает его после; индексы страниц следуют порядку отображения дерева страниц, поэтому 0..LoadedPageCount - 1 — безопасный диапазон для любого индекса, выведенного из внешних данных