Jeu d'instructions et assembleurDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Architecture des ordinateurs · C3 Le processeur · Chapitre 2 · 10 h

Jeu d'instructions et assembleur

Format d'instruction, modes d'adressage et registres ; instructions arithmétiques, logiques et de branchement ; traduire un programme de haut niveau en assembleur.

Deux lignes de programme :

somme = 0pour i de 1 à 2 : somme = somme + i

Sept instructions machine, douze exécutions, et pas une seule variable — seulement des registres numérotés. C'est l'écart que ce chapitre doit combler, et il n'y a pas de raccourci : il se comble en traçant, pas en lisant. C'est pourquoi il pèse dix heures à lui seul, un cinquième du cours.

Le jeu d'instructions (instruction set architecture) est le contrat entre le matériel et le logiciel : la liste de ce que la machine sait faire, et la façon exacte de le lui demander. C'est la seule couche du cours qui soit à la fois visible du programmeur et gravée dans le silicium — ce qui explique sa stabilité. Un binaire x86 de 1995 s'exécute encore aujourd'hui.

Une instruction est un entier

Le chapitre 5 l'a posé : dans le registre d'instruction, il n'y a qu'un mot. Le format d'instruction dit comment ce mot se découpe. Prenons MIPS, l'architecture du TP, dont toutes les instructions tiennent sur exactement 32 bits.

Format R — opérations entre registres┌────────┬───────┬───────┬───────┬────────┬────────┐│ op  6  │ rs 5  │ rt 5  │ rd 5  │ sh  5  │ fn  6  │└────────┴───────┴───────┴───────┴────────┴────────┘   add $rd, $rs, $rt        rd ← rs + rt Format I — une valeur immédiate ou un déplacement┌────────┬───────┬───────┬────────────────────────┐│ op  6  │ rs 5  │ rt 5  │   immédiat        16   │└────────┴───────┴───────┴────────────────────────┘   addi $rt, $rs, imm       rt ← rs + imm Format J — un saut┌────────┬───────────────────────────────────────┐│ op  6  │        adresse             26         │└────────┴───────────────────────────────────────┘

Trois enseignements se lisent directement sur ces cases.

Cinq bits pour désigner un registre, donc 25=322^5 = 32 registres : le nombre n'est pas choisi, il découle du format. Seize bits pour l'immédiat, donc une constante comprise entre 32768-32768 et 3276732767 ; charger une valeur plus grande demande deux instructions, ce qui surprend toujours en TD. Et six bits de code opération, soit 64 codes — d'où le champ fn supplémentaire du format R, qui démultiplie les possibilités sans élargir le mot.

C'est l'occasion de nommer un débat vieux de quarante ans. Une architecture CISC (x86) offre des instructions nombreuses, de longueurs variables, dont certaines font beaucoup de travail. Une architecture RISC (MIPS, ARM, RISC-V) n'offre que des instructions simples, toutes de même longueur, et compte sur le compilateur pour les combiner. Le second choix simplifie énormément le décodage et rend le pipeline du chapitre 8 possible ; c'est pourquoi il domine aujourd'hui, y compris à l'intérieur des processeurs x86, qui traduisent en interne leurs instructions vers un jeu de type RISC.

Les registres, et pourquoi ils existent

Un registre est une mémoire de la taille d'un mot, à l'intérieur du processeur. Il n'a pas d'adresse : il a un numéro, et ce numéro tient dans l'instruction elle-même.

Sa raison d'être est le goulot d'étranglement du chapitre 5. Lire un registre coûte une fraction de cycle ; lire la mémoire en coûte plusieurs dizaines, voire des centaines si la donnée n'est pas en cache. Une architecture RISC en tire une règle stricte, dite chargement-rangement : seules deux instructions touchent la mémoire, lw pour charger et sw pour ranger. Tout le calcul se fait entre registres.

Conséquence directe sur la façon d'écrire : on charge, on calcule autant que possible, on range. Un TD où chaque opération commence par un lw et finit par un sw produit du code juste et trois fois trop lent.

Quelques registres MIPS reviennent constamment : $zero, qui vaut toujours 0 et ne peut pas être écrit — c'est lui qui permet de charger une constante par addi $t0, $zero, 5, faute d'instruction « mettre à » ; $t0 à $t9, les registres de travail ; $s0 à $s7, ceux qu'une fonction doit restituer intacte ; $sp, le sommet de pile ; $ra, l'adresse de retour.

