Article technique

Assembleur en ligne _ftol : du Delphi 32 bits à DCC64

L'idiome _ftol historique du Delphi 32 bits existe à cause des fichiers objet C. Quand une unité lie des fichiers .obj produits par un compilateur C 32 bits, ces objets appellent le helper d'exécution _ftol à chaque conversion de float vers entier, la valeur restant au sommet de la pile x87 dans ST(0) plutôt que passée en paramètre. L'équivalent Delphi déclare donc un paramètre Double factice, dépile ST(0) dans ce paramètre avec un bloc d'assembleur en ligne de deux lignes, puis le tronque en Pascal. Il a compilé sans broncher sous DCC32 pendant longtemps, ce qui explique pourquoi il a fini dans tant d'unités graphiques et PDF anciennes sans que personne ne le remette en question

En basculant la cible de build en 64 bits, le compilateur s'arrête avec l'erreur E1025 Unsupported language feature: 'ASM'. Cette erreur n'est pas un avertissement de compatibilité. Elle signifie que DCC64 ne compilera pas du tout cette routine, quelle que soit la qualité avec laquelle l'assembleur fonctionnait auparavant

La version originale 32 bits ressemble généralement à ceci :

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

C'est précisément ce bloc asm à l'intérieur d'un corps Pascal begin...end que DCC64 refuse. Les deux compilateurs ont des règles différentes quant à l'endroit où l'assembleur est autorisé, et cette frontière a son importance

Pourquoi DCC64 trace-t-il des lignes de démarcation différentes

DCC32 permet l'assembleur en ligne dans les routines Pascal ordinaires. Le compilateur comprend les conventions d'appel 32 bits et peut déduire l'emplacement des variables locales et des paramètres, tolérant ainsi les fragments d'assembleur accédant aux cadres de pile par leur nom. DCC64 adopte une position plus stricte : l'assembleur doit se trouver dans des fonctions assembleur dédiées, c'est-à-dire des fonctions dont l'intégralité du corps est en assembleur et où les conventions d'appel sont gérées explicitement. Les mélanges de Pascal et d'assembleur ne sont pas du tout pris en charge

La cause fondamentale est d'ordre architectural. Dans la convention d'appel Windows 64 bits (ABI Microsoft), les quatre premiers paramètres de type entier sont transmis via RCX, RDX, R8 et R9, et les types à virgule flottante via XMM0 à XMM3. Le FPU x87 n'intervient pas dans le passage normal des arguments ; il reste techniquement disponible, mais l'ABI ne l'utilise pas pour transmettre les paramètres. L'assembleur supposant que la valeur "se trouve sur la pile du FPU" suppose un état que l'ABI 64 bits ne crée jamais

Par conséquent, l'ancien fragment de code ne présente pas seulement un problème de syntaxe. Même si DCC64 l'acceptait, l'hypothèse de registre serait erronée

Diagramme Delphi comparant DCC32, qui compile l'asm inline à l'intérieur des routines Pascal, avec DCC64, qui abandonne avec E1025 et n'autorise que les fonctions en assembleur pur sous l'ABI Win64
DCC32 tolère un bloc asm dans une routine Pascal tandis que DCC64 répond E1025 car l'ABI Win64 passe les arguments dans les registres plutôt que sur la pile x87

Écrire une vraie version assembleur 64 bits

Vérifiez d'abord qu'un _ftol 64 bits est vraiment nécessaire. Les compilateurs C Win64, MSVC comme la chaîne d'outils C++Builder basée sur Clang, convertissent les floats en entiers en ligne avec des instructions SSE2 et n'émettent aucun appel à _ftol, si bien que les fichiers objet 64 bits référencent rarement le symbole. Quand vous devez réellement exporter un symbole _ftol avec la convention cdecl pour des raisons de compatibilité binaire, la fonction doit s'écrire comme une routine en assembleur pur. Sous l'ABI 64 bits, un paramètre Double arrive dans XMM0, et un résultat Integer revient dans EAX, la moitié basse de RAX. La directive .NOFRAME indique à DCC64 que la routine gère elle-même sa pile, ce qui convient à une fonction feuille aussi courte :

function _ftol: Integer; cdecl;
// Valeur Double attendue dans XMM0 selon l'ABI 64 bits
asm
  .NOFRAME
  cvttsd2si  eax, xmm0   // tronque vers un entier 32 bits, résultat dans eax
