Aller au contenu principal

Répéter

Ce que ce chapitre apporte

  • Construire une boucle à partir d'un seul branchement dont la destination est placée plus haut dans le programme.
  • Distinguer la boucle dont la condition est testée avant le corps de celle dont elle est testée après, et prévoir laquelle s'exécute au moins une fois.
  • Compter le nombre de tours d'une boucle et repérer le décalage d'une unité sur la borne.
  • Diagnostiquer une boucle qui ne se termine pas en examinant la variation de la condition d'arrêt.
  • Écrire une multiplication par additions répétées, faute d'instruction de multiplication dans RV32I.

Il n'existe aucune instruction pour en RISC-V, et aucune instruction tant que. Le processeur ne connaît pas la boucle : il connaît une destination. Un branchement qui vise une étiquette placée plus haut dans le programme ramène le compteur ordinal en arrière, et les instructions déjà exécutées le sont à nouveau. C'est tout, et c'est suffisant. Une boucle n'est rien d'autre qu'un si dont le saut remonte, et toutes les questions qui font trébucher sur les boucles, le nombre de tours, la borne atteinte ou non, le programme qui ne s'arrête pas, deviennent des questions sur une seule instruction : celle qui décide de revenir.

Le saut en arrière

Les étiquettes du chapitre précédent servaient toutes à enjamber du code. Rien n'oblige une étiquette à être placée en aval : posée avant le branchement qui la vise, elle fait revenir le compteur ordinal.

RISC-V RV32Ipas 0 sur 6pc = 0x0

programme

  1. 0x0addi a0, a0, -1
  2. 0x4bnez a0, bouclebne a0, zero, boucle

registres

a03

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

Avancer pas à pas en surveillant le compteur ordinal dans l'en-tête : il passe de 0x4 à 0x0, puis recommence. Compter les pas jusqu'à l'arrêt, et relever la valeur de a0 à chaque passage sur l'instruction 0x0.

Six pas au total, pour deux instructions écrites. Le registre a0 prend successivement 2, 1, puis 0, et le branchement de l'adresse 0x4 cesse de sauter au troisième passage, ce qui laisse le compteur ordinal sortir du programme. Trois tours, six pas : la distinction est importante et ne cessera pas de l'être, car un tour coûte ici deux instructions.

Il n'y a pas de boucle, il y a une destination

Le processeur ne fait aucune différence entre un saut vers l'avant et un saut vers l'arrière. Dans les deux cas, il range une adresse dans le compteur ordinal. C'est l'auteur du programme, et lui seul, qui appelle « boucle » un saut en arrière et « condition » un saut en avant. Cette absence de forme imposée explique pourquoi un désassembleur ne retrouve jamais le pour ou le tant que du code d'origine : il ne voit que des sauts, et doit deviner la forme.

Un « tant que » complet

Une boucle utile a besoin de trois choses : une préparation avant d'entrer, un corps, et une condition qui décide de recommencer. La somme des entiers de un à a0 les montre toutes les trois.

RISC-V RV32Ipas 0 sur 23pc = 0x0

programme

  1. 0x0li t0, 0addi t0, zero, 0
  2. 0x4li t1, 1addi t1, zero, 1
  3. 0x8bgt t1, a0, finblt a0, t1, fin
  4. 0xcadd t0, t0, t1
  5. 0x10addi t1, t1, 1
  6. 0x14j bouclejal zero, boucle

registres

t00
t10
a05

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

Exécuter jusqu'au bout, puis revenir en arrière jusqu'au pas 4 et suivre la ligne t0 du tableau des registres : 1, 3, 6, 10, 15. Relever le nombre de pas affiché en fin d'exécution.

Le moteur compte vingt-trois pas et laisse t0 à 15, t1 à 6. La structure se lit sur les adresses : t0 et t1 sont préparés une fois, avant l'étiquette ; la condition de sortie est testée à l'adresse 0x8, en tête de tour ; le corps occupe 0xc et 0x10 ; le saut inconditionnel de 0x14 revient à boucle.

Trois remarques valent d'être faites. La condition est, comme au chapitre précédent, l'inverse de celle qui décrit la boucle : le compteur tourne tant que t1 ne dépasse pas a0, donc l'instruction sort quand t1 dépasse a0. Ensuite, la valeur finale de t1 est 6 et non 5 : le compteur est incrémenté une dernière fois avant que le test ne le refuse, ce qui surprend toujours. Enfin, le corps et l'incrément ne sont séparés par rien de particulier ; c'est l'auteur qui décide qu'addi t1, t1, 1 fait partie de la mécanique et non du travail.

