Tableaux et adresses
Ce que ce chapitre apporte
- Charger l'adresse d'une donnée dans un registre avec la, puis lire la case visée avec lw.
- Calculer l'adresse d'un élément à partir de l'adresse de base, de l'indice et de la taille d'un élément.
- Parcourir un tableau de mots en déplaçant un registre d'adresse de quatre en quatre.
- Parcourir une chaîne d'octets terminée par un zéro, en déplaçant l'adresse de un en un.
- Expliquer pourquoi lire au-delà de la fin d'un tableau ne provoque aucune erreur, et ce que la lecture rapporte alors.
En Python, t[3] désigne le quatrième élément d'une liste, et la question de savoir où il se trouve ne se pose jamais. Le processeur, lui, ne dispose d'aucune notion de tableau, d'élément ou d'indice. Il a une mémoire, qui est une longue suite d'octets numérotés, et des instructions qui lisent quatre octets à une adresse. Un tableau n'est donc rien de plus qu'une convention : des valeurs de même taille rangées les unes derrière les autres, et une adresse qui désigne la première. Tout le reste, y compris l'indice, est une addition. Ce chapitre installe ce calcul d'adresse et montre ce qui arrive quand il sort du tableau, ce qui est précisément la différence entre un langage qui protège et un langage qui obéit.
Charger une adresse, puis lire à travers elle
Une déclaration données: pose des valeurs en mémoire et donne un nom à l'adresse de la première. Ce nom n'est pas une variable : c'est une adresse, et elle doit passer par un registre avant que quoi que ce soit ne soit lu. Le geste tient en deux temps, la puis lw, et il ne changera plus de tout le module.
programme
- 0x0la t0, noteslui t0, 1
- 0x4addi zero, zero, 0
- 0x8lw t1, 0(t0)
- 0xcaddi t0, t0, 4
- 0x10lw t2, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | notes | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 | |
| 0x100c | 40 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Cinq pas, t1 à 12 et t2 à 5. La figure signale les deux accès, à l'adresse 0x1000 puis à l'adresse 0x1004 : quatre octets d'écart pour un élément d'écart. Entre les deux, addi t0, t0, 4 a fait passer t0 de 4096 à 4100, c'est-à-dire de 0x1000 à 0x1004. Le registre t0 ne contient pas une note : il contient l'endroit où en chercher une.
La colonne des instructions réelles mérite un regard. La ligne la t0, notes y donne lui t0, 1, suivie d'un addi zero, zero, 0 qui ne fait rien : l'assembleur réserve deux instructions pour charger une adresse, parce qu'une adresse ne tient pas toujours sur douze bits, et comble avec une instruction neutre quand une seule suffit.
Le moteur de ce module rejette l'écriture qui mélange le nom et la lecture, alors qu'un assembleur professionnel l'accepterait en la développant en deux instructions. Le refus force le geste juste : une adresse se charge dans un registre, puis se lit à travers lui. C'est ce geste, et non la commodité d'écriture, qui rend lisible tout ce qui suit, du parcours de tableau jusqu'à la pile.
L'indice n'existe pas, il se calcule
L'accès t[i] d'un langage de haut niveau se décompose en quatre choses, dont aucune n'est cachée ici : une adresse de base, un indice, une taille d'élément, et une addition. L'adresse cherchée vaut base plus indice multiplié par la taille de l'élément. Pour des mots de quatre octets, multiplier par quatre revient à décaler de deux bits vers la gauche, ce que slli fait en une instruction, faute de multiplication dans RV32I.
programme
- 0x0la t0, noteslui t0, 1
- 0x4addi zero, zero, 0
- 0x8slli t1, a0, 2
- 0xcadd t1, t0, t1
- 0x10lw t2, 0(t1)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| a0 | 2 |
mémoire
| 0x1000 | notes | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 | |
| 0x100c | 40 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Le registre t1 vaut 8 après le décalage, 4104 après l'addition, et la lecture rapporte -3, le troisième élément. Les trois instructions du milieu sont exactement ce qu'un compilateur produit pour t[i], et elles expliquent d'un coup plusieurs mystères : pourquoi un tableau est indexé à partir de zéro, puisque l'élément zéro est celui qui se trouve à l'adresse de base plus rien ; pourquoi la taille d'élément doit être connue à la compilation ; et pourquoi l'accès à un élément quelconque coûte le même temps quel que soit l'indice.
Parcourir plutôt que recalculer
Rien n'oblige à repartir de la base à chaque tour. Un parcours complet se fait plus simplement en déplaçant le registre d'adresse, une boucle du chapitre précédent tenant le compte des éléments restants.
programme
- 0x0la t0, noteslui t0, 1
- 0x4addi zero, zero, 0
- 0x8li t1, 5addi t1, zero, 5
- 0xcli t2, 0addi t2, zero, 0
- 0x10beqz t1, finbeq t1, zero, fin
- 0x14lw t3, 0(t0)
- 0x18add t2, t2, t3
- 0x1caddi t0, t0, 4
- 0x20addi t1, t1, -1
- 0x24j bouclejal zero, boucle
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| t3 | 0 |
mémoire
| 0x1000 | notes | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 | |
| 0x100c | 40 | |
| 0x1010 | 7 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Trente-cinq pas, t2 à 61, et cinq lectures aux adresses 0x1000, 0x1004, 0x1008, 0x100c et 0x1010. Deux registres suffisent à tenir le parcours : t0 dit où lire, t1 dit combien de fois encore. Le registre t0 finit à 4116, une case au-delà du dernier élément, ce qui est normal et mérite d'être noté : après un parcours, le pointeur désigne la fin, pas le dernier élément.
Écrire dans le tableau se fait avec la même adresse, et l'instruction symétrique.
programme
- 0x0la t0, noteslui t0, 1
- 0x4addi zero, zero, 0
- 0x8li t1, 4addi t1, zero, 4
- 0xcbeqz t1, finbeq t1, zero, fin
- 0x10lw t2, 0(t0)
- 0x14add t2, t2, t2
- 0x18sw t2, 0(t0)
- 0x1caddi t0, t0, 4
- 0x20addi t1, t1, -1
- 0x24j bouclejal zero, boucle
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | notes | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 | |
| 0x100c | 40 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Trente-deux pas, et le tableau contient à la fin 24, 10, -6 et 80. Chaque tour lit, calcule, range au même endroit. La figure signale les écritures avec la valeur précédente, ce qui rend visible le fait qu'un tableau modifié sur place perd ses valeurs d'origine sans laisser de trace.
Une chaîne est un tableau d'octets
Un texte suit exactement la même convention, avec deux différences. La taille d'un élément vaut un octet et non quatre, donc l'adresse avance de un en un. Et la longueur n'est écrite nulle part : la fin est signalée par un octet nul posé après le dernier caractère. Le parcours ne compte donc pas, il cherche.
programme
- 0x0la t0, messagelui t0, 1
- 0x4addi zero, zero, 0
- 0x8li t1, 0addi t1, zero, 0
- 0xclb t2, 0(t0)
- 0x10beqz t2, finbeq t2, zero, fin
- 0x14addi t1, t1, 1
- 0x18addi t0, t0, 1
- 0x1cj bouclejal zero, boucle
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 |
mémoire
| 0x1000 | message | 1702064993 |
| 0x1004 | 1701601901 | |
| 0x1008 | 29301 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Cinquante-cinq pas, et t1 finit à 10, la longueur du mot. Les valeurs prises par t2 sont 97, 115, 115, 101, 109, 98, 108, 101, 117, 114, puis 0 : les codes des lettres, lus un octet à la fois par lb, et le zéro terminal trouvé à l'adresse 0x100a. Le décalage est de 1 à chaque tour, jamais de 4, parce que la taille d'un élément est ici d'un octet.
La taille d'élément n'est donc pas une propriété de la mémoire, c'est un choix de lecture. Rien n'empêche de lire quatre octets d'un texte d'un seul coup.
programme
- 0x0la t0, messagelui t0, 1
- 0x4addi zero, zero, 0
- 0x8lb t1, 0(t0)
- 0xclb t2, 3(t0)
- 0x10lw t3, 0(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| t3 | 0 |
mémoire
| 0x1000 | message | 1145258561 |
| 0x1004 | 0 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Les deux lectures d'octet donnent 65 et 68, les codes de A et de D. La lecture de mot, à la même adresse de base, donne 1 145 258 561. Ce nombre n'a aucun sens en tant que nombre, et il est pourtant la lecture exacte des mêmes quatre octets, assemblés dans l'ordre où le processeur les range. Les octets ne changent pas : c'est l'instruction qui décide de les voir comme un caractère ou comme un nombre de trente-deux bits.
Noter au passage le décalage écrit dans l'instruction, 3(t0), qui évite de déplacer le registre. Les deux écritures sont équivalentes, et la première est préférable quand l'adresse de base doit être conservée.
Sortir du tableau ne provoque rien
Voici la différence la plus nette entre ce module et un langage de haut niveau. En Python, lire t[7] dans une liste de trois éléments lève une exception qui arrête tout. Ici, il ne se passe rien du tout.
programme
- 0x0la t0, noteslui t0, 1
- 0x4addi zero, zero, 0
- 0x8lw t1, 8(t0)
- 0xclw t2, 12(t0)
- 0x10lw t3, 16(t0)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| t3 | 0 |
mémoire
| 0x1000 | notes | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 | |
| 0x100c | seuil | 777 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Le tableau notes occupe douze octets, de 0x1000 à 0x100b. La première lecture, au décalage 8, atteint son dernier élément et donne -3. La deuxième, au décalage 12, sort du tableau et donne 777 : c'est la valeur de seuil, déclarée juste après et posée à l'adresse 0x100c. La troisième, au décalage 16, atteint une case où rien n'a jamais été écrit et donne 0. Aucune des trois n'est signalée comme une faute, et le programme se termine normalement.
Un tableau n'existe pas pour le processeur, donc sa fin n'existe pas davantage. Lire au-delà rapporte ce qui se trouve à côté : une autre donnée, un reste d'exécution précédente, ou des octets jamais écrits. Le programme continue avec une valeur fausse, et la faute se manifeste bien plus loin, sous une forme qui n'a plus aucun rapport avec sa cause. C'est le mécanisme de fond des vulnérabilités de lecture hors limites, et l'explication de pourquoi les langages qui vérifient l'indice acceptent d'en payer le prix.
Une erreur est bien signalée, mais elle porte sur autre chose : l'alignement. Un mot de quatre octets se lit à une adresse multiple de quatre, et le moteur refuse toute autre adresse.
programme
- 0x0la t0, noteslui t0, 1
- 0x4addi zero, zero, 0
- 0x8lw t1, 1(t0)
registres
| t0 | 0 | |
| t1 | 0 |
mémoire
| 0x1000 | notes | 12 |
| 0x1004 | 5 | |
| 0x1008 | -3 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
L'exécution s'interrompt avec le message qui désigne 0x1001 comme non multiple de quatre. La contrainte vient du matériel, qui va chercher la mémoire par blocs alignés, et elle explique une part des règles de disposition que les langages système imposent aux structures de données. Il est donc parfaitement possible de sortir d'un tableau sans être inquiété, et parfaitement impossible de lire un mot une case trop loin dans le mauvais sens.
Deux dimensions dans une seule suite de cases
Une grille n'a pas d'existence propre non plus. Elle est rangée ligne après ligne dans la même suite de cases, et l'élément de coordonnées ligne et colonne occupe l'indice ligne × largeur + colonne. La formule ajoute une multiplication à celle qui convertit l'indice en octets.
programme
- 0x0la t0, grillelui t0, 1
- 0x4addi zero, zero, 0
- 0x8slli t1, a0, 2
- 0xcadd t1, t1, a1
- 0x10slli t1, t1, 2
- 0x14add t1, t0, t1
- 0x18lw t2, 0(t1)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| t2 | 0 | |
| a0 | 2 | |
| a1 | 1 |
mémoire
| 0x1000 | grille | 10 |
| 0x1004 | 11 | |
| 0x1008 | 12 | |
| 0x100c | 13 | |
| 0x1010 | 20 | |
| 0x1014 | 21 | |
| 0x1018 | 22 | |
| 0x101c | 23 | |
| 0x1020 | 30 | |
| 0x1024 | 31 | |
| 0x1028 | 32 | |
| 0x102c | 33 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Sept pas, et t2 reçoit 31. Le registre t1 passe par 8, la ligne multipliée par la largeur, puis 9, l'indice dans la suite, puis 36, le même indice converti en octets, puis 4132, soit 0x1024, l'adresse finale. Les deux slli valent tous deux « multiplier par quatre », mais pour des raisons distinctes : le premier parce que la grille a quatre colonnes, le second parce qu'un mot occupe quatre octets. Les deux coïncident ici, et il serait imprudent d'en tirer une règle. Une grille de trois colonnes demanderait une multiplication par trois, donc des additions répétées, puisque RV32I ne multiplie pas.
1.Que contient un registre après la t0, notes ?
2.Quelle est l'adresse de l'élément d'indice 3 d'un tableau de mots dont la base vaut 0x1000 ?
3.Pourquoi un parcours de chaîne avance-t-il de un en un, alors qu'un parcours de tableau de mots avance de quatre en quatre ?
4.Comment un programme sait-il où se termine une chaîne ?
5.Un programme lit un mot situé huit octets après la fin d'un tableau. Que se passe-t-il ?
6.Dans une grille de quatre colonnes, quel indice occupe la case de la ligne 2 et de la colonne 1 ?
La méthode
- Charger l'adresse de base dans un registre avec
la, et considérer ce registre comme un point de lecture, jamais comme une valeur. - Écrire l'adresse cherchée sous forme de somme avant de coder : base plus indice multiplié par la taille d'un élément. Les trois quantités doivent être nommées, y compris la taille.
- Convertir l'indice en octets par un décalage à gauche de deux pour des mots, de zéro pour des octets, et par des additions répétées pour toute autre taille.
- Choisir entre déplacer le pointeur et recalculer l'adresse : le déplacement convient à un parcours complet, le recalcul à un accès isolé ou désordonné.
- Décider la condition d'arrêt avec le tableau lui-même : un compteur d'éléments pour une suite de mots, l'octet nul pour une chaîne. Un parcours sans condition d'arrêt fiable sort du tableau sans prévenir.
- Relire les adresses affichées par la figure plutôt que les valeurs obtenues : une valeur plausible lue à la mauvaise adresse est le défaut le plus coûteux du chapitre.
Synthèse
- Une adresse se charge dans un registre avec
la, puis la case visée se lit aveclw. Le nom d'une donnée n'est pas une variable, c'est une adresse, et elle ne se lit jamais directement. t[i]n'existe pas : l'adresse d'un élément vaut base plus indice multiplié par la taille d'un élément. Pour des mots de quatre octets, la multiplication s'écritsllide deux bits.- Un parcours déplace le registre d'adresse de quatre en quatre pour des mots, sans repartir de la base. À la sortie, le registre désigne la case qui suit le dernier élément.
- Une chaîne est un tableau d'octets : la taille d'élément vaut un, le décalage se fait de un en un avec
lb, et la fin est marquée par un octet nul. Les mêmes quatre octets lus parlwdonnent un nombre, ici 1 145 258 561, sans que rien n'ait changé en mémoire. - Sortir d'un tableau ne provoque aucune erreur : la lecture rapporte la donnée voisine, ici 777, ou zéro si rien n'a été écrit. C'est toute la différence avec un langage qui vérifie l'indice.
- Une erreur est en revanche signalée sur l'alignement : un mot se lit à une adresse multiple de quatre, et 0x1001 est refusée.
- Une grille se range ligne après ligne, et l'indice de la case vaut ligne multipliée par la largeur, plus la colonne, avant d'être converti en octets.
Les tableaux traités ici ont une adresse connue avant l'exécution. La pile ouvre la zone de mémoire dont les adresses se décident pendant l'exécution, celle qui sert à ranger ce qu'un calcul doit mettre de côté, et qui descend au lieu de monter.