Articolo tecnico

Porting della _ftol da Delphi 32 bit a DCC64

Il modo originale di _ftol nel Delphi a 32 bit esiste per colpa dei file oggetto C. Quando una unità linka file .obj prodotti da un compilatore C a 32 bit, quegli oggetti chiamano l'helper di runtime _ftol per ogni cast da float a intero, con il valore lasciato in cima allo stack x87 in ST(0) anziché passato come parametro. L'assistente Delphi quindi dichiara un parametro fittizio Double, preleva ST(0) dentro di esso con un blocco di assembly inline di due righe, e poi lo tronca in Pascal. Ha compilato senza problemi sotto DCC32 per molto tempo, ed è esattamente per questo che è finito in così tante unit grafiche e PDF vecchie senza che nessuno lo mettesse in discussione

Passando il target di compilazione a 64 bit, il compilatore si arresta con E1025 Unsupported language feature: 'ASM'. Questo errore non è un avviso di compatibilità. Significa che DCC64 non compilerà affatto la routine, a prescindere da quanto bene funzionasse l'assembly in precedenza

L'originale a 32 bit si presentava tipicamente in questo modo:

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

Quel blocco asm all'interno di un corpo Pascal begin...end è esattamente ciò che DCC64 rifiuta. I due compilatori hanno regole diverse su dove sia consentito l'assembly, e questo confine è fondamentale

Perché DCC64 definisce il confine in modo diverso

DCC32 consente l'assembly inline all'interno di normali routine Pascal. Il compilatore conosce la convenzione di chiamata a 32 bit e può determinare la posizione delle variabili locali e dei parametri, tollerando quindi frammenti di assembly che accedono al record di attivazione dello stack per nome. DCC64 assume una posizione più rigida: l'assembly deve trovarsi in una funzione assembler dedicata, in cui l'intero corpo è scritto in assembly e la convenzione di chiamata è gestita esplicitamente. Il codice misto Pascal ed assembly non è affatto supportato

Il motivo di fondo è architetturale. Nella convenzione di chiamata di Windows a 64 bit (Microsoft ABI), i primi quattro parametri arrivano in RCX, RDX, R8 e R9 per i tipi interi, oppure da XMM0 a XMM3 per i valori a virgola mobile. Non c'è alcun coinvolgimento della FPU x87 nel passaggio standard dei parametri; l'x87 è tecnicamente disponibile, ma l'ABI non lo utilizza per il trasporto degli argomenti. L'assembly che presuppone che un valore sia "sullo stack della FPU" fa riferimento a uno stato che l'ABI a 64 bit non crea mai

Quindi il vecchio frammento non ha semplicemente un problema di sintassi. Anche se DCC64 lo accettasse, le assunzioni sui registri sarebbero errate

Diagramma Delphi che confronta DCC32, che compila asm inline dentro routine Pascal, con DCC64, che abortisce con E1025 e ammette solo funzioni assembler pure sotto la ABI Win64
DCC32 tollera un blocco asm dentro una routine Pascal mentre DCC64 risponde con E1025 perché l'ABI Win64 sposta gli argomenti nei registri invece che nello stack x87

Scrivere una versione assembler a 64 bit corretta

Prima di tutto verificate se serve davvero una _ftol a 64 bit. I compilatori C Win64, sia MSVC sia la toolchain C++Builder basata su Clang, convertono i float in interi inline con istruzioni SSE2 e non emettono chiamate a _ftol, quindi i file oggetto a 64 bit referenziano raramente il simbolo. Quando serve davvero esportare un simbolo _ftol con convenzione cdecl per compatibilità binaria, la funzione va scritta come routine in puro assembler. Sotto l'ABI a 64 bit un parametro Double arriva in XMM0, e un risultato Integer torna in EAX, la metà bassa di RAX. La direttiva .NOFRAME dice a DCC64 che la routine gestisce da sé lo stack, appropriato per una funzione foglia così corta:

function _ftol: Integer; cdecl;
// valore Double atteso in XMM0 secondo l'ABI a 64 bit
asm
  .NOFRAME
  cvttsd2si  eax, xmm0   // tronca a un intero a 32 bit, risultato in eax
end;