Une instruction de différence, un tour de différence

Le décalage d'une unité sur la borne est la faute la plus banale de la programmation, et la moins visible. En assembleur, elle tient dans une seule lettre.

RISC-V RV32Ipas 0 sur 19pc = 0x0

programme

  1. 0x0li t0, 0addi t0, zero, 0
  2. 0x4li t1, 1addi t1, zero, 1
  3. 0x8bge t1, a0, fin
  4. 0xcadd t0, t0, t1
  5. 0x10addi t1, t1, 1
  6. 0x14j bouclejal zero, boucle

registres

t00
t10
a05

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

Comparer avec la figure précédente, dont ce programme ne diffère que par l'instruction de l'adresse 0x8. Relever les deux nombres de pas et les deux valeurs finales de t0.

Dix-neuf pas au lieu de vingt-trois, t0 à 10 au lieu de 15, t1 à 5 au lieu de 6. Le corps a été exécuté quatre fois au lieu de cinq. La différence entre les deux programmes est bge au lieu de bgt : le premier sort dès que le compteur atteint la borne, le second attend qu'il la dépasse. Le résultat est aussi juste dans les deux cas du point de vue de la machine, et faux dans l'un des deux du point de vue de l'énoncé.

Nommer la borne avant d'écrire la comparaison

Le seul remède connu consiste à décider, avant d'écrire l'instruction, si la borne fait partie des valeurs traitées. « De 1 à 5 inclus » demande une sortie sur t1 > 5, donc bgt. « Les 5 premiers indices, à partir de 0 » demande une sortie sur t1 >= 5, donc bge. Écrire la comparaison en premier et ajuster ensuite en lançant le programme conduit à un code qui marche pour la valeur essayée, et pour elle seule.

Compter les tours plutôt que la valeur

Une boucle « pour » ne se distingue pas d'un « tant que » dans la machine. Elle en est un cas particulier, celui où la condition porte sur un compteur préparé avant l'entrée et incrémenté à chaque tour. Le programme suivant double une valeur autant de fois que a0 l'indique.

RISC-V RV32Ipas 0 sur 23pc = 0x0

programme

  1. 0x0li t0, 1addi t0, zero, 1
  2. 0x4li t1, 0addi t1, zero, 0
  3. 0x8bge t1, a0, fin
  4. 0xcadd t0, t0, t0
  5. 0x10addi t1, t1, 1
  6. 0x14j bouclejal zero, boucle

registres

t00
t10
a05

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

Exécuter jusqu'à l'arrêt et suivre t0 : 2, 4, 8, 16, 32. Relever le nombre de pas, puis remplacer 5 par 0 sur la ligne registres et relancer.

Vingt-trois pas, t0 à 32, t1 à 5. Le compteur va de 0 à 4 inclus et s'arrête à 5, ce qui donne bien cinq tours : c'est la forme « les n premiers », celle qui demande bge et non bgt. Avec a0 = 0, le moteur ne compte que trois pas : les deux préparations, puis le branchement qui sort immédiatement. Le corps n'est jamais exécuté, et t0 reste à 1.

La condition en bas plutôt qu'en haut

Rien n'impose de tester en tête de tour. La même boucle s'écrit avec le test à la fin, et le branchement conditionnel remplace alors à la fois le test et le saut de retour.

RISC-V RV32Ipas 0 sur 17pc = 0x0

programme

  1. 0x0li t0, 1addi t0, zero, 1
  2. 0x4li t1, 0addi t1, zero, 0
  3. 0x8add t0, t0, t0
  4. 0xcaddi t1, t1, 1
  5. 0x10blt t1, a0, boucle

registres

t00
t10
a05

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

Comparer le nombre de pas avec celui de la figure précédente, qui calcule exactement la même chose. Regarder aussi le nombre d'instructions du programme, affiché à gauche.

Dix-sept pas au lieu de vingt-trois, pour un résultat identique : t0 à 32. Deux raisons à cet écart. Le tour coûte trois instructions au lieu de quatre, puisque le branchement conditionnel sert aussi de retour et qu'il n'y a plus de saut inconditionnel. Et la condition est cette fois écrite dans le sens direct, blt t1, a0, puisqu'elle décide de continuer et non de sortir.

