Aller au contenu principal

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.

Les dix instructions employées ici

Ce chapitre n'introduit aucune instruction nouvelle : les dix qu'il emploie ont toutes été rencontrées avant. Elles reviennent en revanche toutes les trois ou quatre lignes, et un lecteur qui les a croisées il y a quelques jours a le droit de ne plus se souvenir de leur forme exacte. Les voici rassemblées une fois pour toutes, à garder sous les yeux le temps du chapitre.

InstructionCe qu'elle fait
la t0, notesmet dans t0 l'adresse de la donnée déclarée notes, et rien d'autre
lw t1, 0(t0)lit les quatre octets rangés à l'adresse contenue dans t0
sw t1, 0(t0)écrit les quatre octets de t1 à l'adresse contenue dans t0
lb t2, 0(t0)lit un seul octet à cette adresse, au lieu de quatre
li t1, 5met la constante 5 dans t1
add t1, t0, t1range dans t1 la somme de t0 et de t1
addi t0, t0, 4ajoute la constante 4 à t0, sans toucher à la mémoire
slli t1, a0, 2décale a0 de deux bits vers la gauche, ce qui multiplie par quatre
beqz t1, finsaute à l'étiquette fin si t1 vaut zéro
j bouclesaute à l'étiquette boucle, sans condition

Le détail de chaque famille, avec les variantes que ce chapitre n'emploie pas, se trouve dans le mémento du jeu d'instructions.

Un mot enfin sur les figures, qui ne sont pas seulement à regarder tourner. Le bouton « Modifier le programme » ouvre le texte du programme affiché : chaque frappe le réassemble et le relance, et un autre bouton ramène à la version d'origine. Les consignes qui demandent de changer une valeur et de relancer se suivent donc sans quitter la page, et une instruction dont le rôle reste flou se comprend souvent mieux en la supprimant pour voir ce qui manque ensuite.

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.

RISC-V RV32Ipas 0 sur 5pc = 0x0

programme

  1. 0x0la t0, noteslui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8lw t1, 0(t0)
  4. 0xcaddi t0, t0, 4
  5. 0x10lw t2, 0(t0)

registres

sp0x2000
t00
t10
t20

mémoire

0x1000notes12
0x10045
0x1008-3
0x100c40

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

Exécuter pas à pas en surveillant t0 et la zone de mémoire affichée sous les registres. Relever les deux adresses lues, signalées par la figure à droite de l'instruction, et les deux valeurs obtenues.

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.

`lw t1, notes` est refusé, et c'est volontaire

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.

RISC-V RV32Ipas 0 sur 5pc = 0x0

programme

  1. 0x0la t0, noteslui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8slli t1, a0, 2
  4. 0xcadd t1, t0, t1
  5. 0x10lw t2, 0(t1)

registres

sp0x2000
t00
t10
t20
a02

mémoire

0x1000notes12
0x10045
0x1008-3
0x100c40

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

Suivre t1 sur les trois derniers pas : il vaut d'abord l'indice multiplié par quatre, puis l'adresse complète, et sert enfin de point de lecture. Modifier a0 et relancer pour atteindre un autre élément.

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.

RISC-V RV32Ipas 0 sur 35pc = 0x0

programme

  1. 0x0la t0, noteslui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8li t1, 5addi t1, zero, 5
  4. 0xcli t2, 0addi t2, zero, 0
  5. 0x10beqz t1, finbeq t1, zero, fin
  6. 0x14lw t3, 0(t0)
  7. 0x18add t2, t2, t3
  8. 0x1caddi t0, t0, 4
  9. 0x20addi t1, t1, -1
  10. 0x24j bouclejal zero, boucle

registres

sp0x2000
t00
t10
t20
t30

mémoire

0x1000notes12
0x10045
0x1008-3
0x100c40
0x10107

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

Exécuter jusqu'à l'arrêt en suivant les adresses lues, qui avancent de quatre en quatre à partir de 0x1000. Relever le nombre de pas et la valeur finale de t2.

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.

RISC-V RV32Ipas 0 sur 32pc = 0x0