Les modes d'adressage

Un opérande peut être désigné de plusieurs façons. Le mode d'adressage est cette façon, et c'est le second point qui coince du chapitre — moins par difficulté que par confusion entre « l'adresse » et « ce qui est à l'adresse ».

ModeÉcritureL'opérande est…
Immédiataddi $t0, $zero, 5dans l'instruction elle-même
Registreadd $t0, $t1, $t2dans un registre
Direct (absolu)lw $t0, 2000à l'adresse écrite dans l'instruction
Indirect par registrelw $t0, 0($t1)à l'adresse contenue dans un registre
Basé avec déplacementlw $t0, 8($t1)à l'adresse $t1 + 8
Relatif au compteur ordinalbeq $t0, $t1, +12à l'adresse courante + un écart

Deux méritent un mot. Le basé avec déplacement est le mode de l'accès aux tableaux et aux champs de structure : l'adresse de base dans un registre, le décalage constant dans l'instruction. T[i] pour un tableau d'entiers de 4 octets devient « adresse de T plus 4i4i » — d'où le décalage de 2 bits, plus rapide qu'une multiplication, que le compilateur génère systématiquement.

Le relatif au compteur ordinal est le mode des branchements. On n'écrit pas l'adresse absolue de la cible mais l'écart par rapport à l'instruction courante, ce qui tient sur 16 bits et rend le code translatable : un programme chargé ailleurs en mémoire continue de fonctionner sans réécriture, ce dont le chargeur du cours de systèmes tire parti.

Quiz · 1 question

En MIPS, quelle est la différence entre addi $t0, $t1, 8 et lw $t0, 8($t1) ?

  • Aucune : les deux ajoutent 8 au contenu de $t1 et rangent le résultat dans $t0identiques
  • La première met la valeur $t1 + 8 dans $t0 ; la seconde va CHERCHER EN MÉMOIRE le mot situé à l'adresse $t1 + 8 et met ce mot dans $t0calcul contre accès mémoire
  • La première travaille sur des entiers, la seconde sur des flottantstypes différents

Réponse : Les deux calculent bien la même somme $t1 + 8, mais elles n'en font pas la même chose. addi s'arrête là : la SOMME est le résultat. lw traite cette somme comme une ADRESSE, effectue un accès mémoire, et rapporte le contenu trouvé. C'est toute la distinction entre l'adresse et ce qui est à l'adresse, et c'est la confusion la plus fréquente du chapitre. Deuxième conséquence, celle du chapitre 7 : addi coûte un cycle, lw peut en coûter plusieurs centaines si la donnée n'est pas en cache.

Quatre familles d'instructions

Arithmétiques et logiques. add, sub, and, or, xor, sll et srl pour les décalages. Elles lisent des registres et écrivent un registre. Chacune correspond à un réglage de l'UAL du chapitre 4, sélectionné par le multiplexeur que le décodage commande.

Transfert. lw et sw entre mémoire et registre, move entre registres. À noter, la distinction annoncée au chapitre 2 : lb charge un octet avec extension de signe, lbu sans. Choisir la mauvaise transforme un 5-5 en 251, silencieusement.

Branchements et sauts. C'est ici que tout le contrôle se joue, et le mécanisme est toujours le même : écrire dans le compteur ordinal. beq et bne le font sous condition d'égalité, j inconditionnellement, jal en sauvegardant au passage l'adresse de retour dans $ra — c'est l'appel de fonction, et jr $ra en est le retour.

Un détail qui déroute : MIPS n'a pas d'instruction « brancher si inférieur ». Il faut deux temps — slt calcule un booléen dans un registre, puis beq ou bne branche dessus. C'est le slti que l'animation ci-dessous exécute, et c'est aussi pourquoi la comparaison signée et la non signée sont deux instructions distinctes, slt et sltu.

Appels système. syscall passe la main au système d'exploitation pour tout ce que le processeur seul ne peut pas faire : afficher, lire, terminer. C'est la frontière que le chapitre 1 du cours de systèmes appellera le passage en mode noyau.

Traduire du haut niveau

Toutes les structures de contrôle se ramènent à des branchements, selon des schémas mécaniques qu'il faut connaître par cœur.

