Aller au contenu principal

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.

RISC-V RV32Ipas 0 sur 1pc = 0x0

programme

  1. 0x0add t0, a0, a1

registres

t00
a03
a14

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

Cliquer une fois sur « Exécuter une instruction » et regarder la ligne t0 du tableau des registres : elle passe de 0 à 7, et la figure signale la valeur qu'elle avait avant.

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.

Instruction

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 ».

Jeu d'instructions

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.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0li t0, 10addi t0, zero, 10
  2. 0x4addi t0, t0, 5
  3. 0x8slli t0, t0, 1

registres

t00

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

Exécuter les trois instructions une par une en regardant l'en-tête de la figure : le compteur ordinal passe de 0x0 à 0x4, puis à 0x8. Reculer ensuite d'un pas pour retrouver l'état précédent.

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.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0li t0, 10addi t0, zero, 10
  2. 0x4slli t0, t0, 1
  3. 0x8addi t0, t0, 5

registres

t00

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

Comparer avec la figure précédente, qui contient exactement les mêmes trois instructions. Aller à la fin des deux et relever les deux valeurs de t0 : 30 dans un cas, 25 dans l'autre.

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 programme est une trajectoire, pas une description

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é.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0li t0, 7addi t0, zero, 7
  2. 0x4addi zero, t0, 100
  3. 0x8add t1, zero, zero

registres

t00
t10

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

Exécuter les trois instructions. La deuxième range 107 dans zero, du moins en apparence : la figure ne signale aucun changement, et la troisième instruction en tire bien 0.

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.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0li t0, 5addi t0, zero, 5
  2. 0x4li t1, 100000lui t1, 24
  3. 0x8addi t1, t1, 1696

registres

t00
t10

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

Les deux lignes se ressemblent, et la colonne de droite montre qu'elles ne produisent pas la même chose : une instruction pour la première, deux pour la seconde. Avancer jusqu'à la fin et regarder t1 passer par une valeur intermédiaire.

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.

Une ligne écrite n'est pas toujours une instruction exécutée

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.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0sub t0, a0, a1
  2. 0x4add t1, t0, t0
  3. 0x8addi t2, t1, -2

registres

t00
t10
t20
a025
a14

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

Suivre les trois résultats intermédiaires : t0 reçoit l'écart, t1 son double, t2 le résultat final. Modifier ensuite mentalement l'ordre des deux dernières instructions et prévoir ce qui se passerait.

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.

La machine ne connaît aucun type

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.

Lire plutôt qu'écrire

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.

RISC-V RV32Ipas 0 sur 3pc = 0x0

programme

  1. 0x0add t0, a0, a0
  2. 0x4addi zero, t0, 1
  3. 0x8addi t1, t0, 4

registres

t00
t10
a06

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

Exécuter pas à pas et repérer la seule instruction que la figure exécute sans rien signaler comme modifié. Elle est parfaitement valide, et parfaitement inutile.
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.

Vérification rapideon peut se reprendre

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

  1. 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.
  2. 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.
  3. Décomposer un calcul en étapes élémentaires, une opération par instruction, et choisir un registre pour chaque résultat intermédiaire.
  4. 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é.
  5. 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.
  6. 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.
  7. 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, a1 en 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, a1 range dans t0 la somme de a0 et a1, et sub t0, a0, a1 leur 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 zero vaut 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 : li est 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.