Технічна стаття

Портування вбудованого асемблера _ftol з 32-бітної Delphi до DCC64

Оригінальна ідіома _ftol у 32-бітній Delphi виглядає як розумний однорядковий код: обгортка функції Pascal, яка використовує вбудований асемблер для маніпуляцій з керуючим словом x87 FPU, усікання значення на стеку FPU та вилучення результату. Вона тривалий час успішно компілювалася під 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 всередині тіла Pascal begin...end - це саме те, що відхиляє DCC64. Ці два компілятори мають різні правила щодо того, де дозволено використовувати асемблер, і ця межа має значення

Чому DCC64 встановлює інші межі

DCC32 дозволяє використовувати вбудований асемблер всередині звичайних процедур Pascal. Компілятор знає 32-бітну угоду про виклик (calling convention) і може визначити, де знаходяться локальні змінні та параметри, тому він допускає фрагменти асемблера, які звертаються до кадру стека за іменем. DCC64 займає суворішу позицію: асемблерний код має бути у виділеній асемблерній функції, де все тіло є асемблером, а угода про виклик обробляється явно. Змішаний код Pascal-plus-asm не підтримується взагалі

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

Отже, старий фрагмент має не лише синтаксичну проблему. Навіть якби DCC64 прийняв його, припущення щодо регістрів були б хибними

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

Якщо вам дійсно потрібно експортувати символ _ftol з угодою cdecl для бінарної сумісності, функцію необхідно написати як чисту асемблерну процедуру. Відповідно до 64-бітного ABI, параметр Double надходить у XMM0, а цілочисельний результат при поверненні має бути в RAX. Директива .NOFRAME вказує DCC64, що процедура самостійно керує своїм стеком, що є цілком доречним для такої короткої листової функції (leaf function):

function _ftol: Integer; cdecl;
// Double value expected in XMM0 per 64-bit ABI
asm
  .NOFRAME
  cvttsd2si  rax, xmm0   // truncate-to-integer, result in rax
end;

CVTTSD2SI - це інструкція SSE2 для перетворення числа з плаваючою комою подвійної точності на знакове ціле число з усіканням до нуля, що є саме тим, що має робити _ftol. Це одна інструкція, яка бере параметр безпосередньо звідти, де його залишив ABI, і поміщає результат туди, де його очікує ABI. Жодних маніпуляцій з керуючим словом FPU не потрібно

Зверніть увагу, що якщо вхідне значення перевищує діапазон 32-бітного знакового цілого числа, CVTTSD2SI повертає невизначене цілочисельне значення ($80000000). Це така ж поведінка, як у x87 fistp при виході за межі діапазону. Перш ніж вважати міграцію завершеною, варто переконатися, чи можуть ваші виклики генерувати такі значення

Коли 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 або нескінченність. Інструкція x87 fistp в оригінальному коді просто записувала бітовий шаблон без генерації жодних винятків. Якщо ваш код передає незвичайні значення з плаваючою комою у цю функцію, а стара поведінка була безшумною, перед викликом Trunc додайте перевірку за допомогою IsNaN та IsInfinite з модуля Math

Умовна компіляція, коли обидві цільові платформи залишаються активними

Деякі проєкти повинні продовжувати постачати як 32-бітні, так і 64-бітні бінарні файли. Якщо оригінальну асемблерну версію необхідно зберегти для 32-бітної архітектури, а для 64-бітної надати нову реалізацію, використовуйте директиву умовної компіляції CPUX64:

function _ftol(f: Double): Integer; cdecl;
begin
{$IFDEF CPUX64}
  Result := Trunc(f);
{$ELSE}
  // 32-bit path: DCC32 accepts inline asm
  asm
    lea   eax, f
    fstp  qword ptr [eax]
  end;
  Result := Trunc(f);
{$ENDIF}
end;

Це мінімальне механічне виправлення, і його варто розглядати як тимчасове. Кодова база, яка містить архітектурно-залежний асемблер у допоміжній функції, єдиною метою якої є усікання числа з плаваючою комою до цілого, несе на собі непотрібний технічний борг. Від 32-бітної гілки можна повністю відмовитися, як тільки ви переконаєтеся, що ніщо не залежить від побічних ефектів FPU старої реалізації

Якщо функція з'являється у компоненті, який використовується в кількох модулях, перед прийняттям рішення щодо міграції виконайте пошук _ftol по всій кодовій базі. Символ з таким ім'ям може бути оголошений у більш ніж одному місці; компонувальник вибирає один і мовчки ігнорує інші, а це означає, що ви можете виправити одну копію і все одно скомпонувати код з іншою, яка не була змінена