si (a < b) alors X sinon Y         slt  $t0, $a, $b       # $t0 ← (a < b)        beq  $t0, $zero, sinon # condition INVERSÉE : on saute si FAUX        ... X ...        j    finsisinon:  ... Y ...finsi:

La règle qui surprend : la condition est toujours inversée. En haut niveau on entre dans le bloc si le test réussit ; en assembleur on saute par-dessus le bloc si le test échoue. Une fois cette inversion admise, les trois autres schémas s'écrivent seuls.

tant que (i < n) faire corps boucle: slt  $t0, $i, $n        beq  $t0, $zero, fin        ... corps ...        j    bouclefin:

Un pour est un tant que dont l'initialisation précède et l'incrément clôt le corps — c'est exactement le programme de l'animation. Et l'accès T[i] se décompose en trois temps : décaler i de 2 bits pour obtenir l'écart en octets, l'ajouter à l'adresse de base, puis charger.

Animation · 14 étapes

somme = 0 ; pour i de 1 à 2 : somme = somme + i

  1. Initialiser l'accumulateuraddi ajoute une valeur immédiate à un registre. Additionner 0 au registre $zero, toujours nul, est la façon idiomatique de charger une constante : il n'y a pas d'instruction « mettre à ».
  2. Initialiser le compteurLes variables de haut niveau somme et i n'existent plus : ce sont $t0 et $t1. C'est l'allocation de registres, et c'est le premier travail d'un compilateur.
  3. Tour 1 — évaluer la conditionslti (set less than immediate) écrit 1 dans $t2 si i est inférieur à 3, sinon 0. Le test d'une boucle de haut niveau devient donc DEUX instructions : calculer un booléen, puis brancher dessus.
  4. Tour 1 — le branchement n'est pas prisbeq saute si les deux registres sont égaux. $t2 vaut 1 et $zero vaut 0 : le saut n'a pas lieu, le CO passe simplement à l'instruction suivante. La condition est INVERSÉE par rapport au code source — on saute quand la boucle doit s'arrêter.
  5. Tour 1 — le corps de la boucleEnfin l'addition utile : somme ← somme + i. Une seule instruction du programme source, une seule instruction machine — c'est l'exception, pas la règle.
  6. Tour 1 — incrémenterL'incrément du « pour » est explicite en assembleur. Rien ne l'écrit à votre place, et l'oublier produit la boucle infinie la plus classique du TD.
  7. Tour 1 — retour en têtej écrit une nouvelle valeur dans le compteur ordinal. Toute structure de contrôle — boucle, condition, appel de fonction — se ramène à cela : écrire dans le CO.
  8. Tour 2 — la condition tient encorei vaut 2, donc i inférieur à 3 : $t2 reste à 1.
  9. Tour 2 — on n'en sort pasMême branchement, même issue : le corps s'exécute une seconde fois.
  10. Tour 2 — somme vaut 31 + 2. L'accumulateur porte bien son nom.
  11. Tour 2 — incrémenteri passe à 3. C'est cette valeur qui fera échouer le test au tour suivant.
  12. Tour 2 — retour en têteTroisième évaluation de la condition, et cette fois elle va tomber.
  13. Tour 3 — la condition tombe3 n'est pas inférieur à 3 : slti écrit 0 dans $t2. Le corps ne s'exécutera pas une troisième fois, ce qui est bien le comportement du « pour i de 1 à 2 » de départ.
  14. Sortie de boucle$t2 vaut 0, donc égal à $zero : le branchement est pris et le CO saute à l'étiquette fin. somme vaut 3, soit 1 + 2. Le programme de haut niveau tenait en deux lignes ; il en a fallu sept, et douze exécutions d'instruction.

Quiz · 1 question

Dans la traduction d'un tant que (i < n), pourquoi le branchement teste-t-il l'échec de la condition plutôt que sa réussite ?

  • Par convention historique du langage assembleur MIPSconvention
  • Parce qu'un branchement fait SAUTER : pour continuer dans la boucle il ne faut rien faire, et il faut sauter uniquement pour en sortir — donc quand la condition est faussesauter, c'est sortir
  • Parce que les processeurs évaluent plus rapidement une condition fausse qu'une condition vraievitesse d'évaluation

