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

Перенос ассемблера _ftol из 32-битного Delphi в DCC64

Оригинальная идиома _ftol в 32-битном Delphi существует из-за C-объектных файлов. Когда модуль линкует .obj-файлы, созданные 32-битным C-компилятором, эти объекты вызывают рантайм-помощник _ftol для каждого приведения float к целому, при этом значение остаётся на вершине стека x87 в ST(0), а не передаётся как параметр. Поэтому Delphi-замена объявляет фиктивный параметр Double, выталкивает ST(0) в него двухстрочным блоком встроенного ассемблера, а затем усекает его уже средствами Pascal. Под DCC32 это долго собиралось без проблем — именно поэтому фрагмент расползся по множеству старых графических и PDF-модулей без единого вопроса со стороны разработчиков

Переключите целевую платформу на 64-битную, и компилятор завершит работу с ошибкой E1025 Unsupported language feature: 'ASM'. Эта ошибка не является предупреждением о совместимости. Она означает, что DCC64 вообще не будет компилировать эту процедуру, независимо от того, насколько хорошо ассемблерный код работал раньше

32-битный оригинал обычно выглядел примерно так:

function _ftol(f: Double): Integer; cdecl;
begin
  asm
    lea   eax, f
    fstp  qword ptr [eax]
  end;
  Result := Trunc(f);
end;

Этот блок asm внутри тела begin...end на Pascal — именно то, что отклоняет DCC64. У двух компиляторов разные правила относительно того, где разрешен ассемблер, и эти границы имеют значение

Почему DCC64 проводит границу иначе

DCC32 разрешает встроенный ассемблер внутри обычных процедур Pascal. Компилятор знает 32-битное соглашение о вызове и может определить, где находятся локальные переменные и параметры, поэтому он допускает фрагменты ассемблера, которые обращаются к кадру стека по имени. DCC64 занимает более строгую позицию: ассемблер должен находиться в выделенной функции ассемблера, где все тело состоит из ассемблера, а соглашение о вызове обрабатывается явно. Смешанный код Pascal-плюс-asm не поддерживается вообще

Основная причина носит архитектурный характер. В 64-битном соглашении о вызове Windows (Microsoft ABI) первые четыре параметра передаются в RCX, RDX, R8 и R9 для целочисленных типов, или в XMM0 — XMM3 для чисел с плавающей запятой. При обычной передаче параметров x87 FPU не используется; технически x87 доступен, но ABI не использует его для транспортировки аргументов. Ассемблер, предполагающий, что значение находится «в стеке FPU», опирается на состояние, которое 64-битный ABI никогда не создает

Таким образом, старый фрагмент имеет проблему не только с синтаксисом. Даже если бы DCC64 принял его, предположения о регистрах были бы неверными

Диаграмма Delphi: DCC32, компилирующий inline-ассемблер внутри процедур Pascal, против DCC64, который прерывается с E1025 и допускает только чистые ассемблерные функции по ABI Win64
DCC32 терпит asm-блок внутри паскалевской подпрограммы, а DCC64 отвечает E1025, поскольку ABI Win64 передаёт аргументы в регистрах, а не в стеке x87

Написание правильной версии на 64-битном ассемблере

Сначала проверьте, нужен ли 64-битный _ftol вообще. C-компиляторы Win64 — и MSVC, и Clang-цепочка C++Builder — конвертируют float в целое встроенными SSE2-инструкциями и не генерируют вызовов _ftol, поэтому 64-битные объектные файлы редко ссылаются на этот символ. Когда вам действительно нужно экспортировать символ _ftol с соглашением cdecl ради бинарной совместимости, функцию следует написать как чистую ассемблерную процедуру. В 64-битном ABI параметр Double приходит в XMM0, а результат Integer возвращается в EAX, младшей половине RAX. Директива .NOFRAME сообщает DCC64, что процедура сама управляет своим стеком, что подходит для столь короткой листовой функции:

function _ftol: Integer; cdecl;
// Значение Double ожидается в XMM0 по 64-битному ABI
asm
  .NOFRAME
  cvttsd2si  eax, xmm0   // усечение до 32-битного целого, результат в eax
end;

CVTTSD2SI — это инструкция SSE2 для преобразования числа с плавающей запятой двойной точности в целое число со знаком с усечением к нулю, что именно и должна делать функция _ftol. Это одна инструкция, она берет параметр прямо оттуда, где его оставил ABI, и помещает результат туда, где его ожидает ABI. Жонглирование управляющим словом FPU не требуется

