Aller au contenu principal

Un autre alphabet

Ce que ce chapitre apporte

  • Lire une instruction x86-64 à deux opérandes et dire quel registre elle détruit.
  • Passer d'une syntaxe Intel à une syntaxe AT&T sans se tromper sur le sens d'une affectation.
  • Expliquer le rôle du registre de drapeaux et retrouver, dans une liste d'instructions, celle qui a posé les drapeaux qu'un saut vient de lire.
  • Nommer les quatre tailles emboîtées d'un même registre x86-64 et dire laquelle une instruction manipule.
  • Retrouver dans un désassemblage x86-64 les mécanismes vus en RISC-V : prologue de fonction, appel, retour, boucle.

RISC-V a servi de langue d'apprentissage parce qu'il est régulier : trois opérandes, la destination toujours en premier, une instruction qui fait une chose. La plupart des machines de bureau et des serveurs, elles, exécutent du x86-64, et un désassembleur ouvert sur un exécutable affiche donc autre chose. Rien de ce qui a été appris ne devient faux : les registres restent des registres, la pile descend toujours, un saut arrière fait toujours une boucle. Mais l'orthographe change, et trois particularités suffisent à donner l'impression de ne rien comprendre. Ce chapitre ne demande pas d'apprendre à écrire du x86-64. Il apprend à le reconnaître, c'est-à-dire à savoir ce qu'une ligne fait sans avoir à la deviner.

Trois opérandes contre deux

En RISC-V, add t0, t1, t2 nomme trois registres : une destination et deux sources. Les deux sources sortent intactes de l'opération, et c'est si naturel qu'on ne le remarque pas.

En x86-64, les instructions arithmétiques n'ont que deux opérandes, et le premier est à la fois une source et la destination.

; x86-64, syntaxe Intel
add  eax, ecx      ; eax reçoit eax + ecx : l'ancienne valeur de eax est perdue
sub  edx, eax      ; edx reçoit edx - eax
mov  eax, ecx      ; eax reçoit une copie de ecx

La destination est à gauche, comme le membre de gauche d'une affectation, et c'est la seule bonne façon de lire ces lignes : add eax, ecx se lit « eax devient eax plus ecx ». La conséquence est immédiate et se paie en instructions : conserver la valeur d'un opérande oblige à la copier d'abord.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0add t0, a0, a1
  2. 0x4mv t1, a0addi t1, a0, 0
  3. 0x8add t1, t1, a1

registres

t00
t10
a07
a13

Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.

Exécuter les trois instructions. La première fait en une fois ce que les deux suivantes font en deux, et a0 ressort intact dans les deux cas parce qu'une copie a été faite exprès. Comparer les colonnes t0 et t1 à l'arrivée.

La première instruction est la forme RISC-V. Les deux suivantes imitent la forme x86 : une copie, puis une addition qui écrase sa propre destination. Le résultat est le même, le nombre d'instructions non.

Ce coût se voit dans du code réellement produit. Voici une fonction qui additionne trois entiers, compilée sans optimisation par GCC 16.2.1 pour x86-64, puis désassemblée en syntaxe Intel.

mov    edx,DWORD PTR [rbp-0x4]
mov    eax,DWORD PTR [rbp-0x8]
add    edx,eax
mov    eax,DWORD PTR [rbp-0xc]
add    eax,edx

Cinq instructions pour deux additions, parce que chaque addition consomme sa destination et qu'il faut sans cesse recharger. La même chose en RISC-V demanderait deux add et trois lw.

La destination à gauche n'est pas une convention universelle

Trois familles d'écriture cohabitent dans la documentation technique : RISC-V écrit la destination en premier, x86-64 en syntaxe Intel aussi, x86-64 en syntaxe AT&T en dernier. Lire une ligne dans la mauvaise convention inverse le sens d'une affectation sans produire la moindre absurdité visible, ce qui en fait une erreur difficile à repérer.

Le même code, deux orthographes

Un désassembleur x86-64 propose deux syntaxes. Elles décrivent les mêmes octets, et inversent l'ordre des opérandes. Voici la même fonction, tirée du même exécutable, affichée des deux façons.