Cette forme est plus courte, et c'est celle qu'un compilateur produit le plus souvent. Elle n'est pourtant pas équivalente, et le cas limite le montre sans ambiguïté.

RISC-V RV32Ipas 0 sur 5pc = 0x0

programme

  1. 0x0li t0, 1addi t0, zero, 1
  2. 0x4li t1, 0addi t1, zero, 0
  3. 0x8add t0, t0, t0
  4. 0xcaddi t1, t1, 1
  5. 0x10blt t1, a0, boucle

registres

t00
t10
a00

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

Exécuter les cinq pas avec a0 valant zéro, et lire la valeur finale de t0. La comparer à celle obtenue par la figure à condition en tête, relancée avec la même valeur de a0.

Cinq pas, et t0 finit à 2. La boucle a tourné une fois alors qu'il ne fallait pas qu'elle tourne du tout, parce que le test n'arrive qu'après le corps. La version à condition en tête, lancée avec la même valeur, compte trois pas et laisse t0 à 1. Un corps exécuté zéro fois et un corps exécuté une fois : l'écart est entier, il apparaît exactement sur le cas vide, et c'est le cas que personne n'essaie.

Le cas vide est le seul qui sépare les deux formes

Pour toute valeur strictement positive, les deux écritures donnent le même résultat, la seconde en quelques instructions de moins. Elles ne diffèrent que sur l'entrée vide : zéro élément à traiter, zéro caractère à lire, zéro mesure à moyenner. Éprouver une boucle sur des données normales ne révèle donc jamais cette différence.

La boucle qui ne s'arrête jamais

Une boucle s'arrête si, et seulement si, quelque chose fait évoluer la condition d'arrêt vers sa réalisation. Le programme ci-dessous a l'air correct, et ne s'arrête pas.

RISC-V RV32Ipas 0 sur 40pc = 0x0

programme

  1. 0x0bge a0, a1, fin
  2. 0x4addi a0, a0, 1
  3. 0x8addi a1, a1, 1
  4. 0xcj bouclejal zero, boucle

registres

a00
a110

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

Lancer l'exécution complète : la figure signale que le programme a été arrêté d'autorité après quarante instructions. Suivre a0 et a1 en même temps et dire ce qui empêche la condition de devenir vraie.

Le moteur s'arrête de lui-même au bout de quarante instructions, avec le message qui l'explique. La cause se lit sur les deux registres suivis côte à côte : a0 monte de 0 à 10, mais a1 monte de 10 à 20 pendant ce temps. L'écart entre les deux ne se réduit jamais, donc a0 ne rattrape jamais a1. Le bug n'est pas dans la condition, qui est correcte, mais dans le corps, qui modifie une valeur qu'il aurait fallu laisser tranquille.

C'est la forme la plus courante de boucle sans fin, et elle est bien plus fréquente que l'oubli pur et simple de l'incrément. La bonne question à poser devant une boucle n'est donc pas « la condition est-elle juste », mais « quelle instruction du corps rapproche la condition de sa réalisation, et cette instruction est-elle exécutée à chaque tour ».

Multiplier sans instruction de multiplication

RV32I ne sait pas multiplier. L'instruction mul existe dans une extension du jeu, mais pas dans la base, et un processeur qui ne l'implémente pas doit s'en passer. La multiplication devient donc une boucle : multiplier a0 par a1 consiste à ajouter a0 à un total, a1 fois.

RISC-V RV32Ipas 0 sur 30pc = 0x0

programme

  1. 0x0li t0, 0addi t0, zero, 0
  2. 0x4beqz a1, finbeq a1, zero, fin
  3. 0x8add t0, t0, a0
  4. 0xcaddi a1, a1, -1
  5. 0x10j bouclejal zero, boucle

registres

t00
a06
a17

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 t0 : 6, 12, 18, 24, 30, 36, 42. Relever le nombre de pas, puis échanger les valeurs de a0 et a1 sur la ligne registres et relancer pour comparer.

Trente pas, et t0 finit à 42. Le compteur est ici la valeur a1 elle-même, décomptée jusqu'à zéro : c'est plus court qu'un compteur séparé, et cela détruit a1 au passage, ce dont il faudra se souvenir. En échangeant les deux valeurs de départ, c'est-à-dire en calculant sept fois six au lieu de six fois sept, le moteur ne compte plus que vingt-six pas pour le même résultat.

