L'assembleur x86-64
Ce que ce chapitre apporte 5 points
- 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.
programme
- 0x0add t0, a0, a1
- 0x4mv t1, a0addi t1, a0, 0
- 0x8add t1, t1, a1
registres
| Registre | Valeur | Valeur précédente |
|---|---|---|
| t0 | 0 | |
| t1 | 0 | |
| a0 | 7 | |
| a1 | 3 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
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.
Un compilateur connaît ce coût, et le contourne quand il le peut. Voici une fonction qui additionne trois entiers, a + b + c, compilée avec optimisation par GCC 16.2.1 pour x86-64, puis désassemblée en syntaxe Intel.
add edi,esi
lea eax,[rdi+rdx*1]
ret
La première addition écrase edi, qui contenait a : c'est la forme à deux opérandes, et le compilateur l'accepte parce que a ne servira plus. La seconde n'est pas une addition mais lea, une instruction prévue pour calculer des adresses, que le compilateur détourne parce qu'elle a trois opérandes : elle range rdi + rdx dans eax sans détruire aucune de ses sources. Là où RISC-V écrit deux add ordinaires, x86 emprunte une instruction d'adressage pour retrouver ce qu'une destination séparée donne gratuitement.
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.
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 ?
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 Registres et mémoire, 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.
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 Les branchements conditionnels 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.
Deux instructions y servent, dont l'une n'avait pas encore quitté le mémento. slt t0, a0, a1 range 1 dans t0 si a0 est plus petit que a1, et 0 sinon : son nom abrège set if less than, poser si plus petit. bnez t0, plus_petit saute à l'étiquette si t0 ne vaut pas zéro, et la colonne des instructions réelles la donne comme bne t0, zero, plus_petit.
programme
- 0x0slt t0, a0, a1
- 0x4bnez t0, plus_petitbne t0, zero, plus_petit
- 0x8li t1, 0addi t1, zero, 0
- 0xcj finjal zero, fin
- 0x10li t1, 1addi t1, zero, 1
- 0x14blt a0, a1, aussi
- 0x18li t2, 0addi t2, zero, 0
- 0x1cj vraiment_finjal zero, vraiment_fin
- 0x20li t2, 1addi t2, zero, 1
registres
| Registre | Valeur | Valeur précédente |
|---|---|---|
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| a0 | 5 | |
| a1 | 9 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
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.
1.Que laisse cmp eax, ecx derrière elle ?
2.Pourquoi un saut conditionnel x86-64 est-il difficile à lire isolément ?
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.
| Nom | Bits atteints | Exemple d'usage |
|---|---|---|
rax | les 64 bits | une adresse, un entier long |
eax | les 32 bits de poids faible | un entier ordinaire |
ax | les 16 bits de poids faible | un entier court |
al | les 8 bits de poids faible | un 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 Masques binaires et décalages.
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.
Que désignent rax, eax, ax et al ?
La pile, et le retour rangé ailleurs
Le chapitre La pile et le cadre d'appel 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-64 | ce que cela fait | l'équivalent RISC-V |
|---|---|---|
push rbp | descend rsp de huit, écrit rbp au sommet | addi sp, sp, -4 puis sw s0, 0(sp) |
pop rbp | lit le sommet dans rbp, remonte rsp de huit | lw s0, 0(sp) puis addi sp, sp, 4 |
call f | empile l'adresse de retour, puis saute vers f | jal ra, f, qui range l'adresse dans ra |
ret | dépile une adresse et saute dessus | jalr zero, ra, 0, qui saute à l'adresse de ra |
La ligne qui compte est la troisième. Le chapitre Appeler une fonction en assembleur 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.
L'écrasement d'adresse de retour du chapitre sur les appels 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.
Qu'est-ce que call f fait de l'adresse de retour, et en quoi RISC-V diffère-t-il ?
Le dictionnaire des deux alphabets
Les pièces sont maintenant toutes là, et elles se rangent dans un tableau. La colonne du milieu donne les instructions RISC-V que ce module a réellement employées, et celle de droite la façon dont le même geste s'écrit en x86-64. Ce tableau se lit dans un seul sens : il sert à comprendre une ligne rencontrée dans un désassemblage, non à écrire du x86-64, qui demanderait bien autre chose. Les registres eax, ecx et edx y tiennent le rôle des t0, t1, t2, et les quatre dernières lignes reprennent la section précédente pour que le tableau puisse servir seul.
| Le geste | RISC-V | x86-64, syntaxe Intel |
|---|---|---|
| Poser une constante | li t0, 5 | mov eax, 5 |
| Copier un registre | mv t0, t1 | mov eax, ecx |
| Additionner deux registres | add t0, t1, t2 | mov eax, ecx puis add eax, edx |
| Ajouter une constante | addi t0, t0, 4 | add eax, 4 |
| Soustraire | sub t0, t1, t2 | mov eax, ecx puis sub eax, edx |
| Combiner des motifs de bits | and t0, t1, t2, xor, andi t0, t1, 0xff, ori | and eax, ecx, xor eax, ecx, and eax, 0xff, or eax, 8 |
| Décaler | slli, srli, srai t0, t1, 2 | shl eax, 2, shr eax, 2, sar eax, 2 |
| Lire un mot en mémoire | lw t1, 0(t0) | mov eax, DWORD PTR [rcx] |
| Écrire un mot en mémoire | sw t1, 0(t0) | mov DWORD PTR [rcx], eax |
| Lire un octet signé | lb t1, 0(t0) | movsx eax, BYTE PTR [rcx] |
| Lire un octet non signé | lbu t1, 0(t0) | movzx eax, BYTE PTR [rcx] |
| Charger l'adresse d'une donnée | la t0, notes | lea rax, [rip+0x2e35] |
| Sauter sans condition | j boucle | jmp 400460 |
| Sauter si égal | beq a0, a1, cible | cmp eax, ecx puis je 400460 |
| Sauter si nul | beqz t0, cible | test eax, eax puis je 400460 |
| Sauter si non nul | bnez t0, cible | test eax, eax puis jne 400460 |
| Sauter si plus petit | blt a0, a1, cible | cmp eax, ecx puis jl 400460 |
| Sauter si plus petit, non signé | bltu a0, a1, cible | cmp eax, ecx puis jb 400460 |
| Sauter si supérieur ou égal | bge a0, a1, cible | cmp eax, ecx puis jge 400460 |
| Garder le résultat d'une comparaison | slt t0, a0, a1 | cmp eax, ecx puis setl al |
| Empiler un mot | addi sp, sp, -4 puis sw t0, 0(sp) | push rax |
| Dépiler un mot | lw t0, 0(sp) puis addi sp, sp, 4 | pop rax |
| Appeler une fonction | jal ra, f, ou call f | call 400446 |
| Revenir | ret | ret |
Trois différences traversent ce tableau, et ce sont celles des sections précédentes, vues d'un seul coup.
La colonne de droite copie sans arrêt, parce que ses opérations à deux opérandes écrasent leur première source. Là où RISC-V nomme une destination distincte, x86-64 place un mov devant l'opération, et une ligne devient deux.
La colonne de droite coupe chaque décision en deux lignes, une qui pose les drapeaux et une qui les lit, alors que la colonne du milieu tient dans une seule instruction qui nomme ce qu'elle compare. Le test eax, eax des deux lignes « si nul » et « si non nul » est le tour de passe-passe habituel : un ET d'un registre avec lui-même ne change rien et pose les drapeaux.
La colonne de droite calcule directement en mémoire, enfin. add eax, DWORD PTR [rbp-0x4] est une instruction x86-64 parfaitement ordinaire, qui additionne un registre et une case mémoire. Aucune instruction RISC-V ne fait cela : un calcul y passe forcément par un lw, puis l'opération, puis un sw si le résultat doit repartir en mémoire. C'est ce qui s'appelle une architecture à chargement et rangement, et c'est la raison pour laquelle un même traitement y demande plus d'instructions, mais des instructions toutes de la même forme.
La colonne du milieu est celle du mémento du jeu d'instructions, où chaque famille figure au complet, avec les variantes que ce module n'a pas employées.
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 sur les appels.
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 sur la pile et le cadre d'appel. 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 Du code source au code machine.
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.
Quatre écritures de cette figure méritent un rappel, la dernière fois du module. call plus_grand est l'autre nom de jal ra, plus_grand : elle range dans ra l'adresse de l'instruction qui suit, puis saute. bge a0, a1, garder saute à l'étiquette si a0 est supérieur ou égal à a1. mv a0, a1 recopie un registre dans un autre. Et ret saute à l'adresse que ra contient.
programme
- 0x0call plus_grandjal ra, plus_grand
- 0x4j finjal zero, fin
- 0x8bge a0, a1, garder
- 0xcmv a0, a1addi a0, a1, 0
- 0x10retjalr zero, ra, 0
registres
| Registre | Valeur | Valeur précédente |
|---|---|---|
| ra | 0 | |
| a0 | 7 | |
| a1 | 3 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
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 précédent annonçait ce genre de disparition ; la voici.
Ce que douze 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 ces douze chapitres parcourus, 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 est à moitié écrite : cette ligne est un lw, un addi, un sw, et rien n'oblige ces trois instructions à rester collées. Il manque l'autre moitié, celle qui dit qui les sépare, et quand. Ce chapitre ferme le parcours, et cette moitié-là se trouve dans le parcours Systèmes d'exploitation, qui la donne en deux temps. Son premier chapitre, Processus et ordonnancement, monte d'abord d'un étage, jusqu'au système qui décide quel programme a la main et quand il la perd, et les deux chapitres qui le suivent y ajoutent la mémoire et les threads. Partager un processeur : le multitâche redescend ensuite au ras de la machine : une machine qui ne traite qu'une instruction à la fois donne pourtant l'apparence d'en mener plusieurs, et le moment où elle passe de l'une à l'autre n'est écrit nulle part dans le programme.
Un désassemblage affiche jle 40045d au lieu d'un nom d'étiquette. Pourquoi ?
Chapitre révisé en octobre 2026 · Une erreur dans ce chapitre ?