Réponse : En assembleur il n'existe pas de « bloc » : il n'y a que des instructions consécutives et des sauts. Continuer dans le corps de la boucle est donc l'action PAR DÉFAUT, celle qui ne demande aucune instruction. Le saut ne sert qu'à l'exception : quitter la boucle. On branche donc quand la condition d'entrée est FAUSSE. Cette inversion est le réflexe à acquérir — une fois admise, si-sinon, tant que et pour s'écrivent mécaniquement. Elle explique aussi pourquoi le code compilé teste souvent le contraire de ce qu'on a écrit.

À vous

L'exercice donne un interpréteur MIPS réduit — une quinzaine d'instructions — et vous demande d'écrire le programme, pas la machine. Trois traductions à produire, dans l'ordre de difficulté : un maximum de deux valeurs (si-sinon), la somme d'un tableau (boucle et accès indexé), puis un compte d'éléments pairs.

L'interpréteur affiche la trace registre par registre. Servez-vous-en comme du tableau de la salle de TD : quand un programme ne donne pas le bon résultat, la trace montre l'instruction exacte où l'état diverge de ce que vous attendiez.

Exercice de code

Traduisez un si-sinon puis une boucle de haut niveau en assembleur MIPS réduit.

Point de départ

// ── Un interpréteur MIPS réduit ───────────────────────────────────────────
// Une instruction : [mnémonique, destination, source1, source2].
// Registres nommés librement ; "zero" vaut toujours 0.

function executer(prog, memoire, tracer) {
  const R = { zero: 0 };
  const lire = (x) => (typeof x === "number" ? x : (R[x] ?? 0));
  const etiquettes = {};
  prog.forEach((ins, i) => { if (ins[0] === "label") etiquettes[ins[1]] = i; });

  let CO = 0, pas = 0;
  while (CO < prog.length && pas++ < 500) {
    const [op, a, b, c] = prog[CO];
    let saut = null;
    if (op === "li")   R[a] = lire(b);
    if (op === "add")  R[a] = lire(b) + lire(c);
    if (op === "sub")  R[a] = lire(b) - lire(c);
    if (op === "addi") R[a] = lire(b) + c;
    if (op === "slt")  R[a] = lire(b) < lire(c) ? 1 : 0;
    if (op === "slti") R[a] = lire(b) < c ? 1 : 0;
    if (op === "andi") R[a] = lire(b) & c;
    if (op === "lw")   R[a] = memoire[lire(b) + c];        // lw a, c(b)
    if (op === "sw")   memoire[lire(b) + c] = lire(a);
    if (op === "beq" && lire(a) === lire(b)) saut = etiquettes[c];
    if (op === "bne" && lire(a) !== lire(b)) saut = etiquettes[c];
    if (op === "j")    saut = etiquettes[a];
    if (tracer && op !== "label") {
      console.log("   " + [op, a, b, c].filter((x) => x !== undefined).join(" ").padEnd(22) +
        JSON.stringify(R));
    }
    CO = saut !== null && saut !== undefined ? saut : CO + 1;
  }
  return R;
}

// ── 1. Maximum de deux valeurs (si-sinon) ─────────────────────────────────
// max = (a < b) ? b : a, avec a en $a et b en $b, résultat dans $max.
const MAX = [
  ["li", "a", 12], ["li", "b", 37],
  ["slt", "t", "a", "b"],          // t = (a < b)
  ["beq", "t", "zero", "sinon"],   // condition INVERSÉE
  // ← branche « alors » : max = b
  ["j", "fin"],
  ["label", "sinon"],
  // ← branche « sinon » : max = a
  ["label", "fin"],
];

// ── 2. Somme d'un tableau (boucle + accès indexé) ─────────────────────────
// Le tableau occupe la mémoire à partir de l'adresse 0, un mot par case.
const TABLEAU = [4, 8, 15, 16, 23, 42];
const SOMME = [
  ["li", "somme", 0],
  ["li", "i", 0],
  ["li", "n", TABLEAU.length],
  ["label", "boucle"],
  // ← à écrire : sortir si i >= n, charger T[i], accumuler, incrémenter i
];

// ── À VOUS ────────────────────────────────────────────────────────────────
// Complétez MAX puis SOMME. Un troisième défi : compter les valeurs paires
// du tableau (indice : andi t, x, 1 met à 1 le bit de parité).

