Ranger et reprendre
Ce que ce chapitre apporte
- Désigner une case de mémoire par son adresse, et lire une adresse écrite en hexadécimal.
- Charger une valeur de la mémoire vers un registre, et ranger un registre en mémoire, en passant par une adresse chargée au préalable.
- Choisir la largeur d'un accès entre lw, lh et lb, et distinguer leurs variantes signées de leurs variantes non signées.
- Déterminer dans quel ordre les octets d'un mot sont posés en mémoire, et le vérifier octet par octet.
- Expliquer pourquoi une adresse mal alignée fait échouer un chargement, et reconnaître le message qui le signale.
Un processeur calcule vite et retient peu. Trente-deux registres suffisent à tenir trois ou quatre résultats intermédiaires, et pas davantage : une trame réseau, un relevé de mesures, une phrase, rien de tout cela n'y entre. Ce qui dépasse vit ailleurs, dans la mémoire, et ne monte dans le processeur que le temps d'un calcul avant de redescendre. Ce chapitre installe ce va-et-vient. Il amène au passage deux notations dont aucune documentation technique ne se prive, l'écriture hexadécimale et l'ordre des octets, et une contrainte qui fait échouer des programmes entiers, l'alignement.
Trente-deux cases, et le reste du monde
Le décompte du chapitre précédent mérite d'être pris au sérieux. Trente-deux registres de trente-deux bits, dont un qui vaut toujours zéro : la totalité de ce qu'un processeur RISC-V tient sous la main représente cent vingt-quatre octets. Un nom de fichier un peu long n'y tient pas.
La mémoire est l'autre pièce, démesurée en comparaison. C'est un tableau d'octets numérotés à partir de zéro, et le numéro d'un octet s'appelle son adresse : la mémoire n'a pas de noms, elle a des numéros.
Entre les deux pièces, la règle est stricte. Le processeur calcule uniquement sur les registres. Il n'existe aucune instruction qui additionne deux cases de mémoire, ni même qui en compare deux. Ce qui existe, ce sont deux opérations de transport : charger, qui recopie de la mémoire vers un registre, et ranger, qui recopie d'un registre vers la mémoire. Tout programme un peu sérieux passe donc son temps à faire descendre des valeurs, calculer, et remonter le résultat.
Cette contrainte vient du matériel : un registre est physiquement à côté de l'unité de calcul, et la mémoire est loin. Loin se mesure en cycles, et l'écart entre les deux explique une bonne part des programmes lents.
Une adresse est le numéro d'un octet dans la mémoire. Elle ne dit rien de ce que cet octet contient, ni de sa taille : c'est l'instruction employée qui décide de lire un octet, deux ou quatre à partir de là. Une adresse est elle-même un nombre de trente-deux bits, et tient donc dans un registre, ce qui est exactement ce qui rend les tableaux et les chaînes possibles.
Charger l'adresse, puis lire à travers
Le geste central de ce chapitre se fait en deux temps, et jamais en un seul.
programme
- 0x0la t0, mesurelui t0, 1
- 0x4addi zero, zero, 0
- 0x8lw t1, 0(t0)
- 0xcaddi t1, t1, 1
- 0x10sw t1, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 |
mémoire
| 0x1000 | mesure | 42 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
La ligne données: mesure = 42 pose un mot de quatre octets en mémoire et lui donne un nom. Ce nom n'existe que pour qui écrit le programme : à l'exécution, il ne reste qu'une adresse, et la figure l'affiche, 0x1000.
la t0, mesure charge l'adresse de mesure dans t0. Rien n'est lu en mémoire à ce stade : t0 reçoit un numéro, 4096 en décimal, 0x1000 en hexadécimal. La colonne des instructions réelles montre d'ailleurs que la est une pseudo-instruction, remplacée ici par lui t0, 1, qui pose la valeur 4096 sans toucher à la mémoire.
lw t1, 0(t0) charge enfin le mot. L'écriture 0(t0) se lit « à l'adresse contenue dans t0, plus zéro ». Le nombre devant les parenthèses est un décalage, ajouté à l'adresse au moment de l'accès. C'est lui qui permettra plus tard de parcourir un tableau sans recalculer l'adresse à chaque élément.
sw t1, 0(t0) fait le trajet inverse. L'ordre des opérandes surprend : le registre nommé en premier est ici la source, et la destination est la case mémoire. C'est la seule famille d'instructions où le premier nom n'est pas celui qui reçoit.
Cette écriture, acceptée par beaucoup d'assembleurs, cache le point que ce chapitre enseigne. La figure la refuse avec un message qui rappelle la marche à suivre : charger d'abord l'adresse avec la, puis lire à travers le registre. La raison est qu'une adresse de trente-deux bits ne tient pas dans une instruction de trente-deux bits ; un assembleur qui accepte lw t1, mesure fabrique en réalité deux instructions dans le dos de celui qui écrit, et l'habitude se paie au premier débogage.
Une adresse s'écrit en hexadécimal
0x1000, 0x3004, 0x7ffe4a20 : les adresses ne s'écrivent jamais en décimal, dans aucune documentation, aucun message d'erreur, aucun débogueur. La raison est typographique avant d'être mathématique.
Un octet vaut de 0 à 255. En décimal, il occupe une, deux ou trois positions, ce qui rend toute colonne illisible. En hexadécimal, chaque chiffre représente exactement quatre bits, donc un octet occupe toujours deux chiffres, de 00 à ff. Une adresse de trente-deux bits, soit quatre octets, en occupe toujours huit. Un vidage de mémoire devient une grille régulière, et le découpage en octets se lit à l'œil.
Les seize chiffres vont de 0 à 9 puis de a à f, où a vaut dix et f quinze. Le préfixe 0x n'appartient pas au nombre : il annonce seulement la base.
programme
- 0x0li t0, 0x2Aaddi t0, zero, 42
- 0x4li t1, 42addi t1, zero, 42
- 0x8li t2, 0b101010addi t2, zero, 42
registres
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Les trois écritures désignent le même nombre, et la machine n'en garde aucune trace : les instructions réelles affichées sont addi t0, zero, 42 trois fois de suite. Une base est une notation pour les humains, pas une propriété d'une valeur. Reste à savoir où les données atterrissent.
programme
- 0x0la t0, alui t0, 1
- 0x4addi zero, zero, 0
- 0x8la t1, blui t1, 1
- 0xcaddi t1, t1, 4
- 0x10la t2, clui t2, 1
- 0x14addi t2, t2, 8
registres
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | a | 7 |
| 0x1004 | b | 8 |
| 0x1008 | c | 9 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Les trois déclarations se suivent à partir de 0x1000, chacune occupant quatre octets, d'où un écart de quatre entre deux adresses voisines. La figure affiche 4096, 4100 et 4104 dans les registres et les mêmes valeurs en hexadécimal dans la zone mémoire : un registre contient un nombre, et l'hexadécimal n'est que la façon dont toute documentation l'écrit.
Quatre octets, et dans quel ordre
Un mot de trente-deux bits occupe quatre octets consécutifs. Reste une question que rien n'impose : lequel des quatre est posé en premier. Les deux réponses existent dans l'industrie, et RISC-V, comme x86, choisit de commencer par l'octet de poids faible. Cette convention s'appelle le petit-boutisme.
programme
- 0x0la t0, nlui t0, 1
- 0x4addi zero, zero, 0
- 0x8lb t1, 0(t0)
- 0xclb t2, 1(t0)
- 0x10lb t3, 2(t0)
- 0x14lb t4, 3(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| t3 | 0 | |
| t4 | 0 |
mémoire
| 0x1000 | n | 16909060 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Le mot écrit 0x01020304 se lit comme n'importe quel nombre : 01 est son octet de poids fort, 04 son octet de poids faible. En mémoire, l'ordre est inversé, l'octet 04 occupant l'adresse 0x1000 et l'octet 01 l'adresse 0x1003. La figure le montre sans commentaire possible : quatre valeurs, 4 puis 3 puis 2 puis 1. Ce détail devient central dès qu'un programme lit autre chose que sa propre mémoire.
programme
- 0x0la t0, tramelui t0, 1
- 0x4addi zero, zero, 0
- 0x8lbu t1, 0(t0)
- 0xclbu t2, 1(t0)
- 0x10lhu t3, 0(t0)
- 0x14lw t4, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| t3 | 0 | |
| t4 | 0 |
mémoire
| 0x1000 | trame | 168496141 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Trois accès à la même adresse 0x1000, trois résultats différents, parce que trois largeurs différentes. L'octet vaut 13, soit 0x0d. Les deux octets valent 3085, soit 0x0c0d : l'octet d'adresse 0x1001, celui qui vaut 0c, est devenu le poids fort. Le mot entier vaut 168 496 141, soit 0x0a0b0c0d.
C'est très exactement le problème que rencontre quiconque lit une trame réseau. Les protocoles de l'Internet rangent leurs champs dans l'ordre inverse, l'octet de poids fort en premier, et une documentation de protocole précise toujours lequel des deux ordres elle emploie. Un champ de deux octets lu naïvement dans un mot reçu du réseau donne une valeur qui n'a aucun sens, et ce genre d'erreur ne se voit pas : le programme ne plante pas, il calcule faux.
Tant qu'un programme écrit un mot et le relit en entier, la convention est invisible : elle s'annule. Elle apparaît dès qu'un accès étroit se pose sur une donnée large, ce qui est précisément ce que fait tout code qui décode un format, une trame ou un fichier binaire. Lire un mot en quatre lb est le geste qui révèle la convention, et le seul.
Un octet signé, un octet non signé
Un octet chargé dans un registre doit remplir trente-deux bits. Il reste vingt-quatre bits à décider, et le jeu d'instructions ne tranche pas : il propose les deux réponses.
programme
- 0x0la t0, octetlui t0, 1
- 0x4addi zero, zero, 0
- 0x8lb t1, 0(t0)
- 0xclbu t2, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | octet | 240 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
lb traite l'octet comme un nombre signé : son bit de poids fort étant à un, il recopie ce bit dans les vingt-quatre positions restantes, et le registre contient -16. lbu traite le même octet comme un nombre non signé : il complète par des zéros, et le registre contient 240. Le motif de bits lu en mémoire est identique dans les deux cas ; seule l'interprétation change, et elle est choisie par le nom de l'instruction.
La même paire existe pour deux octets.
programme
- 0x0la t0, demilui t0, 1
- 0x4addi zero, zero, 0
- 0x8lh t1, 0(t0)
- 0xclhu t2, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | demi | 65486 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Le choix se fait sur la nature de la donnée. Un relevé de température qui peut descendre sous zéro se lit avec lb ; un compteur, un code, un octet de trame se lisent avec lbu. Se tromper donne une valeur juste dans la moitié des cas, ce qui est la pire des situations : le défaut n'apparaît que sur les données dont le bit de poids fort est à un.
Écrire moins qu'un mot
Le rangement suit la même logique, en plus simple : il n'y a rien à interpréter, seulement une largeur à choisir. sw écrit quatre octets, sh deux, sb un seul, et l'écriture ne touche que les octets concernés.
programme
- 0x0la t0, nlui t0, 1
- 0x4addi zero, zero, 0
- 0x8li t1, 0xFFaddi t1, zero, 255
- 0xcsb t1, 1(t0)
- 0x10lw t2, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | n | 16909060 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
La valeur de départ vaut 0x01020304, la valeur finale 16 973 572, soit 0x0102ff04. Le deuxième octet en partant de la droite a changé, passant de 03 à ff, et les trois autres sont intacts. C'est ce que fait tout pilote qui modifie un champ d'un registre matériel sans perturber les champs voisins, et c'est le sujet des deux chapitres qui suivent.
L'alignement, et l'erreur qui le signale
Une dernière contrainte encadre les accès larges : l'adresse d'un mot doit être un multiple de quatre, celle d'un demi-mot un multiple de deux. La démonstration la plus claire consiste à violer la règle.
programme
- 0x0la t0, nlui t0, 1
- 0x4addi zero, zero, 0
- 0x8addi t0, t0, 1
- 0xclw t1, 0(t0)
registres
| t0 | 0 | |
| t1 | 0 |
mémoire
| 0x1000 | n | 16909060 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
L'adresse 0x1000 était valable, 0x1001 ne l'est plus. La cause tient au matériel : la mémoire est câblée par blocs de quatre octets, et un mot à cheval sur deux blocs demanderait deux accès au lieu d'un. Plutôt que de payer ce coût en silence, l'architecture refuse.
La ligne attendu: erreur en tête du programme sert à dire que cet échec est voulu. Le contrôle vaut dans les deux sens : un programme déclaré fautif qui s'exécuterait normalement serait signalé lui aussi, de sorte qu'une démonstration d'erreur ne peut pas cesser silencieusement de démontrer.
Personne n'écrit une adresse impaire à la main. Elle apparaît quand un programme avance dans une zone d'octets et tente d'y lire un mot, ou quand un décalage exprimé en octets est ajouté là où un décalage exprimé en éléments était attendu. Le symptôme est brutal et immédiat, ce qui est une chance : il désigne l'instruction fautive au lieu de laisser un résultat faux se propager.
Ce que coûte un aller-retour
Reste à voir pourquoi les registres existent, plutôt que de tout garder en mémoire.
programme
- 0x0la t0, mesureslui t0, 1
- 0x4addi zero, zero, 0
- 0x8lw t1, 0(t0)
- 0xclw t2, 4(t0)
- 0x10add t1, t1, t2
- 0x14lw t2, 8(t0)
- 0x18add t1, t1, t2
- 0x1clw t2, 12(t0)
- 0x20add t1, t1, t2
- 0x24la t3, totallui t3, 1
- 0x28addi t3, t3, 256
- 0x2csw t1, 0(t3)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| t3 | 0 |
mémoire
| 0x1000 | mesures | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 | |
| 0x100c | 20 | |
| 0x1100 | total | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Quatre valeurs additionnées, et deux registres ont suffi. t1 accumule, t2 sert de case de passage et se fait écraser à chaque tour : ses valeurs successives sont 5, puis -3, puis 20. Une seule adresse a été chargée, 0x1000, et les quatre éléments ont été atteints par les décalages 0, 4, 8 et 12. Ce schéma est le point de départ du parcours de tableau, au chapitre 7.
La déclaration données: total @ 0x1100 = 0 montre l'autre possibilité : une adresse imposée par qui écrit, au lieu d'un placement automatique à la suite des précédentes. C'est ce que fait un système d'exploitation quand il décide où poser les zones d'un programme, et c'est exactement le mécanisme qui, au chapitre suivant, placera une lampe à une adresse choisie.
Le compte des instructions mérite un regard. Trois additions ont demandé quatre chargements, et sur une machine réelle un accès qui rate les mémoires intermédiaires coûte des centaines de fois le prix d'une addition. Tout l'art d'un compilateur consiste à garder dans les trente-deux registres ce dont le calcul a besoin, et à n'écrire en mémoire qu'au dernier moment ; quand il n'y parvient pas, il déverse des valeurs temporaires en mémoire, et cela se voit dans le code produit.
1.Que contient t0 après la t0, mesure ?
2.Dans sw t1, 0(t0), quel est le rôle de t1 ?
3.Un mot vaut 0x01020304. Que lit lb à la première de ses quatre adresses ?
4.Le même octet est lu par lb puis par lbu, à la même adresse. Pourquoi les deux registres diffèrent-ils ?
5.Pourquoi lw refuse-t-il l'adresse 0x1001 ?
6.Pourquoi un compilateur cherche-t-il à garder les valeurs dans les registres ?
7.Combien de chiffres hexadécimaux faut-il pour écrire une adresse de trente-deux bits ?
La méthode
- Poser la donnée avec une ligne
données:, en lui donnant un nom, et laisser la figure afficher l'adresse obtenue plutôt que de la supposer. - Charger l'adresse d'abord, avec
la, dans un registre qui lui sera réservé, et ne plus le modifier tant que la donnée sert. - Lire à travers ce registre avec
0(registre), puis employer le décalage pour atteindre les éléments voisins sans recalculer l'adresse. - Choisir la largeur avant de choisir le signe : d'abord
w,houbselon la taille réelle de la donnée, puis la variante non signée pour un code ou un octet de trame, et la variante signée pour une mesure qui peut être négative. - Vérifier l'ordre des octets dès qu'un accès étroit se pose sur une donnée large, en lisant les octets un par un et en comparant à l'écriture hexadécimale affichée.
- Contrôler l'alignement avant tout
lwdont l'adresse a été calculée, et lire le message d'erreur jusqu'au bout : il donne l'adresse fautive, donc le calcul à reprendre. - Compter les accès mémoire d'un morceau de code, et chercher si une valeur relue plusieurs fois ne pourrait pas rester dans un registre.
Synthèse
- La mémoire est un tableau d'octets numérotés, et une adresse est ce numéro. Le processeur ne calcule que sur les registres : la mémoire ne se lit et ne s'écrit que par des instructions de transport,
lwetswen tête. - Le geste enseigné tient en deux temps :
lapour charger l'adresse, puis0(registre)pour lire ou écrire à travers elle. La formelw t1, mesureest refusée, parce qu'elle masque ce qui coûte. - Une adresse s'écrit en hexadécimal, où un chiffre vaut quatre bits : un octet occupe toujours deux chiffres, une adresse de trente-deux bits toujours huit. La base est une notation, jamais une propriété de la valeur.
- Les largeurs d'accès se choisissent :
lwpour quatre octets,lhpour deux,lbpour un. Les varianteslbuetlhucomplètent par des zéros,lbetlhrecopient le bit de poids fort ; sur0x000000F0, l'écart va de -16 à 240. - RISC-V est petit-boutiste : l'octet de poids faible occupe l'adresse la plus basse. Un mot
0x01020304se relit en quatrelbcomme 4, 3, 2 et 1, et c'est ce que rencontre toute lecture de trame ou de format binaire. - Un mot se lit à une adresse multiple de quatre, faute de quoi l'exécution s'arrête avec un message qui nomme l'adresse fautive. La contrainte vient du câblage de la mémoire, pas d'un choix de langage.
- Trente-deux registres imposent l'aller-retour : les valeurs descendent en mémoire dès qu'elles ne servent plus, et remontent quand elles resservent. Chaque aller-retour coûte, et c'est ce que cherche à éviter tout code rapide.
Reste une question laissée de côté : à quoi sert d'écrire dans une case mémoire dont personne ne relira jamais le contenu. Agir sur le monde montre que certaines cases sont surveillées par du matériel, et qu'un sw bien placé allume une lampe.