syntaxe Intel                          syntaxe AT&T
push   rbp                             push   %rbp
mov    rbp,rsp                         mov    %rsp,%rbp
mov    DWORD PTR [rbp-0x4],edi         mov    %edi,-0x4(%rbp)
mov    DWORD PTR [rbp-0x8],esi         mov    %esi,-0x8(%rbp)
mov    eax,DWORD PTR [rbp-0x4]         mov    -0x4(%rbp),%eax
cmp    eax,DWORD PTR [rbp-0x8]         cmp    -0x8(%rbp),%eax
jle    40045d                          jle    40045d
mov    eax,DWORD PTR [rbp-0x4]         mov    -0x4(%rbp),%eax
jmp    400460                          jmp    400460
mov    eax,DWORD PTR [rbp-0x8]         mov    -0x8(%rbp),%eax
pop    rbp                             pop    %rbp
ret                                    ret

Trois différences d'écriture, et rien d'autre.

L'ordre : Intel écrit la destination d'abord, AT&T en dernier. mov rbp,rsp et mov %rsp,%rbp font tous deux « rbp reçoit rsp ».

La ponctuation : AT&T préfixe chaque registre d'un pour-cent et chaque constante immédiate d'un dollar. Intel n'écrit rien devant les registres.

L'adressage : DWORD PTR [rbp-0x4] en Intel et -0x4(%rbp) en AT&T désignent la même chose, à savoir la case mémoire située quatre octets sous l'adresse contenue dans rbp. C'est le lw t1, -4(t0) du chapitre 2, avec deux notations pour un même calcul d'adresse.

Une seule habitude évite de se tromper : repérer d'abord la syntaxe, sur un signe pour-cent ou un crochet, avant de lire la première ligne. La suite du chapitre emploie la syntaxe Intel.

Le canal invisible

Voici la particularité qui égare le plus de lecteurs, et elle n'a aucun équivalent dans ce module.

En RISC-V, comparer et sauter est une instruction. blt a0, a1, cible compare les deux registres et saute selon le résultat. Ce qui a été comparé est écrit noir sur blanc dans la ligne qui saute.

En x86-64, ce sont deux instructions, et la seconde ne dit pas ce qu'elle regarde.

cmp    eax, ecx        ; soustrait ecx de eax, jette le résultat, et ne garde que des drapeaux
jle    cible           ; saute si la comparaison précédente disait « inférieur ou égal »

cmp est une soustraction dont le résultat est jeté. Ce qu'elle laisse derrière elle tient dans un registre à part, le registre de drapeaux, qui n'est nommé dans aucune des deux lignes. Quelques bits y résument la soustraction : un bit qui dit que le résultat était nul, un autre qu'il était négatif, un autre qu'il y a eu une retenue, un autre un débordement. jle lit ces bits et en déduit la relation.

Registre de drapeaux

Le registre de drapeaux est un registre que presque toutes les instructions arithmétiques et logiques modifient au passage, sans qu'aucune ne le nomme, et que les instructions de saut conditionnel lisent, sans le nommer davantage. Il est le canal par lequel une comparaison atteint le saut qui la suit.

Ce que cela change pour qui lit un désassemblage est considérable. Devant jle, la question « comparé à quoi ? » n'a pas de réponse dans la ligne : il faut remonter le listing jusqu'à la dernière instruction ayant posé les drapeaux, et vérifier au passage qu'aucune autre ne les a modifiés entre-temps. Or beaucoup d'instructions les modifient, y compris des instructions dont ce n'est pas le propos, comme un add ou un and. Un saut conditionnel peut donc se trouver loin de la comparaison dont il dépend, et rien dans le texte ne relie les deux.

Ce n'est pas un défaut de lecture, ni un manque de familiarité qui se corrigerait avec du temps : c'est un choix d'architecture, celui d'un état implicite partagé entre instructions, et le chapitre 5 a montré l'autre choix, où la comparaison et le saut tiennent dans une seule instruction qui nomme tout ce qu'elle regarde.

La figure suivante montre les deux formes du côté RISC-V. La première coupe la décision en deux, avec un résultat intermédiaire rangé dans un registre bien visible ; la seconde la fait en une instruction.

RISC-V RV32Ipas 0 sur 5pc = 0x0

programme

  1. 0x0slt t0, a0, a1
  2. 0x4bnez t0, plus_petitbne t0, zero, plus_petit
  3. 0x8li t1, 0addi t1, zero, 0
  4. 0xcj finjal zero, fin
  5. 0x10li t1, 1addi t1, zero, 1
  6. 0x14blt a0, a1, aussi
  7. 0x18li t2, 0addi t2, zero, 0
  8. 0x1cj vraiment_finjal zero, vraiment_fin
  9. 0x20li t2, 1addi t2, zero, 1