console.log("— maximum —");
console.log("max =", executer(MAX, [], true).max, "| attendu 37");

console.log("— somme —");
console.log("somme =", executer(SOMME, [...TABLEAU], false).somme, "| attendu 108");

Solution

function executer(prog, memoire, tracer) {
  const R = { zero: 0 };
  const lire = (x) => (typeof x === "number" ? x : (R[x] ?? 0));
  const etiquettes = {};
  prog.forEach((ins, i) => { if (ins[0] === "label") etiquettes[ins[1]] = i; });

  let CO = 0, pas = 0;
  while (CO < prog.length && pas++ < 500) {
    const [op, a, b, c] = prog[CO];
    let saut = null;
    if (op === "li")   R[a] = lire(b);
    if (op === "add")  R[a] = lire(b) + lire(c);
    if (op === "sub")  R[a] = lire(b) - lire(c);
    if (op === "addi") R[a] = lire(b) + c;
    if (op === "slt")  R[a] = lire(b) < lire(c) ? 1 : 0;
    if (op === "slti") R[a] = lire(b) < c ? 1 : 0;
    if (op === "andi") R[a] = lire(b) & c;
    if (op === "lw")   R[a] = memoire[lire(b) + c];
    if (op === "sw")   memoire[lire(b) + c] = lire(a);
    if (op === "beq" && lire(a) === lire(b)) saut = etiquettes[c];
    if (op === "bne" && lire(a) !== lire(b)) saut = etiquettes[c];
    if (op === "j")    saut = etiquettes[a];
    if (tracer && op !== "label") {
      console.log("   " + [op, a, b, c].filter((x) => x !== undefined).join(" ").padEnd(22) +
        JSON.stringify(R));
    }
    CO = saut !== null && saut !== undefined ? saut : CO + 1;
  }
  return R;
}

// 1. Maximum. Le schéma si-sinon : tester, brancher sur l'ÉCHEC vers la
// branche sinon, écrire la branche alors, sauter par-dessus le sinon.
const MAX = [
  ["li", "a", 12], ["li", "b", 37],
  ["slt", "t", "a", "b"],
  ["beq", "t", "zero", "sinon"],
  ["add", "max", "b", "zero"],     // max = b  (pas d'instruction « copier »)
  ["j", "fin"],
  ["label", "sinon"],
  ["add", "max", "a", "zero"],     // max = a
  ["label", "fin"],
];

const TABLEAU = [4, 8, 15, 16, 23, 42];

// 2. Somme. Le schéma tant que : condition en tête, saut de sortie sur
// l'échec, corps, incrément, saut de retour.
const SOMME = [
  ["li", "somme", 0],
  ["li", "i", 0],
  ["li", "n", TABLEAU.length],
  ["label", "boucle"],
  ["slt", "t", "i", "n"],
  ["beq", "t", "zero", "fin"],
  ["lw", "x", "i", 0],             // ici une case = un mot, donc pas de x4
  ["add", "somme", "somme", "x"],
  ["addi", "i", "i", 1],
  ["j", "boucle"],
  ["label", "fin"],
];

// 3. Compter les valeurs paires : même boucle, un test de parité en plus.
const PAIRS = [
  ["li", "compte", 0], ["li", "i", 0], ["li", "n", TABLEAU.length],
  ["label", "boucle"],
  ["slt", "t", "i", "n"],
  ["beq", "t", "zero", "fin"],
  ["lw", "x", "i", 0],
  ["andi", "p", "x", 1],           // p = bit de poids faible = 1 si impair
  ["bne", "p", "zero", "suite"],   // impair : on ne compte pas
  ["addi", "compte", "compte", 1],
  ["label", "suite"],
  ["addi", "i", "i", 1],
  ["j", "boucle"],
  ["label", "fin"],
];

console.log("— maximum —");
console.log("max =", executer(MAX, [], true).max, "| attendu 37");

console.log("— somme —");
console.log("somme =", executer(SOMME, [...TABLEAU], false).somme, "| attendu 108");

console.log("— nombre de valeurs paires —");
console.log("compte =", executer(PAIRS, [...TABLEAU], false).compte, "| attendu 4");

En travaux pratiques

Travaux pratiques 6 · 4 h

Traduire, à la main puis avec le compilateur