Cette différence de quatre pas n'a rien d'anecdotique. Le produit est le même, le programme est le même, seul le nombre de tours change, parce que la boucle tourne autant de fois que le second opérande. Multiplier par un petit nombre coûte peu, multiplier par un grand nombre coûte beaucoup : un mul matériel, lui, coûte le même temps quelles que soient les valeurs. Ranger le plus petit des deux dans a1 est donc une optimisation réelle, et c'est exactement le genre de raisonnement qui disparaît derrière l'étoile d'un langage de haut niveau.

Invariant de boucle

Un invariant est une propriété vraie avant le premier tour et préservée par chaque tour. Dans la multiplication ci-dessus, l'invariant s'énonce ainsi : t0 contient le produit de a0 par le nombre de tours déjà faits, et a1 contient le nombre de tours restants. Leur somme des contributions reste constante. Formuler l'invariant avant d'écrire la boucle est le moyen le plus sûr de choisir la bonne comparaison et la bonne initialisation, et de savoir ce qui doit être vrai à la sortie.

Vérification rapideon peut se reprendre

1.Qu'est-ce qui distingue une boucle d'une condition, du point de vue du processeur ?

2.Une boucle écrit bgt t1, a0, fin avec a0 = 5 et t1 partant de 1. Combien de fois le corps s'exécute-t-il ?

3.Quelle est la seule différence observable entre une boucle testée en tête et la même testée en fin de corps ?

4.Une boucle incrémente son compteur à chaque tour et ne s'arrête pas. Quelle explication reste possible ?

5.Pourquoi une multiplication par additions répétées ne coûte-t-elle pas toujours le même temps ?

6.À quoi sert de formuler l'invariant d'une boucle avant de l'écrire ?

La méthode

  1. Énoncer l'invariant avant toute écriture : ce que contient chaque registre au début de chaque tour, et ce qui reste à faire.
  2. Décider si la borne est incluse, puis en déduire la comparaison : incluse demande bgt ou bgtu, exclue demande bge ou bgeu.
  3. Écrire la préparation avant l'étiquette, le corps après, et le retour en dernier. Une instruction d'initialisation placée après l'étiquette par mégarde repart à chaque tour.
  4. Vérifier qu'une instruction du corps rapproche la condition de sa réalisation, et qu'elle est exécutée sur tous les chemins possibles du corps.
  5. Éprouver la boucle sur l'entrée vide, zéro élément, avant de l'éprouver sur une entrée normale. C'est le cas qui sépare les deux formes de boucle, et celui qui n'est jamais essayé.
  6. Compter les tours et les pas séparément, puisqu'un tour vaut trois ou quatre instructions, et que le compte de pas de la figure est le seul chiffre qui ne se discute pas.

Synthèse

  • Une boucle est un branchement dont la destination est placée plus haut dans le programme. Le processeur ne connaît ni pour ni tant que : il range une adresse dans le compteur ordinal, en avant ou en arrière, sans faire de différence.
  • Une boucle complète tient en trois morceaux : une préparation avant l'étiquette, un corps, et une condition qui décide de recommencer. Testée en tête, elle sort sur la condition inverse ; testée en fin de corps, elle revient sur la condition directe.
  • Le décalage d'une unité se joue sur une seule instruction : bgt et bge sur la même boucle donnent cinq tours et vingt-trois pas dans un cas, quatre tours et dix-neuf pas dans l'autre, pour des sommes de 15 et de 10.
  • La boucle à condition en fin de corps est plus courte, dix-sept pas contre vingt-trois, mais exécute son corps au moins une fois : sur une entrée vide, elle donne 2 là où l'autre donne 1.
  • Une boucle ne s'arrête que si le corps rapproche la condition d'arrêt de sa réalisation. Un compteur qui monte vers une borne qui monte aussi produit une boucle sans fin, que le moteur signale en s'arrêtant d'autorité.
  • RV32I n'ayant aucune instruction de multiplication, un produit s'écrit par additions répétées, et son coût dépend de l'opérande choisi comme compteur : trente pas pour six fois sept, vingt-six pour sept fois six.

Une boucle qui répète un calcul sur le même registre reste limitée à ce qu'un registre peut contenir. Tableaux et adresses fait porter la répétition sur une suite de cases mémoire, et montre que l'indice d'un tableau n'existe pas : il se calcule.