registres

t00
t10
t20
a05
a19

Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.

Exécuter jusqu'au bout et relever t1 et t2, qui valent la même chose. Regarder t0 après la première instruction : c'est le seul endroit du programme où le résultat de la comparaison est visible, et en x86-64 il ne le serait pas.

slt t0, a0, a1 est l'équivalent le plus proche d'un cmp : il range le résultat d'une comparaison quelque part. La différence tient en un mot : ce quelque part a un nom, t0, et se lit dans la ligne suivante. Les drapeaux de x86-64 n'en ont pas.

Deux écritures très courantes découlent de ce mécanisme et méritent d'être reconnues. test edi, edi fait un ET entre un registre et lui-même, ce qui ne change rien, mais pose les drapeaux : c'est la façon usuelle de demander « ce registre est-il nul ». xor eax, eax fait un OU exclusif d'un registre avec lui-même, ce qui donne toujours zéro : c'est la façon usuelle de remettre un registre à zéro, plus courte à encoder que mov eax, 0.

Quatre tailles, un seul registre

RISC-V a trente-deux registres de trente-deux bits, et un registre se désigne d'un seul nom. x86-64 a seize registres généraux de soixante-quatre bits, et chacun porte quatre noms selon la partie qu'on veut atteindre.

NomBits atteintsExemple d'usage
raxles 64 bitsune adresse, un entier long
eaxles 32 bits de poids faibleun entier ordinaire
axles 16 bits de poids faibleun entier court
alles 8 bits de poids faibleun octet, un caractère

Les quatre désignent le même registre physique. Écrire dans al change les huit bits de poids faible de rax et laisse le reste, écrire dans eax change les trente-deux bits de poids faible et met les trente-deux autres à zéro. Cette asymétrie est une source d'erreurs réputée, et elle n'a aucun équivalent en RISC-V, où lire ou écrire une partie d'un registre passe par un décalage ou un masque explicites, comme au chapitre 4.

Le phénomène se voit dans du code réel. Voici une fonction qui teste si un caractère est une espace, compilée avec optimisation, en syntaxe Intel.

xor    eax,eax         ; met eax à zéro, donc rax tout entier
cmp    dil,0x20        ; compare l'octet de poids faible de rdi au code 32
sete   al              ; met l'octet de poids faible de rax à 1 si la comparaison disait « égal », à 0 sinon
ret

Quatre lignes, trois tailles différentes du même mécanisme : dil est l'octet de poids faible du registre qui portait le premier argument, al celui du registre de résultat, et eax sert à mettre le résultat entier à zéro avant de n'en écrire qu'un octet. sete est une instruction de la même famille que jle : elle lit les drapeaux, mais au lieu de sauter, elle range zéro ou un.

La pile, et le retour rangé ailleurs

Le chapitre 8 a montré une pile qui descend et un registre qui en désigne le sommet. x86-64 a exactement cela, le registre s'appelle rsp, et la pile descend aussi. La différence est que la mécanique est câblée dans quatre instructions au lieu d'être écrite à la main.

x86-64ce que cela faitl'équivalent RISC-V
push rbpdescend rsp de huit, écrit rbp au sommetaddi sp, sp, -4 puis sw s0, 0(sp)
pop rbplit le sommet dans rbp, remonte rsp de huitlw s0, 0(sp) puis addi sp, sp, 4
call fempile l'adresse de retour, puis saute vers fjal ra, f, qui range l'adresse dans ra
retdépile une adresse et saute dessusjalr zero, ra, 0, qui saute à l'adresse de ra

La ligne qui compte est la troisième. Le chapitre 9 a insisté sur un point : jal range l'adresse de retour dans un registre, ra, et c'est à la fonction appelée de la recopier sur la pile si elle appelle à son tour. En x86-64, call l'écrit directement sur la pile, sans passer par un registre.

Le résultat observable est le même, la répartition du travail entre le matériel et le programme est différente. Sur x86-64, toute fonction appelée trouve son adresse de retour sur la pile, ce qui simplifie le programme et rend l'adresse de retour systématiquement voisine des variables locales. Sur RISC-V, une fonction terminale, qui n'appelle personne, n'a rien à empiler du tout et repart de ra, ce qui économise deux accès mémoire à chaque appel.

Une adresse de retour toujours sur la pile