Écrire de l'assembleur, retrouver ses propres constructions dans ce que produit gcc, et découvrir que la convention d'appel n'est pas une règle du langage mais un contrat entre fonctions.

Avant de commencer

  • Le TP 5 : cycle et compteur ordinal
  • Un simulateur MIPS ou RISC-V (MARS, rars) et gcc

Énoncé

  1. Premiers pasDans le simulateur, écrivez un programme qui additionne deux constantes et affiche le résultat. Exécutez pas à pas et regardez les registres changer.
  2. Traduire une conditionTraduisez à la main un si-sinon en assembleur. Comptez le nombre d'étiquettes nécessaires et dites pourquoi la traduction inverse la condition. Indice : Il n'y a pas d'instruction « si vrai, exécute ce bloc » : seulement « si faux, saute ailleurs ».
  3. Traduire une boucleTraduisez une boucle qui additionne les entiers de 1 à n. Faites-la tourner pour n = 5, puis comptez les instructions exécutées.
  4. Confronter au compilateurÉcrivez la même boucle en C, compilez avec gcc -S -O0, et comparez ligne à ligne avec votre version. Notez trois différences et expliquez-les.
  5. Appeler une fonctionÉcrivez une fonction assembleur qui calcule un carré, appelez-la deux fois de suite. Respectez la convention : arguments, valeur de retour, adresse de retour.
  6. Casser la conventionRendez votre fonction récursive sans sauvegarder l'adresse de retour. Exécutez et expliquez précisément où le programme part.
  7. Le prix de l'optimisationRecompilez le C avec -O2 et comparez au -O0. Comptez les instructions et les accès mémoire, et repérez ce que le compilateur a supprimé.

C'est réussi quand

  • Votre si-sinon se traduit avec la condition inversée, et vous savez dire pourquoi
  • Votre fonction s'appelle deux fois de suite sans corrompre l'appelant
  • La version récursive sans sauvegarde boucle, et vous savez sur quelle instruction
  • Vous savez nommer une variable que -O2 a fait disparaître de la mémoire

Correction

Le si-sinon, à l'envers
/* en C */                # en assembleur
if (a > b)                bgt  $t0, $t1, vrai
  c = 1;                li   $t2, 0        # sinon
else                      j    fin
  c = 0;              vrai:
                        li   $t2, 1
                      fin:

Le processeur ne sait pas « exécuter un bloc si » : il sait sauter. Toute structure de contrôle se réécrit donc en tests et sauts, et souvent avec la condition INVERSÉE — on saute par-dessus le cas qu'on ne veut pas. C'est pourquoi un assembleur désassemblé est si pénible à relire : la structure du programme d'origine a disparu.

La boucle, et ce que fait gcc à côté
# votre version                gcc -S -O0
  li   $t0, 1     # i        sw  $zero, -8($fp)   # tout en MÉMOIRE
  li   $t1, 0     # somme    lw  $eax, -8($fp)
boucle:                        addl ...
  bgt  $t0, $a0, fin         sw  $eax, -8($fp)    # relu, réécrit
  add  $t1, $t1, $t0
  addi $t0, $t0, 1
  j    boucle
fin:

Sans optimisation, gcc range CHAQUE variable en mémoire et la relit à chaque usage : c'est ce qui rend le pas-à-pas au débogueur fidèle au code source. Votre version, qui garde tout en registres, est plus rapide — et c'est exactement ce que fera -O2. Le compilateur n'est pas naïf : à -O0, il est délibérément littéral.

La convention d'appel
carre:
  mul  $v0, $a0, $a0    # argument dans $a0, retour dans $v0
  jr   $ra              # saute à l'adresse rangée dans $ra

main:
  li   $a0, 5
  jal  carre            # jal range l'adresse SUIVANTE dans $ra
  move $s0, $v0

Rien dans le matériel n'impose que l'argument passe par $a0 : c'est une CONVENTION, respectée par tous les compilateurs d'une même plateforme. C'est elle qui permet d'appeler depuis du C une fonction écrite en assembleur, ou en Rust. La convention est la vraie interface binaire — pas le langage.

L'adresse de retour écrasée
fact:
  ble  $a0, 1, base
  addi $a0, $a0, -1
  jal  fact          # ← ÉCRASE $ra avec l'adresse d'ici
  ...
