Техническая статья

Баги Delphi под Win64, найденные при упрочнении HotPDF

Код 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, загруженные в такие хосты, получают то поведение, которое выбрал хост, — потому библиотека не может полагаться ни на один исход

Числовая ловушка Win64 в HotPDF, где System.Math Power с целыми аргументами биндится к перегрузке Single: Power от 10 в 20-й возвращает 1.0000000200408773E20 вместо 1E20, а Power от 10 в 100-й даёт плюс бесконечность при замаскированных исключениях или EOverflow при незамаскированных
потеря точности — улика: если степень десятки возвращается с Single-шумом, выиграла не та перегрузка — стройте масштаб сами
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 заменила цикл

Ловушка кодогенерации Win64 в чистке рендерера HotPDF: while-цикл, перечитывающий TList.Count, делил один стековый слот между условием и телом, dcc64 не освежал его после Delete, пустые прозрачностные группы освобождали элемент -1 и поднимали EListError, а правка — цикл for downto, чьи границы вычисляются однажды
практический урок дешевле первопричины: downto-циклы с фиксированными границами не могут протухнуть, а работа над рендерером не завершена, пока dcc64 не прогнал набор тестов

Мы не свели это к минимальной репродукции, и маленький автономный цикл вроде 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

Ловушка границы Int64 в HotPDF: Double не может представить High(Int64), поэтому Win64-сравнение округляет предел вверх до 2^63, D равное 2^63 проходит проверку, а Round молча возвращает Low(Int64), тогда как Win32 сравнивает в 80-битном Extended, где граница точна и то же сравнение даёт False
одна конверсия — весь баг: граница округляется ровно на исключаемое вами значение, так что пишите потолок литералом со строгим меньше
ВыражениеWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), исключения замаскированы (дефолт Delphi 12+)1E100+Inf
Power(10, 100), exOverflow без маски1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp без маскиEInvalidOpLow(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 или стройте ограниченные степени десятки сами
  • Прогоняйте числовые тесты хотя бы раз со снятыми через SetExceptionMask exOverflow и 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 есть загрузки и полный список возможностей