Une machine qui ne ment pas
Ce que ce chapitre apporte
- Nommer les trois pièces qui composent l'état d'un processeur : les registres, la mémoire, le compteur ordinal.
- Lire une instruction RISC-V et dire quel registre elle modifie, à partir de quelles sources.
- Suivre l'exécution instruction par instruction et prévoir la valeur d'un registre avant de la vérifier.
- Expliquer pourquoi l'ordre des instructions change le résultat, et le montrer sur un exemple.
- Distinguer une instruction réellement exécutée par le matériel d'un raccourci d'écriture proposé par l'assembleur.
Un langage de haut niveau est fait de promesses. total = total + 1 promet une addition, afficher(x) promet un affichage, t[3] promet un accès au quatrième élément. Ces promesses tiennent presque toujours, et c'est très bien ainsi. Presque toujours : un jour, un programme se comporte d'une façon que son texte n'explique pas, et il faut regarder ce qui se passe réellement. L'assembleur est le langage de ce moment-là. Il ne promet rien : il dit qu'il ajoute le contenu d'un registre à celui d'un autre, et range le résultat dans un troisième. Ce chapitre installe les trois seules pièces dont une machine a besoin, et les montre bouger.
Trois pièces, et rien d'autre
Un processeur ne contient ni variables, ni fonctions, ni types. Son état complet, à un instant donné, tient dans trois choses.
Les registres sont des cases de calcul internes, en nombre fixe et très petit : trente-deux en RISC-V, chacune contenant un nombre de trente-deux bits. Ce sont les seules cases sur lesquelles le processeur sait calculer directement. Elles portent des noms courts, t0, a0, sp, qui ne veulent rien dire en eux-mêmes : ce sont des étiquettes sur des cases, pas des noms de variables.
La mémoire est un très grand tableau d'octets numérotés. Son numéro s'appelle une adresse. Le processeur ne sait pas calculer dessus : il sait seulement en recopier un morceau dans un registre, ou l'inverse. Le chapitre suivant s'occupe de ce va-et-vient.
Le compteur ordinal est un registre à part, qui contient l'adresse de la prochaine instruction à exécuter. C'est lui, et lui seul, qui décide de ce qui se passe ensuite. Il porte souvent son nom anglais, program counter, abrégé pc.
Exécuter un programme consiste à répéter le même geste : lire l'instruction que le compteur ordinal désigne, la faire, avancer le compteur ordinal. Rien de plus.
La première instruction
Voici l'instruction la plus simple du jeu. Elle prend le contenu de deux registres, les additionne, et range le résultat dans un troisième.
programme
- 0x0add t0, a0, a1
registres
| t0 | 0 | |
| a0 | 3 | |
| a1 | 4 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
L'ordre des trois noms se lit toujours de la même façon en RISC-V : la destination d'abord, les sources ensuite. add t0, a0, a1 se lit donc « ranger dans t0 la somme de a0 et de a1 ». Cette régularité vaut pour presque toutes les instructions du jeu, et c'est une des raisons pour lesquelles ce cours l'a choisi.
La ligne registres: en tête n'est pas une instruction : elle décrit la machine avant le départ, pour que la figure ait quelque chose à calculer. Sans elle, tous les registres valent zéro.
Une instruction est un ordre élémentaire que le matériel sait exécuter en un temps fixe. Elle nomme au plus un registre destination et au plus deux sources, et elle ne fait qu'une chose. Il n'existe pas d'instruction qui calcule une moyenne, trie un tableau ou affiche un texte : chacune de ces opérations est une suite d'instructions.
Le compteur ordinal avance de quatre en quatre
Chaque instruction occupe quatre octets en mémoire. La première du programme est donc à l'adresse 0x0, la deuxième à 0x4, la troisième à 0x8. L'écriture 0x annonce un nombre en hexadécimal, une notation que le chapitre suivant justifie ; pour l'instant, il suffit de savoir que 0x8 vaut huit.
programme
- 0x0li t0, 10addi t0, zero, 10
- 0x4addi t0, t0, 5
- 0x8slli t0, t0, 1
registres
| t0 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Le registre t0 prend successivement les valeurs 10, 15, puis 30. Trois instructions, trois états, et aucune magie : addi t0, t0, 5 ajoute cinq à ce que t0 contient déjà, slli t0, t0, 1 décale son contenu d'un bit vers la gauche, ce qui revient à le multiplier par deux.
Le bouton qui recule d'un pas mérite d'être essayé tout de suite. Il rend possible une chose que les outils du domaine n'offrent pas : revenir sur l'instant précis où une valeur a changé, au lieu de relancer le programme depuis le début en espérant mieux regarder.
L'ordre est le programme
Les trois mêmes instructions, dans un autre ordre, ne donnent pas le même résultat.
programme
- 0x0li t0, 10addi t0, zero, 10
- 0x4slli t0, t0, 1
- 0x8addi t0, t0, 5
registres
| t0 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Dix, doublé, plus cinq : vingt-cinq. Dix, plus cinq, doublé : trente. L'écart n'a rien de surprenant pour qui a fait des mathématiques, mais il dit quelque chose d'important sur la machine. Un processeur n'a aucune notion de priorité des opérations. Il n'y a pas de parenthèses, pas de règle « la multiplication avant l'addition », pas d'expression. Il y a une suite de gestes, et le résultat est ce que cette suite produit.
Traduire une formule vers l'assembleur consiste donc essentiellement à choisir un ordre, et à trouver où ranger les résultats intermédiaires.
Un langage de haut niveau décrit un résultat, et laisse le compilateur choisir le chemin. L'assembleur est le chemin. Deux programmes qui aboutissent au même registre final peuvent passer par des états entièrement différents, et cette différence est exactement ce que la figure montre.
Le registre qui ne change jamais
Parmi les trente-deux registres, le premier est particulier. Il s'appelle zero, il contient zéro, et il contient zéro pour toujours. Le matériel l'a câblé ainsi : les instructions qui prétendent l'écrire sont exécutées normalement, mais leur résultat est jeté.
programme
- 0x0li t0, 7addi t0, zero, 7
- 0x4addi zero, t0, 100
- 0x8add t1, zero, zero
registres
| t0 | 0 | |
| t1 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Une case qui ne retient rien peut sembler un gâchis. Elle est en réalité l'astuce la plus rentable du jeu d'instructions, parce qu'elle évite d'en inventer d'autres. Copier un registre dans un autre, par exemple, n'a pas d'instruction propre : c'est une addition avec zero. Mettre une constante dans un registre non plus : c'est une addition de cette constante à zero. Le chapitre 5 en donnera d'autres usages, autour des comparaisons.
Ce que l'assembleur écrit à la place de ce qui est écrit
Dans les figures précédentes, li t0, 10 apparaît, mais la colonne de droite affiche autre chose : addi t0, zero, 10. Les deux lignes ne se contredisent pas. li n'existe pas dans le matériel. C'est un raccourci d'écriture, une pseudo-instruction, que l'assembleur remplace par une instruction réelle au moment de produire le programme.
Le remplacement n'est pas toujours aussi direct.
programme
- 0x0li t0, 5addi t0, zero, 5
- 0x4li t1, 100000lui t1, 24
- 0x8addi t1, t1, 1696
registres
| t0 | 0 | |
| t1 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
La raison tient à une contrainte de format. Une instruction occupe trente-deux bits, qui doivent loger l'opération, les registres concernés et la constante : il ne reste que douze bits pour cette dernière, soit les valeurs de -2048 à 2047. Cent mille n'y tient pas. L'assembleur écrit donc lui t1, 24, qui pose les bits de poids fort et donne 98 304, puis addi t1, t1, 1696, qui ajoute ce qui manque. La figure montre t1 passer par 98 304 avant d'atteindre 100 000.
Compter les lignes d'un programme ne donne pas le nombre d'instructions, et une étiquette placée après une pseudo-instruction ne désigne pas forcément l'adresse attendue. Les figures de ce module affichent toujours les deux colonnes, la ligne écrite et l'instruction réelle, pour que l'écart reste visible. C'est le même écart qui explique pourquoi un débogueur s'arrête parfois « au milieu » d'une ligne de code.
Traduire un petit calcul
Soit à calculer, à partir d'une mesure rangée dans a0 et d'une correction rangée dans a1, la valeur (a0 - a1) × 2 - 2. RISC-V de base ne sait pas multiplier, mais doubler un nombre revient à l'ajouter à lui-même.
programme
- 0x0sub t0, a0, a1
- 0x4add t1, t0, t0
- 0x8addi t2, t1, -2
registres
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| a0 | 25 | |
| a1 | 4 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Vingt-cinq moins quatre donne vingt et un, doublé quarante-deux, moins deux quarante. Trois registres pour trois étapes : c'est la forme la plus lisible, et pas la plus économe. Rien n'empêche de tout ranger dans t0, puisque chaque étape n'a besoin que de la précédente. Les deux versions calculent la même chose ; la seconde occupe un registre au lieu de trois, ce qui devient un argument quand le programme grandit.
Noter aussi ce qui n'apparaît nulle part : le fait que a0 soit une mesure et a1 une correction. La machine ne le sait pas. Elle voit deux nombres de trente-deux bits. Tout le sens est dans la tête de qui écrit, et dans les commentaires.
Un registre ne contient pas « un entier », « un caractère » ou « une adresse » : il contient trente-deux bits. C'est l'instruction choisie qui décide de les interpréter comme un nombre signé, comme un motif de bits ou comme une adresse. Cette absence de type est la source d'une grande partie des bugs que les langages de haut niveau rendent impossibles, et elle sera reprise en détail au chapitre 5.
Un dernier exercice de lecture
Le programme suivant contient une instruction sans effet. La trouver avant de lancer la figure, puis vérifier.
programme
- 0x0add t0, a0, a0
- 0x4addi zero, t0, 1
- 0x8addi t1, t0, 4
registres
| t0 | 0 | |
| t1 | 0 | |
| a0 | 6 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
La réponse
addi zero, t0, 1 calcule bien treize, et jette le résultat, puisque sa destination est zero. Les deux autres instructions donnent t0 à douze et t1 à seize. Une instruction sans effet n'est pas toujours une erreur : nop, qui sert à occuper de la place sans rien changer, est exactement cela, addi zero, zero, 0.
1.Que contient le compteur ordinal ?
2.Dans sub t2, t0, t1, quel registre reçoit le résultat ?
3.Que se passe-t-il quand une instruction range une valeur dans zero ?
4.Pourquoi li t1, 100000 produit-il deux instructions alors que li t0, 5 n'en produit qu'une ?
5.Deux programmes contiennent exactement les mêmes trois instructions, dans un ordre différent. Que peut-on dire de leurs résultats ?
6.Un registre contient la valeur 65. S'agit-il du nombre soixante-cinq ou du caractère A ?
La méthode
- Repérer la destination avant tout le reste : en RISC-V, c'est le premier registre nommé, et c'est le seul que l'instruction modifie.
- Lire le programme comme une suite d'états, pas comme une expression : noter ce que contient chaque registre après chaque ligne, quitte à le faire sur papier avant de vérifier à la figure.
- Décomposer un calcul en étapes élémentaires, une opération par instruction, et choisir un registre pour chaque résultat intermédiaire.
- Vérifier une prévision plutôt que la lire : prévoir la valeur du registre, exécuter un pas, comparer. Reculer d'un pas quand la prévision tombe à côté, et chercher où l'écart est né.
- Se méfier de ce qui est écrit : comparer la ligne source et l'instruction réelle affichée à côté, et compter les instructions produites plutôt que les lignes.
- Garder les intentions dans les commentaires, puisque les registres n'en portent aucune : un nom de registre ne dira jamais qu'il contient une température.
Synthèse
- L'état d'un processeur tient en trois pièces : les registres, trente-deux cases de calcul de trente-deux bits, la mémoire, un grand tableau d'octets adressés, et le compteur ordinal, qui désigne l'instruction suivante.
- Une instruction RISC-V nomme la destination d'abord, puis ses sources.
add t0, a0, a1range danst0la somme dea0eta1, et ne modifie rien d'autre. - Le compteur ordinal avance de quatre en quatre, chaque instruction occupant quatre octets. C'est lui qui définit ce que le mot « ensuite » veut dire.
- L'ordre fait partie du programme : les mêmes trois instructions permutées donnent 30 dans un cas et 25 dans l'autre. Il n'y a ni expression, ni priorité des opérations, ni parenthèses.
- Le registre
zerovaut toujours zéro, ce qui permet d'écrire une copie ou une constante sans instruction dédiée, et rend certaines instructions valides mais sans effet. - Une ligne écrite n'est pas toujours une instruction exécutée :
liest une pseudo-instruction, remplacée par une instruction réelle, ou par deux quand la constante dépasse douze bits. - Un registre ne porte aucun type : il contient des bits, et c'est l'instruction qui décide de leur sens.
Ces sept points suffisent pour calculer, mais pas pour retenir quoi que ce soit au-delà de trente-deux nombres. Ranger et reprendre ouvre la deuxième pièce, la mémoire, et avec elle la question de savoir où se trouve une donnée.