L'écrasement d'adresse de retour du chapitre 9 s'applique littéralement à x86-64, et même plus directement, puisque l'adresse y est sur la pile dès la première instruction de la fonction, quelle que soit la fonction. C'est la raison historique pour laquelle les exemples de dépassement de tampon des trente dernières années sont écrits sur cette architecture.

Un désassemblage, ligne à ligne

Assez de pièces détachées. Voici une fonction complète, compilée sans optimisation et désassemblée telle quelle. Elle rend le plus grand de ses deux arguments. Les deux arguments arrivent dans edi et esi, le résultat repart dans eax : c'est la convention d'appel de cette plateforme, l'équivalent des a0, a1 du chapitre 9.

400446:  push   rbp                       ; sauver l'ancien pointeur de cadre sur la pile
400447:  mov    rbp,rsp                   ; installer le cadre de cette fonction
40044a:  mov    DWORD PTR [rbp-0x4],edi   ; ranger le premier argument dans la pile
40044d:  mov    DWORD PTR [rbp-0x8],esi   ; ranger le second argument dans la pile
400450:  mov    eax,DWORD PTR [rbp-0x4]   ; relire le premier argument dans eax
400453:  cmp    eax,DWORD PTR [rbp-0x8]   ; comparer eax au second argument, poser les drapeaux
400456:  jle    40045d                    ; si eax est inférieur ou égal, aller en 40045d
400458:  mov    eax,DWORD PTR [rbp-0x4]   ; sinon, résultat = premier argument
40045b:  jmp    400460                    ; et sauter par-dessus la branche suivante
40045d:  mov    eax,DWORD PTR [rbp-0x8]   ; résultat = second argument
400460:  pop    rbp                       ; restaurer l'ancien pointeur de cadre
400461:  ret                              ; dépiler l'adresse de retour et y sauter

Trois observations suffisent à tout tenir.

Les quatre premières lignes et les deux dernières forment le prologue et l'épilogue, c'est-à-dire la même paire empiler/dépiler que le chapitre 8. Elles ne font aucun travail utile : sans optimisation, le compilateur range systématiquement les arguments en mémoire, puis les relit, exactement comme la première traduction de boucle du chapitre 10.

La ligne cmp et la ligne jle sont séparées, et rien dans jle ne dit ce qui a été comparé. C'est le canal invisible, en situation.

Les adresses de saut sont des adresses, pas des étiquettes. jle 40045d désigne un octet précis du programme, et c'est le désassembleur qui a écrit ce nombre parce que l'étiquette d'origine n'existe plus dans le binaire.

Le même calcul, en RISC-V et écrit à la main, tient en trois instructions pour la fonction elle-même, sans prologue ni épilogue parce qu'elle n'a rien à sauvegarder. Les deux instructions qui les précèdent sont l'appel et le saut par-dessus le corps de la fonction.

RISC-V RV32Ipas 0 sur 4pc = 0x0

programme

  1. 0x0call plus_grandjal ra, plus_grand
  2. 0x4j finjal zero, fin
  3. 0x8bge a0, a1, garder
  4. 0xcmv a0, a1addi a0, a1, 0
  5. 0x10retjalr zero, ra, 0

registres

ra0
a07
a13

Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.

Exécuter pas à pas en surveillant ra et le compteur ordinal : call range l'adresse de retour dans ra, ret y ramène. Relever a0 à l'arrivée, puis relancer avec a1 plus grand que a0 en modifiant la première ligne.

Quatre pas d'exécution dans la figure, contre douze instructions dans le désassemblage, pour le même résultat. L'écart ne mesure pas la qualité des deux architectures : il mesure ce qu'un compilateur sans optimisation ajoute, et ce qu'une traduction à la main peut se permettre de ne pas écrire.

Avec optimisation, d'ailleurs, le même compilateur produit pour cette fonction quatre lignes seulement, sans prologue et sans accès mémoire, et pour l'appel plus_grand(7, 3) il produit mov eax,0x7 suivi de ret : il a fait le calcul lui-même et n'a gardé que la réponse. Le chapitre 10 annonçait ce genre de disparition ; la voici.

Vérification rapideon peut se reprendre

1.En syntaxe Intel, que fait add eax, ecx ?

2.Les lignes mov rbp,rsp et mov %rsp,%rbp figurent dans deux désassemblages du même exécutable. Que faut-il en conclure ?

3.Que laisse cmp eax, ecx derrière elle ?

4.Pourquoi un saut conditionnel x86-64 est-il difficile à lire isolément ?

5.Que désignent rax, eax, ax et al ?

