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.
programme
- 0x0add t0, a0, a1
- 0x4mv t1, a0addi t1, a0, 0
- 0x8add t1, t1, a1
registres
| 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.
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.
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 Ranger et reprendre, 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 Décider 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
| 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.
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 Masquer et filtrer.
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 La pile 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 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.
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. 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 Ce que le compilateur écrit.
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
| 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.
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
- 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.
- Lire une instruction à deux opérandes comme une affectation composée :
add eax, ecxse transcrit mentalement en « eax devient eax plus ecx », et signale au passage qu'une valeur vient d'être perdue. - 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.
- Traduire la taille du nom de registre : un nom en
rporte sur 64 bits, enesur 32, une paire de lettres sur 16, une terminaison enlsur 8. - Repérer le prologue et l'épilogue en premier :
push rbpetmov rbp,rspau début,pop rbpetretà la fin. Ce qui reste entre les deux est le travail réel de la fonction. - 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 :
cmpsoustrait, jette le résultat et ne garde que des drapeaux, quejlelit sans les nommer. C'est un choix d'architecture, dont le chapitre sur la décision 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,calletretreprennent la mécanique des chapitres sur la pile et sur les appels, avec une différence :callempile l'adresse de retour, là oùjalla range dansra.- 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.
- Un tableau de correspondance met les instructions employées par ce module en regard de leurs équivalents x86-64. Trois écarts y reviennent partout : une copie de plus par opération, deux lignes par décision au lieu d'une, et des calculs qui touchent la mémoire sans passer par un chargement.
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. Les six chapitres suivants la donnent, en commençant par l'illusion du multitâche : 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.