end;

CVTTSD2SI est une instruction SSE2 qui tronque un nombre à virgule flottante double précision en un entier signé vers zéro, ce qui est précisément ce que _ftol est censé faire. C'est une seule instruction qui récupère l'argument directement là où l'ABI le place et dépose le résultat là où l'ABI l'attend. Aucune manipulation du mot de contrôle du FPU n'est requise

La destination 32 bits compte. Si l'entrée dépasse la plage d'un entier signé 32 bits, CVTTSD2SI avec EAX comme destination renvoie la valeur entière indéfinie ($80000000). Écrire RAX à la place produirait une conversion 64 bits, et des entrées entre 231 et 263 reviendraient avec leurs 32 bits de poids faible, quoi qu'ils valent. C'est le même comportement que le fistp x87 sur une entrée hors plage. Si vos appelants peuvent produire de telles valeurs, cela mérite d'être confirmé avant de déclarer la migration terminée

Delphi : diagramme de chemin de données montrant un paramètre Double arrivant dans XMM0 et converti par une seule instruction CVTTSD2SI en entier tronqué retourné dans EAX, les valeurs hors plage produisant le motif d'entier indéfini
Sous DCC64, le double arrive dans XMM0 et CVTTSD2SI place le résultat 32 bits tronqué dans EAX en une seule instruction SSE2

Quand Trunc est la meilleure solution

La version assembleur ci-dessus ne vaut la peine d'être écrite que si vous avez un véritable besoin de compatibilité binaire : un appelant externe attend le symbole _ftol avec une convention d'appel précise, et vous ne pouvez pas modifier ces appelants. Cette situation est rare. Le plus souvent, _ftol était une fonction d'aide privée utilisée uniquement au sein de la même unité, et aucune dépendance externe ne porte sur son nom ou sa convention

Dans ce cas, remplacez-la par du Pascal simple :

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

Trunc tronque vers zéro, ce qui correspond à ce que faisait _ftol avec le mot de contrôle x87 réglé en mode troncature. Elle compile sous DCC32 et DCC64 sans modification. Le compilateur génère l'instruction appropriée pour chaque cible : sur x64, il émettra de toute façon en général CVTTSD2SI, la même instruction que la version écrite à la main. Vous obtenez un comportement identique, aucun conditionnel de plateforme, et pas d'assembleur à maintenir

La seule différence sémantique qui mérite une vérification : Trunc lève une exception EInvalidOp dans la configuration par défaut de Delphi quand l'entrée est un NaN ou un infini. Le fistp x87 du code d'origine se contentait d'écrire un motif de bits sans rien lever. Si votre code alimente cette fonction avec des valeurs flottantes inhabituelles et que l'ancien comportement était silencieux, protégez l'appel avec IsNaN et IsInfinite de Math avant d'appeler Trunc

Diagramme de décision Delphi pour porter _ftol : le remplacer par un simple Trunc quand aucun appelant externe n'a besoin du symbole cdecl, sinon conserver un export en assembleur pur, et prémunir les entrées NaN ou infini contre EInvalidOp
Migrez vers le Trunc simple sauf si des appelants externes dépendent du symbole exporté, et protégez les entrées NaN ou infini contre EInvalidOp dans les deux cas

Compilation conditionnelle quand les deux cibles restent actives

Certains projets doivent continuer à fournir des binaires 32 bits et 64 bits simultanément. Si la version assembleur d'origine doit être conservée pour le 32 bits tout en fournissant une nouvelle implémentation pour le 64 bits, utilisez la condition CPUX64 :

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

Il s'agit du correctif mécanique minimal, qu'il convient de considérer comme temporaire. Une base de code transportant de l'assembleur spécifique à une architecture pour une fonction d'aide dont l'unique but est la troncature d'un flottant en entier constitue une dette technique superflue. Dès qu'il est confirmé que rien ne dépend des effets secondaires du FPU de l'ancienne implémentation, la branche 32 bits peut être entièrement supprimée

Si cette fonction apparaît dans un composant utilisé dans plusieurs unités, recherchez _ftol dans l'ensemble de la base de code avant de décider de la migration. Le même symbole peut être déclaré à plusieurs endroits ; l'éditeur de liens en choisit un et ignore silencieusement les autres, ce qui signifie que vous pouvez en corriger une copie tout en restant lié à une autre qui n'a pas été modifiée