6.Qu'est-ce que call f fait de l'adresse de retour, et en quoi RISC-V diffère-t-il ?

7.Un désassemblage affiche jle 40045d au lieu d'un nom d'étiquette. Pourquoi ?

La méthode

  1. Identifier la syntaxe avant de lire quoi que ce soit : un signe pour-cent devant les registres annonce AT&T et la destination à droite ; des crochets annoncent Intel et la destination à gauche.
  2. Lire une instruction à deux opérandes comme une affectation composée : add eax, ecx se transcrit mentalement en « eax devient eax plus ecx », et signale au passage qu'une valeur vient d'être perdue.
  3. Remonter le listing depuis chaque saut conditionnel jusqu'à l'instruction qui a posé les drapeaux, et se méfier de toute instruction arithmétique ou logique rencontrée en chemin.
  4. Traduire la taille du nom de registre : un nom en r porte sur 64 bits, en e sur 32, une paire de lettres sur 16, une terminaison en l sur 8.
  5. Repérer le prologue et l'épilogue en premier : push rbp et mov rbp,rsp au début, pop rbp et ret à la fin. Ce qui reste entre les deux est le travail réel de la fonction.
  6. Comparer deux niveaux d'optimisation du même code quand un désassemblage résiste : la version non optimisée dit ce que la fonction calcule, la version optimisée dit ce que la machine exécute.

Synthèse

  • En x86-64, les instructions arithmétiques ont deux opérandes, et le premier est à la fois source et destination. Conserver une valeur coûte une copie préalable, ce que RISC-V évite avec ses trois opérandes.
  • Les syntaxes Intel et AT&T décrivent les mêmes octets en inversant l'ordre des opérandes. Repérer la syntaxe avant de lire une ligne évite d'inverser le sens d'une affectation.
  • Le registre de drapeaux est un canal invisible : cmp soustrait, jette le résultat et ne garde que des drapeaux, que jle lit sans les nommer. C'est un choix d'architecture, dont le chapitre 5 a montré l'alternative, où comparaison et saut tiennent en une instruction qui nomme tout.
  • Un registre x86-64 porte quatre noms selon la taille visée, rax, eax, ax, al, pour un seul registre physique. Écrire dans une partie modifie le tout, et pas toujours de la même façon.
  • push, pop, call et ret reprennent la mécanique du chapitre 8 et du chapitre 9, avec une différence : call empile l'adresse de retour, là où jal la range dans ra.
  • Un désassemblage ne contient plus d'étiquettes, seulement des adresses, et un prologue de fonction non optimisé range systématiquement ses arguments en mémoire avant de les relire.

Ce que onze chapitres ont installé

Le module a commencé par trois pièces : des registres, de la mémoire, un compteur ordinal. Tout le reste en a découlé sans qu'aucune pièce nouvelle soit ajoutée. Une adresse n'est qu'un nombre rangé dans un registre, et une lampe qui s'allume n'est qu'une écriture à la bonne adresse. Un masque n'est qu'un ET, un champ extrait qu'un décalage suivi d'un ET. Une décision n'est qu'une comparaison et un compteur ordinal modifié, une boucle qu'un saut vers une adresse plus petite, un tableau qu'une base plus un indice multiplié par une taille. Une pile n'est qu'un registre qu'on fait descendre, un appel de fonction qu'une adresse de retour rangée quelque part, et une récursion qu'un empilement de ces adresses. Le compilateur, enfin, ne fait rien d'autre que tout cela, plus vite et en choisissant mieux.

Ce qui reste, une fois le module refermé, n'est pas une capacité à écrire des programmes en assembleur, que peu de métiers demandent. C'est une capacité à ne plus être arrêté par certaines questions. Pourquoi une structure de données occupe plus d'octets que la somme de ses champs. Pourquoi une documentation de protocole insiste sur l'ordre des octets. Pourquoi un débogueur affiche une valeur qui n'existe plus. Pourquoi une erreur de segmentation pointe une adresse presque nulle. Pourquoi un dépassement de tampon donne la main à qui l'exploite, plutôt que de simplement faire planter le programme.

Et pourquoi, surtout, une ligne de code aussi anodine que stock = stock - 1 peut donner un résultat faux quand deux traitements l'exécutent ensemble. La réponse tient dans ce module : cette ligne est un lw, un addi, un sw, et rien n'oblige ces trois instructions à rester collées. C'est le point de départ de la condition de course, qui reprend exactement ici.