base:
  jr   $ra           # revient à l'appel INTERNE, pas à l'appelant
                     # → boucle infinie entre fact et lui-même

/* la correction */
  addi $sp, $sp, -4
  sw   $ra, 0($sp)   # empiler avant l'appel
  ...
  lw   $ra, 0($sp)   # dépiler après
  addi $sp, $sp, 4

Il n'y a qu'un seul registre $ra, et chaque jal l'écrase. La pile n'est pas un confort de langage : c'est la seule façon de rendre la récursivité possible sur une machine à registres en nombre fini. Vous retrouverez exactement cette pile, vue d'en haut, au premier chapitre d'Algorithmique 2.

Ce que -O2 supprime
-O0 : 24 instructions, 12 accès mémoire
-O2 :  7 instructions,  0 accès mémoire (hors résultat)

la variable de boucle n'existe plus en mémoire : elle vit
dans un registre du début à la fin
et si n est une constante, gcc calcule la somme À LA COMPILATION

Une variable locale peut n'avoir aucune existence à l'exécution. C'est déroutant au débogueur — gdb affiche alors « optimized out » — et c'est la raison pour laquelle on débogue en -O0 et on livre en -O2. Cela explique aussi le TP 2 : le compilateur raisonne sur ce que la NORME autorise, pas sur ce que la machine ferait.

Ce que la suite en fait

Deux fils partent d'ici. Le premier va au chapitre 7 : lw et sw sont les instructions dont le coût est le plus variable, de un cycle à plusieurs centaines selon que la donnée est en cache ou non, et le principe de localité qui rend le cache efficace se lit directement dans les schémas de boucle qu'on vient d'écrire.

Le second va au chapitre 8. Un jeu d'instructions RISC — longueur fixe, décodage simple, accès mémoire cantonné à deux instructions — n'est pas seulement élégant : c'est ce qui rend le pipeline réalisable. Et les branchements, dont on vient de voir qu'ils sont partout, y deviendront le principal obstacle, d'où la prédiction de branchement.

À retenir

Flashcards · 5 cartes

Que peut-on déduire du seul format d'une instruction MIPS sur 32 bits ?
Cinq bits par champ de registre, donc 32 registres — le nombre découle du format, il n'est pas choisi. Seize bits d'immédiat, donc des constantes de −32768 à 32767 seulement : au-delà il faut deux instructions. Six bits de code opération, soit 64 codes, complétés par un champ fn dans le format R pour ne pas élargir le mot.
Qu'est-ce qu'une architecture chargement-rangement, et quelle discipline impose-t-elle ?
Une architecture où SEULES deux instructions touchent la mémoire, lw et sw ; tout le calcul se fait entre registres. La discipline qui en découle : charger une fois, calculer autant que possible en registres, ranger une fois. Un code qui relit la mémoire à chaque opération est juste mais plusieurs fois trop lent, car un accès mémoire coûte des dizaines de cycles contre une fraction pour un registre.
Quelle est la différence entre addi $t0, $t1, 8 et lw $t0, 8($t1) ?
Les deux calculent $t1 + 8. addi s'arrête là : la somme EST le résultat, rangée dans $t0. lw traite cette somme comme une ADRESSE, fait un accès mémoire et rapporte le contenu trouvé. C'est la distinction entre l'adresse et ce qui est à l'adresse. Le mode « basé avec déplacement » de lw est celui de l'accès aux tableaux et aux champs de structure.
Pourquoi la condition est-elle inversée quand on traduit un si ou un tant que en assembleur ?
Parce qu'il n'existe pas de bloc en assembleur : seulement des instructions consécutives et des sauts. Continuer est l'action par défaut, gratuite ; le saut sert uniquement à l'exception, c'est-à-dire à passer par-dessus le bloc ou à sortir de la boucle. On branche donc quand la condition d'entrée est FAUSSE.
Comment MIPS traite-t-il une comparaison « inférieur à », et pourquoi en deux temps ?
Il n'a pas de « brancher si inférieur ». slt (ou slti) écrit d'abord un booléen 0/1 dans un registre, puis beq ou bne branche sur ce registre. D'où deux instructions pour un seul test de haut niveau. C'est aussi pourquoi slt et sltu sont distinctes : la comparaison signée et la non signée ne donnent pas le même résultat sur les mêmes bits, comme au chapitre 2.