Une machine qui ne ment pas
Ce que ce chapitre apporte
- Expliquer ce qu'est une instruction, et pourquoi un processeur ne sait rien faire d'autre.
- Dire ce qu'est un jeu d'instructions, et pourquoi sa liste est fixe et publiée.
- 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 pas à pas et prévoir la valeur d'un registre avant de la vérifier.
- Expliquer pourquoi l'ordre des instructions change le résultat.
- Distinguer une instruction réellement exécutée d'un raccourci d'écriture proposé par l'assembleur.
- Situer l'assembleur dans un métier d'ingénieur : ce qu'il sert à lire, et ce qu'il ne sert plus à écrire.
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. 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 dit de quoi ce langage est fait, installe les trois pièces dont une machine a besoin, et les montre bouger.
Le processeur ne comprend que des instructions
Cette phrase, que tous les cours du domaine répètent, passe pour une formule. Elle est littérale.
Un processeur est un circuit. Il reçoit un nombre de trente-deux bits et, selon la disposition de ces bits, laisse passer les valeurs vers son additionneur, vers son comparateur, ou vers la sortie qui parle à la mémoire. Ce nombre s'appelle une instruction. Rien à l'intérieur ne ressemble à un mot ou à un nom de variable : rien qui puisse être interprété, deviné, ou compris au sens courant du terme.
add t0, a0, a1 n'existe donc pas dans la machine : c'est la transcription lisible du nombre qui, lui, existe. Le nom add s'appelle un mnémonique, et l'assembleur traduit ces mnémoniques en nombres, presque toujours un pour un.
Un mot mérite d'être écarté : ce ne sont pas des commandes. Une commande se tape dans un terminal et peut échouer en expliquant pourquoi ; une instruction s'adresse à un circuit et ne discute pas.
La conséquence est vertigineuse : tout ce qu'une machine exécute, un interpréteur Python, un navigateur, un système d'exploitation, est une suite d'instructions choisies dans une liste très courte.
Trois pièces, et rien d'autre
Un processeur ne contient ni variables, ni fonctions, ni types. Son état complet 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 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, dont le 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. Ranger et reprendre 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.
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 tout le 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. 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 ne fait qu'une chose. Aucune ne calcule une moyenne, ne trie un tableau ni n'affiche un texte : chacune de ces opérations est une suite d'instructions.
Une liste fixe, et publiée
Un langage de programmation s'étend sans cesse : une fonction définie, une bibliothèque importée, et le vocabulaire disponible a grandi. Un jeu d'instructions ne fonctionne pas ainsi. La liste est close. RISC-V de base en compte une quarantaine, décidées une fois et gravées dans le circuit : en ajouter une ne s'écrit pas, cela se fabrique.
Fixe au sens le plus matériel : le décodeur est câblé pour reconnaître ces motifs de bits et aucun autre. Publiée, ensuite : la spécification de RISC-V est un document public, ce qui permet d'écrire un compilateur ou un débogueur pour cette machine, puis d'exécuter le résultat sur la puce d'un autre fabricant.
Plusieurs de ces listes coexistent : x86-64 sur les serveurs, ARM sur les téléphones, RISC-V dans l'embarqué. Un programme compilé pour l'une ne s'exécute pas sur l'autre, et c'est ce que veut dire « un exécutable pour telle machine ».
Le jeu d'instructions d'un processeur est la liste complète des ordres qu'il sait exécuter, avec pour chacun la disposition exacte de ses bits et l'effet exact qu'il produit. C'est un contrat : le fabricant de la puce s'engage à exécuter cette liste, celui qui écrit les programmes à n'écrire que dedans.
La conséquence pratique est immédiate : quand un calcul ne figure pas dans la liste, il faut le construire avec ce qui s'y trouve. RISC-V de base n'a ni multiplication ni division : doubler une valeur se fait en l'ajoutant à elle-même, et multiplier deux valeurs quelconques demande une boucle. Le chapitre suivant donne la liste entière, famille par famille.
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 sur la mémoire justifie ; il suffit ici 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 : addi t0, t0, 5 ajoute cinq à ce que t0 contient déjà, et slli t0, t0, 1 décale son contenu d'un bit vers la gauche, ce qui revient à le doubler.
Le bouton qui recule d'un pas mérite d'être essayé tout de suite : il rend accessible l'instant précis où une valeur a changé, sans relancer le programme depuis le début.
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, mais il dit l'essentiel sur la machine : un processeur n'a aucune notion de priorité des opérations. Pas de parenthèses, pas d'expression. Une suite de gestes, et le résultat est ce que cette suite produit.
Traduire une formule vers l'assembleur consiste donc à 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 finissent sur le même registre peuvent passer par des états entièrement différents.
Le registre qui ne change jamais
Parmi les trente-deux registres, le premier est particulier. Il s'appelle zero, et il contient zéro pour toujours : le matériel l'a câblé ainsi, et les instructions qui prétendent l'écrire sont exécutées normalement, leur résultat 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. C'est en réalité l'astuce la plus rentable du jeu, parce qu'elle évite d'inventer des instructions : copier un registre n'a pas d'instruction propre, c'est une addition avec zero, et poser une constante non plus. Le chapitre Décider 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 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 et la constante : il ne reste que douze bits pour cette dernière, soit -2048 à 2047. Cent mille n'y tient pas, et l'assembleur fabrique donc deux instructions, la figure montrant t1 passer par 98 304 avant d'atteindre 100 000.
Compter les lignes 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 affichent les deux colonnes, la ligne écrite et l'instruction réelle, et c'est le même écart qui explique qu'un débogueur s'arrête parfois « au milieu » d'une ligne de code.
Les pseudo-instructions sont une vingtaine, et le chapitre suivant en donne la table complète : il n'y a aucune raison de les retenir.
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. Faute de multiplication, 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.
La première ligne emploie sub, la soustraction, qui se lit comme add : la destination d'abord, puis les deux sources dans l'ordre. sub t0, a0, a1 range donc dans t0 la valeur de a0 moins celle de a1, et cet ordre, indifférent pour une addition, décide ici du signe : sub t0, a1, a0 aurait rangé -21.
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, puisque chaque étape n'a besoin que de la précédente et qu'un seul registre suffirait.
Noter aussi ce qui n'apparaît nulle part : le fait que a0 soit une mesure et a1 une correction. La machine voit deux nombres, et tout le sens est dans la tête de qui écrit, ou dans les commentaires.
Un registre ne contient pas « un entier », « un caractère » ou « une adresse » : il contient trente-deux bits, et c'est l'instruction choisie qui décide de les lire comme un nombre signé, un motif de bits ou une adresse. Cette absence de type est la source d'une grande partie des bugs que les langages de haut niveau rendent impossibles, et le chapitre Décider la reprend en détail.
Ce que l'assembleur sert à faire, et ce qu'il ne sert plus à faire
Autant le dire : un ingénieur qui lit ce module n'écrira probablement jamais un programme en assembleur. Ce travail a été confié aux compilateurs il y a plusieurs décennies, et ils le font mieux : choisir quel résultat garder dans quel registre, ordonner les instructions pour que le processeur ne s'arrête pas à attendre, sont des tâches de comptabilité sur des milliers de lignes, où une machine bat un humain sans discussion.
Ce qui reste ne disparaît pas.
Lire. Un rapport de plantage, un débogueur arrêté dans une bibliothèque sans code source, un profileur qui désigne une ligne coûteuse : tous affichent des instructions. Savoir les lire transforme un écran incompréhensible en une phrase simple : « un chargement à une adresse qui vaut zéro ».
Vérifier. Deux écritures équivalentes dans un langage de haut niveau produisent parfois trois instructions dans un cas et trente dans l'autre, et seul le compilateur le dit.
Écrire quelques lignes, là où rien d'autre ne marche : le démarrage d'une machine, un gestionnaire d'interruption, un pilote qui parle à un composant à l'octet près. Quelques dizaines de lignes dans des systèmes qui en comptent des millions, mais quelqu'un les écrit.
Le bénéfice de ce module n'est pas une compétence de rédaction, c'est un modèle : un rez-de-chaussée où rien n'est caché, auquel il devient possible de descendre quand l'étage au-dessus cesse d'être compréhensible. Les bugs qui semblent relever de la magie ont presque toujours, ici, une explication ennuyeuse.
Un dernier exercice de lecture
Le programme suivant contient une instruction sans effet. La trouver avant de lancer la figure.
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 donnent t0 à douze et t1 à seize. Une instruction sans effet n'est pas toujours une erreur : nop 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.Qu'est-ce qu'un jeu d'instructions ?
7.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, et reculer d'un pas quand la prévision tombe à côté.
- 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.
- Chercher une instruction inconnue dans le mémento du chapitre suivant, qui la range dans sa famille avec sa forme d'écriture et son effet.
- Garder les intentions dans les commentaires : un nom de registre ne dira jamais qu'il contient une température.
Synthèse
- Un processeur n'exécute que des instructions, des nombres de trente-deux bits qu'un circuit décode ;
add t0, a0, a1en est la transcription lisible, rien de plus. - Le jeu d'instructions est une liste fixe et publiée, close par construction : une quarantaine d'entrées, sans multiplication ni division, et ce qui n'y figure pas se construit avec ce qui s'y trouve.
- L'état d'un processeur tient en trois pièces : les registres, trente-deux cases de calcul, 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, etsub t0, a0, a1leur différence, dans l'ordre écrit. - Le compteur ordinal avance de quatre en quatre, chaque instruction occupant quatre octets, et définit ainsi ce que « 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, faute de priorité des opérations et de parenthèses.
- Le registre
zerovaut toujours zéro, ce qui permet d'écrire une copie ou une constante sans instruction dédiée. - 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 l'instruction décide de leur sens.
- L'assembleur se lit bien plus souvent qu'il ne s'écrit : plantages, profileurs et désassemblages en sont faits, et les rares lignes écrites à la main tiennent au démarrage et aux pilotes.
Ces points suffisent pour lire les instructions rencontrées ici, et pas les trente autres qui attendent. Le jeu d'instructions donne la liste entière, rangée par familles, avec pour chacune une figure qui la fait tourner et se laisse modifier.