CVTTSD2SI è l'istruzione SSE2 per convertire un float a doppia precisione in un intero con segno con troncamento verso lo zero, che è esattamente ciò che dovrebbe fare _ftol. È una sola istruzione, prende il parametro direttamente da dove l'ha lasciato l'ABI e posiziona il risultato dove l'ABI se lo aspetta. Non è necessaria alcuna manipolazione della control word della FPU

La destinazione a 32 bit conta. Se l'input supera l'intervallo di un intero con segno a 32 bit, CVTTSD2SI con EAX come destinazione restituisce il valore integer indefinite ($80000000). Scrivere RAX invece produrrebbe una conversione a 64 bit, e input tra 231 e 263 tornerebbero con i loro bassi 32 bit quali sono. È lo stesso comportamento di fistp x87 su input fuori intervallo. Vale la pena verificare che i vostri chiamanti non possano produrre valori del genere prima di dichiarare conclusa la migrazione

Delphi: diagramma del percorso dei dati con un parametro Double che arriva in XMM0 e viene convertito da una sola istruzione CVTTSD2SI in un intero troncato restituito in EAX, con i valori fuori intervallo che producono il pattern integer indefinite
Sotto DCC64 il double arriva in XMM0 e CVTTSD2SI depone il risultato troncato a 32 bit in EAX come unica istruzione SSE2

Quando Trunc rappresenta la soluzione migliore

La versione assembler descritta sopra vale la pena di essere scritta solo quando si ha un reale requisito di compatibilità binaria: ad esempio, quando un chiamante esterno si aspetta il simbolo _ftol con una specifica convenzione di chiamata e non è possibile modificare tali chiamanti. Questa situazione è insolita. La maggior parte delle volte, _ftol era un helper privato utilizzato solo all'interno della stessa unità, senza alcuna dipendenza esterna dal suo nome o dalla sua convenzione

In questo caso, è sufficiente sostituirlo con del semplice codice Pascal:

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

Trunc tronca verso lo zero, in linea con quanto faceva _ftol con la control word dell'x87 impostata in modalità di troncamento. Compila sia su DCC32 sia su DCC64 senza modifiche. Il compilatore genera l'istruzione appropriata per ciascun target: su x64 emetterà solitamente CVTTSD2SI, la stessa istruzione della versione scritta a mano. Si ottiene così lo stesso comportamento, senza costrutti condizionali di piattaforma e senza assembly da mantenere

L'unica differenza semantica che vale la pena verificare è che Trunc solleva un'eccezione EInvalidOp nella configurazione predefinita di Delphi quando l'input è un NaN o un infinito. L'istruzione fistp dell'x87 nel codice originale scriveva semplicemente un pattern di bit senza sollevare eccezioni. Se il codice passa valori a virgola mobile insoliti a questa funzione e il vecchio comportamento era silenzioso, è opportuno inserire una protezione con IsNaN e IsInfinite del modulo Math prima di chiamare Trunc

Diagramma decisionale Delphi per portare _ftol: sostituirla con il semplice Trunc quando nessun chiamante esterno ha bisogno del simbolo cdecl, altrimenti mantenere un export assembler puro, e proteggere gli input NaN o infinito contro EInvalidOp
Migrare al Trunc semplice a meno che chiamanti esterni dipendano dal simbolo esportato, e in ogni caso proteggere gli input NaN o infinito contro EInvalidOp

Compilazione condizionale quando entrambi i target rimangono attivi

Alcuni progetti devono continuare a distribuire binari sia a 32 sia a 64 bit. Se la versione assembler originale deve essere conservata per i 32 bit e deve essere fornita una nuova implementazione per i 64 bit, si può utilizzare la direttiva condizionale CPUX64:

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

Questa rappresenta la correzione meccanica minima ed è opportuno considerarla temporanea. Una base di codice che contiene assembly specifico per l'architettura in un helper il cui unico scopo è il troncamento da virgola mobile a intero accumula debito tecnico non necessario. Il ramo a 32 bit può essere rimosso del tutto una volta confermato che nulla dipende dagli effetti collaterali sulla FPU della vecchia implementazione

Se la funzione compare in un componente utilizzato in più unità, è consigliabile cercare _ftol nell'intera base di codice prima di decidere come procedere con la migrazione. Un simbolo con quel nome può essere dichiarato in più punti; il linker ne sceglie uno ignorando silenziosamente gli altri, il che significa che si potrebbe correggere una copia ma continuare a effettuare il collegamento a una copia diversa che non è stata modificata