programme

  1. 0x0la t0, noteslui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8li t1, 4addi t1, zero, 4
  4. 0xcbeqz t1, finbeq t1, zero, fin
  5. 0x10lw t2, 0(t0)
  6. 0x14add t2, t2, t2
  7. 0x18sw t2, 0(t0)
  8. 0x1caddi t0, t0, 4
  9. 0x20addi t1, t1, -1
  10. 0x24j bouclejal zero, boucle

registres

sp0x2000
t00
t10
t20

mémoire

0x1000notes12
0x10045
0x1008-3
0x100c40

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

Exécuter en gardant la zone de mémoire à l'œil : chaque tour lit une case, puis y réécrit le double. Comparer l'état de la mémoire au départ et à l'arrivée.

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.

RISC-V RV32Ipas 0 sur 55pc = 0x0

programme

  1. 0x0la t0, messagelui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8li t1, 0addi t1, zero, 0
  4. 0xclb t2, 0(t0)
  5. 0x10beqz t2, finbeq t2, zero, fin
  6. 0x14addi t1, t1, 1
  7. 0x18addi t0, t0, 1
  8. 0x1cj bouclejal zero, boucle

registres

sp0x2000
t00
t10
t20

mémoire

0x1000message1702064993
0x10041701601901
0x100829301

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

Avancer pas à pas et lire les valeurs successives de t2 : ce sont les codes des caractères. Poursuivre jusqu'à l'octet nul, et relever la valeur finale de t1.

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.

RISC-V RV32Ipas 0 sur 5pc = 0x0

programme

  1. 0x0la t0, messagelui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8lb t1, 0(t0)
  4. 0xclb t2, 3(t0)
  5. 0x10lw t3, 0(t0)

registres

sp0x2000
t00
t10
t20
t30

mémoire

0x1000message1145258561
0x10040

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

Comparer les trois valeurs obtenues à partir de la même adresse de base. Noter le décalage 3 de la deuxième lecture, qui atteint le quatrième octet sans déplacer t0.

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.

RISC-V RV32Ipas 0 sur 5pc = 0x0

programme

  1. 0x0la t0, noteslui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8lw t1, 8(t0)
  4. 0xclw t2, 12(t0)
  5. 0x10lw t3, 16(t0)

registres

sp0x2000
t00
t10
t20
t30

mémoire

0x1000notes12
0x10045
0x1008-3
0x100cseuil777

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

Regarder les trois adresses lues et la zone de données affichée par la figure. Identifier à quelle déclaration appartient chacune des trois cases atteintes.

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.

La mémoire n'a pas de bord

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.

RISC-V RV32Ipas 0 sur 2pc = 0x0

programme

  1. 0x0la t0, noteslui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8lw t1, 1(t0)

registres

t00
t10

mémoire

0x1000notes12
0x10045
0x1008-3

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

Lancer l'exécution : elle s'arrête sur un message qui nomme l'adresse fautive. Remplacer ensuite le décalage 1 par 4 pour retrouver un programme qui se termine.

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.

RISC-V RV32Ipas 0 sur 7pc = 0x0

programme

  1. 0x0la t0, grillelui t0, 1
  2. 0x4addi zero, zero, 0
  3. 0x8slli t1, a0, 2
  4. 0xcadd t1, t1, a1
  5. 0x10slli t1, t1, 2
  6. 0x14add t1, t0, t1
  7. 0x18lw t2, 0(t1)

registres

sp0x2000
t00
t10
t20
a02
a11

mémoire

0x1000grille10
0x100411
0x100812
0x100c13
0x101020
0x101421
0x101822
0x101c23
0x102030
0x102431
0x102832
0x102c33

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

Suivre t1 pas à pas, qui prend successivement quatre valeurs avant de servir de point de lecture. Dire à quoi correspond chacune, puis modifier a0 et a1 pour atteindre une autre case.

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.

Vérification rapideon peut se reprendre

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

  1. Charger l'adresse de base dans un registre avec la, et considérer ce registre comme un point de lecture, jamais comme une valeur.
  2. É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.
  3. 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.
  4. 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é.
  5. 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.
  6. 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 avec lw. 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'écrit slli de 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 par lw donnent 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.