Код Delphi под Win64 может падать там, где тот же исходник чисто бежит на Win32, и компонент HotPDF Delphi PDF component поймал пять таких случаев за недавний упрочняющий проход: Power(10, N) биндится к перегрузке Single, while-цикл читает протухший TList.Count, граница High(Int64) округляется вверх до 2^63, 15-значный float-текст на FPC и ассерты тестов, перестающие компилироваться
Ни одно из этого не проявится, если вы собираете и тестируете только Win32 — ровно так они и просочились. Разобранные случаи приходят из SVG- и XPS-импортёров HotPDF, его страничного рендерера и его JSON-читателя заданий, а приведённые численные результаты воспроизведены маленькими пробными программами, собранными под Win32 и Win64. Если вы переводите кодовую базу Delphi на 64 бита, каждый случай стоит одного grep
Почему Power(10, 100) переполняется только на Win64?
На Win64 System.Math.Power(10, N) с целыми аргументами разрешается в перегрузку Single, так что результат считается и возвращается в одинарной точности, и всё выше примерно 3.4E38 переполняется. На Win32 тот же вызов биндится к перегрузке Extended и бежит на x87 FPU с 80-битной точностью, так что Power(10, 100) — просто 1E100
System.Math объявляет Power для Extended, Double и Single, плюс семейство IntPower, которое Power зовёт при целом показателе. На Win64 Extended — лишь псевдоним Double (SizeOf(Extended) = 8), а для двух целых аргументов компилятор выбирает версию Single. Выдаёт не только переполнение, но и точность: на Win64 Power(10, 20) возвращает 1.0000000200408773E20 — это ровно Single(1E20). Результат Double напечатался бы как 1E20. Мы видели тот же биндинг на каждом опробованном Win64-компиляторе, от Delphi 10.3 до версии компилятора 37.0
Что случится дальше, зависит от маски исключений плавающей точки. Delphi 12 и новее маскируют все FP-исключения по умолчанию, так что переполнение безмолвно: Power(10, 100) возвращает +Inf, а Power(10, -100) — 0. Delphi 11 и старше оставляют exOverflow незамаскированным, и тот же вызов поднимает EOverflow. Приложения, выставляющие маску сами, и DLL, загруженные в такие хосты, получают то поведение, которое выбрал хост, — потому библиотека не может полагаться ни на один исход
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 печатает 1E20; Win64 печатает 1.0000000200408773E20 (перегрузка Single)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Воспроизводим то, что делает Delphi 11 или хост со строгими FP-настройками
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
Снять маску с exOverflow и exInvalidOp на время теста — дешёвый способ увидеть то, что видит старый компилятор или строгий хост. На современном компиляторе с дефолтными настройками баг не падает, а выдаёт бесконечности и нули, а их в тестовом логе заметить куда труднее. Восстанавливайте прежнюю маску в finally: маска — состояние на поток, и остаток тестового прогона наследует всё, что вы оставили
Как перегрузка добралась до SVG- и XPS-импорта HotPDF
Читатели путей SVG и XPS в HotPDF делят один сканер чисел, и тот сканер масштабировал мантиссу через Power(10, Exponent), едва прочитав показатель. Любой SVG, переданный в THotPDF.ImportSVGFormXObject (точку входа за статьёй о импорте SVG в PDF как переиспользуемых form XObject), и любая геометрия путей, обрабатываемая при конверсии XPS и OpenXPS в PDF, могли скормить этому вызову координату вроде 1e100 или 5e99
v2.770.91 уже зажимала показатель сотней и отвергала значения, которые перевалят за 1E300, — казалось, достаточно: 1E100 и рядом не стоит с пределом Double около 1.8E308. На Win64 всё равно переполнялось, потому что вычисление вообще не шло в Double. С v2.770.155 сканер строит степень десятки сам, и числа вроде 1e-100 или длинная мантисса с большим отрицательным показателем читаются своим настоящим значением вместо схлопывания в 0
Безопасная степень десятки для ограниченных показателей
Когда показатель ограничен, безопаснейшая степень десятки — та, что вы построили сами умножениями в Double. Цикл из максимум 100 умножений не стоит ничего рядом со сканированием окружающего текста, он никогда не порождает промежуточное число крупнее финального масштаба и ведёт себя одинаково на Win32, Win64 и Free Pascal
const
MaxDecimalExponent = 100;
function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
out Scaled: Double): Boolean;
var
Scale: Double;
I: Integer;
begin
Scaled := 0;
Result := False;
if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
Exit;
// Отказываем результатам, которые покинут диапазон Double
if (Exponent > 0) and (Value <> 0) and
(Log10(Abs(Value)) + Exponent > 300) then
Exit;
Scale := 1.0;
for I := 1 to Abs(Exponent) do
Scale := Scale * 10.0; // никогда не превышает 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // делим: 1E-100 не имеет точного Double
Result := True;
end;
Три детали несут вес. Проверка диапазона использует два сравнения вместо Abs(Exponent) <= 100, потому что Abs(Low(Integer)) всё ещё отрицателен и проскочил бы насквозь. Отрицательные показатели делят на масштаб вместо умножения на предвычисленную 1E-100, у которой нет точного Double и которая добавила бы ещё один шаг округления. А пре-чек Log10 отказывает результатам вне диапазона Double прежде, чем умножение успеет переполниться
Будьте честны насчёт того, чем цикл платит. Степени десятки до 1E22 точны в Double; дальше каждое умножение округляет, и после сотни из них масштаб отстоит на несколько единиц в последнем знаке от корректно округлённой 1E100. Для координат рисования это невидимо. Для конверсии текст-в-double общего назначения, обязанной воспроизводить каждое значение бит в бит, этого мало — нужна корректно округляющая конверсия
Когда dcc64 читает протухший TList.Count в while-цикле
Мы наблюдали, как Win64-компилятор (dcc64, версия компилятора 37.0) генерирует код для цикла while List.Count > Start do, удаляющего с конца списка и сравнивающего со стековой временной вместо перечитывания Count. Правка, починившая это, — цикл for ... downto, чьи границы по определению вычисляются ровно один раз
Цикл приехал в v2.769.3, которая научила код прозрачностных групп рендерера держать мягкие маски, созданные внутри группы, живыми через двухпроходный рендер и освобождать их потом. Чистка сидела в блоке finally после одно- или двухпроходного цикла for, внутри цикла по тайлам. В редуцированном виде «до» и «после» выглядят так:
// Форма, которую мы видели неверно скомпилированной dcc64 (версия 37.0)
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
while Masks.Count > Start do
begin
TObject(Masks[Masks.Count - 1]).Free;
Masks.Delete(Masks.Count - 1);
end;
end;
// Замена: границы вычисляются однажды, временной нечему протухать
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count — NativeInt с Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
В сгенерированном Win64-коде Count из условия цикла и Count, читаемый внутри тела, делили один слот стека. Условие сравнивало с тем слотом на входе, прежде чем что-либо успело его записать, и ничто не освежало его после Delete. Когда группа не создала ни одной собственной мягкой маски, тело всё равно бежало и спрашивало у пустого списка элемент -1, так что в 64-битных сборках каждая страница с такой прозрачностной группой падала с EListError. Win32-код того же исходника был корректен, и v2.770.1 заменила цикл
Мы не свели это к минимальной репродукции, и маленький автономный цикл вроде DropMasksWhile вполне может скомпилироваться правильно; окружающие try/finally и вложенные циклы, похоже, имеют значение. Трактуйте это как наблюдаемую кодогенерацию одной версии компилятора, а не как известный дефект всякого Win64-компилятора. Практический урок дешевле первопричины: цикл, чьё условие перечитывает количество элементов коллекции, пока тело её усыхает, стоит переписать как for ... downto с фиксированными границами, а правкам рендерера нужен полный Win64-прогон тестов, а не только Win32
Локализация падения, которое показывает только оптимизированная Win64-сборка
Отказ воспроизводился только в оптимизированной Win64-сборке, так что локация пришла из инструментов вне IDE. Маленькая пробная программа регистрировала векторный обработчик исключений через AddVectoredExceptionHandler, снимала стек на первом исключении через RtlCaptureStackBackTrace и переводила адреса возврата в имена функций по детальной map-карте, которую линкер пишет с -GD. Дизассемблирование той функции показало сравнение, читающее стековый слот [rbp+0x298], который писался только внутри тела цикла. Это тот уровень улик, который нужен, прежде чем винить компилятор, и занял он меньше времени, чем степпинг по release-сборке
Почему High(Int64) — не безопасная верхняя граница для Double?
Double не может представить High(Int64): конверсия 9223372036854775807 в Double округляется вверх ровно до 2^63 — на единицу за наибольшим Int64. На Win64 эта конверсия случается внутри самого сравнения, так что D <= High(Int64) — True для D = 2^63, а следующий за ним Round или Trunc переполняется
Win32 прячет это по той же причине, что и проблему с Power. Сравнение бежит в 80-битной точности Extended с 64-битной мантиссой, где High(Int64) точен и 2^63 корректно сравнивается как большее. Win64 широчайшего типа в запасе не имеет. Вне-диапазонная конверсия тоже некрасива: в наших Win64-тестах Round(2^63) возвращал Low(Int64) — тихая смена знака, замаскирован exInvalidOp или нет. Win32 при маске возвращает то же значение, а без маски поднимает EInvalidOp
| Выражение | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), исключения замаскированы (дефолт Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow без маски | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp без маски | EInvalidOp | Low(Int64) |
HotPDF встретил это в JSON-читателе за значениями заданий документов. JSON не ставит числам пределов диапазона, а старый сериализатор превращал любое значение с Frac(Value) = 0 в целое через Round, так что совершенно легальный 1e19 становился либо неверным целым, либо исключением — в зависимости от маски. С v2.770.169 целое число записывается как integer, только когда влезает в Int64, всё прочее хранит свой float-текст, а целочисленные геттеры возвращают дефолт вызывающего для вне-диапазонных значений вместо завёрнутого
const
TwoPow63 = 9223372036854775808.0; // 2^63, точно в Double и Extended
function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
R := 0;
Result := not IsNan(Value) and not IsInfinite(Value) and
(Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
if Result then
R := Trunc(Value);
end;
function JsonNumberText(const Value: Double): string;
var
R: Int64;
begin
// Вызывающие сперва отвергают NaN и бесконечности: у JSON нет их написания
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral останавливается на 15 цифрах
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
Верхняя граница — литерал 9223372036854775808.0 со строгим <. Та константа — 2^63, точная и в Double, и в Extended, так что сравнение значит одно и то же на каждой платформе. Нижняя граница может позволить себе >=, потому что -2^63 — ровно Low(Int64). Проверки IsNan и IsInfinite первыми, с короткозамкнутым вычислением, держат NaN и бесконечности подальше от Frac и сравнений, способных поднять EInvalidOp, когда хост снял с неё маску
Сколько цифр float-в-текст реально даёт на Win64?
Меньше, чем просите, — на двух компиляторах из трёх. FloatToStrF(Value, ffGeneral, 17, 0) во Free Pascal 3.3.1 на Win64 останавливается на 15 значащих цифрах, так что 1/3 возвращается как 0.333333333333333, и два разных Double могут сериализоваться в идентичный текст. Str(Value:24, Text) с последующим Trim даёт 17 значащих цифр в научной записи — 3.3333333333333331E-001 для того же значения — и всегда пишет точку как десятичный разделитель независимо от локали. Если HotPDF на FPC — часть вашей матрицы сборки, остальное о платформенных различиях разбирают заметки о поддержке Free Pascal и Lazarus Win64 в HotPDF
Delphi принимает запрос на 17 цифр, но два Delphi-таргета всё ещё расходятся в выводе: FloatToStrF(0.1, ffGeneral, 17, 0) даёт 0.10000000000000001 на Win32 и 0.1 на Win64. Win64 RTL может вдобавок внести ошибку округления в последней цифре и при форматировании, и при разборе, так что больше цифр сужает зазор, не гарантируя, что каждый битовый паттерн Double переживёт текстовый круг. Документация HotPDF таких обещаний не даёт, и вашей не стоит тоже, если только вы не поставляете собственный корректно округляющий форматтер и парсер. Передавайте TFormatSettings.Invariant или сами меняйте разделитель на старых версиях Delphi, чтобы немецкая или французская локаль не вписала запятую в JSON
Почему Assert.AreEqual перестаёт компилироваться на Win64?
Assert.AreEqual(3, Length(Arr)) на динамическом массиве компилируется для Win32 и валится для Win64 с E2532 «Couldn't infer generic type argument from different argument types», потому что Length динамического массива возвращает NativeInt на Win64. С литералом Integer с одной стороны и 64-битным NativeInt с другой дженерик Assert.AreEqual<T> в DUnitX не может остановиться на одном T, и сборка останавливается
TList.Count ловит ту же ошибку с Delphi 12, где свойство стало NativeInt; Delphi 11 всё ещё декларирует его как Integer. Length у string возвращает Integer на обеих платформах и не затронут — вот почему ошибка всплывает в одних тестовых юнитах и не всплывает в других. Пишите аргумент типа явно, Assert.AreEqual<NativeInt>(3, Length(Arr)), и компилируйте тестовый проект dcc64 до коммита. Набор, собирающийся только под Win32, не признается, что его Win64-сборка сломана, пока кто-то чужой не попробует
Чек-лист портирования на Win64 для числового кода Delphi
- Ищите вызовы
Power(иIntPower(с целыми аргументами; передавайте значения с типомDoubleили стройте ограниченные степени десятки сами - Прогоняйте числовые тесты хотя бы раз со снятыми через
SetExceptionMaskexOverflowиexInvalidOp, на обоих Win32 и Win64 - Пишите верхнюю границу
Int64как< 9223372036854775808.0, никогда<= High(Int64), и отвергайте NaN и бесконечности до любого сравнения - Не конвертируйте разобранное число в
Int64лишь потому, чтоFracнулевой; JSON-числа могут быть куда крупнее - Переписывайте
while-циклы, перечитывающиеCountпри удалении элементов, какfor ... downto-циклы с фиксированными границами - На FPC Win64 используйте
Str(Value:24, Text), когда нужно больше 15 значащих цифр - Используйте
Assert.AreEqual<NativeInt>для ассертов надLengthиCountи компилируйте тесты dcc64 до коммита - После любой правки парсера или рендерера гоняйте полный регрессионный набор на Win32 и Win64, а не на одном из них
Библиотечные правки, описанные здесь, все в HotPDF начиная с v2.770.169, так что SVG-импорт, конверсия XPS, рендеринг прозрачности и обработка JSON-заданий теперь ведут себя на Win64 так же, как на Win32. Если вы генерируете или обрабатываете PDF-файлы из Delphi или C++Builder под обе платформы, на странице HotPDF Delphi PDF component есть загрузки и полный список возможностей