32-битный приёмник имеет значение. Если вход выходит за диапазон 32-битного целого со знаком, CVTTSD2SI с приёмником EAX возвращает значение integer indefinite ($80000000). Запись в RAX вместо этого дала бы 64-битную конвертацию, и входы между 231 и 263 возвращались бы теми самыми младшими 32 битами, которые оказались в них. Это то же поведение, что у x87 fistp на входе вне диапазона. Прежде чем объявлять миграцию завершённой, стоит убедиться, могут ли ваши вызывающие стороны порождать такие значения

Delphi: диаграмма пути данных — параметр Double приходит в XMM0 и одной инструкцией CVTTSD2SI преобразуется в усечённое целое, возвращаемое в EAX, а вне-диапазонные значения дают паттерн integer indefinite
Под DCC64 double прибывает в XMM0, и CVTTSD2SI кладёт усечённый 32-битный результат в EAX одной SSE2-инструкцией

Когда Trunc является лучшим решением

Приведенную выше версию на ассемблере стоит писать только тогда, когда у вас есть фактическое требование двоичной совместимости: какой-то внешний вызывающий объект ожидает символ _ftol с определенным соглашением о вызове, и вы не можете изменить эти вызывающие объекты. Такая ситуация встречается редко. В большинстве случаев _ftol был внутренним помощником, используемым только внутри того же модуля, и вообще не было никакой внешней зависимости от его имени или соглашения

В этом случае замените его обычным Pascal:

function _ftol(f: Double): Integer; cdecl;
begin
  Result := Trunc(f);
end;

Trunc усекает к нулю, что соответствует тому, что делал _ftol, когда управляющее слово x87 было установлено в режим усечения. Он компилируется в DCC32 и DCC64 без изменений. Компилятор генерирует соответствующую инструкцию для каждой целевой платформы: на x64 он обычно все равно будет генерировать CVTTSD2SI, ту же инструкцию, что и в версии, написанной вручную. Вы получаете идентичное поведение, никаких условий для платформ и никакого ассемблера для поддержки

Одно семантическое отличие, которое стоит проверить: Trunc вызывает исключение EInvalidOp в конфигурации Delphi по умолчанию, когда входные данные являются NaN или бесконечностью. fistp x87 в оригинальном коде просто записывал битовый шаблон без вызова каких-либо исключений. Если ваш код передает в эту функцию необычные значения с плавающей запятой, а старое поведение было скрытым, используйте проверку IsNaN и IsInfinite из Math перед вызовом Trunc

Диаграмма решений Delphi по переносу _ftol: заменить обычным Trunc, когда ни один внешний вызывающий не нуждается в символе cdecl, иначе оставить чистый ассемблерный экспорт, и защитить входы NaN или бесконечность от EInvalidOp
Переходите на обычный Trunc, если внешние вызывающие не зависят от экспортированного символа, и в любом случае защищайте входы NaN или бесконечность от EInvalidOp

Условная компиляция, когда обе платформы остаются активными

Некоторые проекты должны продолжать поставлять как 32-битные, так и 64-битные бинарные файлы. Если исходную версию ассемблера необходимо сохранить для 32-битной версии и предоставить новую реализацию для 64-битной, используйте условие CPUX64:

function _ftol(f: Double): Integer; cdecl;
begin
{$IFDEF CPUX64}
  Result := Trunc(f);
{$ELSE}
  // путь 32-bit: DCC32 принимает inline asm
  asm
    lea   eax, f
    fstp  qword ptr [eax]
  end;
  Result := Trunc(f);
{$ENDIF}
end;

Это минимальное механическое исправление, и к нему стоит относиться как к временному. База кода, которая содержит специфичный для архитектуры ассемблер во вспомогательной функции, единственной целью которой является усечение числа с плавающей запятой до целого числа, несет ненужный технический долг. 32-битная ветка может полностью исчезнуть, как только вы подтвердите, что ничто не зависит от побочных эффектов FPU старой реализации

Если функция появляется в компоненте, используемом в нескольких модулях, найдите во всей базе кода символ _ftol, прежде чем решать, как выполнять миграцию. Символ с таким именем может быть объявлен более чем в одном месте; компоновщик выбирает один и молча игнорирует остальные, а это означает, что вы можете исправить одну копию и все равно скомпоновать другую, которая не была тронута