C3 — Le processeurDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Architecture des ordinateurs

Cours 3Le processeur

Le cœur du cours : ce que la machine fait à chaque coup d'horloge, et comment un programme de haut niveau s'y ramène.

2 chapitres · 14 h de travail estimé

  1. 1. Modèle de von Neumann4 h
  2. 2. Jeu d'instructions et assembleur10 h

Chapitre 1 · 4 h

Modèle de von Neumann

Unité de traitement, unité de commande, mémoire et entrées/sorties ; bus de données, d'adresses et de contrôle ; cycle fetch-decode-execute, horloge et fréquence.

Reprogrammer l'ENIAC, en 1945, prenait plusieurs jours. Non pas d'écriture — de câblage : une équipe d'opératrices rebranchait des centaines de fiches et repositionnait des milliers d'interrupteurs. La machine n'exécutait pas un programme, elle était le programme.

La même année, un rapport resté célèbre pour son statut de brouillon — le First Draft of a Report on the EDVAC, signé du seul John von Neumann alors que l'idée est collective — propose autre chose : ranger les instructions dans la mémoire, à côté des données. Changer de programme devient alors ce que c'est aujourd'hui, écrire des octets quelque part.

Ce chapitre pose cette architecture. Il est court et il est central : les chapitres 6, 7 et 8 ne feront qu'en détailler des morceaux — le jeu d'instructions, la mémoire, les entrées/sorties.

Le programme est une donnée

L'idée tient en une phrase, et ses conséquences occupent l'informatique entière.

Puisque le programme est en mémoire, un programme peut lire, écrire et fabriquer un autre programme. Un compilateur devient possible : c'est un programme dont la sortie est un programme. Un système d'exploitation devient possible : il charge en mémoire le code d'une application, puis lui donne la main. Un virus devient possible, pour la même raison.

Le revers est immédiat. Rien, dans la mémoire, ne distingue physiquement une instruction d'une donnée : ce sont des motifs de bits, dans les mêmes cases, sur le même bus. C'est le processeur qui décide, par le seul fait de savoir où il en est. Faire pointer le compteur ordinal sur des données, et la machine les exécutera consciencieusement — c'est le mécanisme d'une bonne part des attaques par débordement de tampon, et la raison pour laquelle les systèmes modernes marquent certaines zones « non exécutables ».

Les quatre organes

Le modèle décompose la machine en quatre parties, et le vocabulaire est stable depuis 1945.

L'unité arithmétique et logique (UAL) calcule : additions, soustractions, opérations booléennes, comparaisons. C'est l'additionneur du chapitre 4, entouré de quelques portes et d'un multiplexeur qui sélectionne l'opération.

L'unité de commande ne calcule rien. Elle lit l'instruction courante, la décode, et active les bons signaux au bon moment : lire la mémoire, écrire dans un registre, régler l'UAL sur « additionner ». C'est le chef d'orchestre, et son travail est intégralement combinatoire et séquentiel — décodeurs, multiplexeurs, bascules.

Ensemble, UAL et unité de commande forment le processeur. On y ajoute quelques registres, mémoires internes minuscules et très rapides :

RegistreRôle
Compteur ordinal (CO)adresse de la prochaine instruction
Registre d'instruction (RI)l'instruction en cours de traitement
Accumulateur / registres générauxles opérandes et les résultats
Registre d'étatles indicateurs du chapitre 2 : retenue, débordement, zéro, signe

La mémoire est un tableau de cases numérotées. Donner une adresse, recevoir un mot ; donner une adresse et un mot, l'écrire. Elle ne sait rien d'autre, et surtout pas ce qu'elle contient.

Les entrées/sorties relient le tout au monde. Le chapitre 8 leur est consacré.

Trois bus

Ces organes communiquent par des faisceaux de fils appelés bus, et il y en a exactement trois, chacun avec sa nature propre.

Le bus d'adresses est unidirectionnel : seul le processeur émet, la mémoire écoute. Sa largeur détermine l'espace adressable, et c'est le calcul le plus utile du chapitre : nn fils donnent 2n2^n adresses. Avec 16 fils, 64 Kio. Avec 32 fils, 2322^{32} octets, soit exactement 4 Gio — la fameuse limite des systèmes 32 bits, qui n'est pas une décision commerciale mais un nombre de fils.

Le bus de données est bidirectionnel : un mot y circule dans un sens à la lecture, dans l'autre à l'écriture. Sa largeur — 8, 32, 64 bits — est la quantité transférée par accès.

Le bus de contrôle transporte les signaux qui disent quoi faire et quand : lecture ou écriture, horloge, demandes d'interruption du chapitre 8.

Quiz · 1 question

Un processeur dispose d'un bus d'adresses de 20 fils et d'un bus de données de 16 bits. Quelle est la quantité maximale de mémoire adressable, et pourquoi ?

  • 2²⁰ × 2 octets = 2 Mio, car chaque adresse désigne un mot de 16 bitsproduit des deux bus
  • 2²⁰ = 1 Mio d'octets si la mémoire est adressée par octet : c'est le bus d'ADRESSES qui fixe le nombre de cases, pas le bus de donnéesle bus d'adresses seul
  • 2¹⁶ = 64 Kio, car le bus de données limite la taille des valeurs manipuléesle bus de données

Réponse : Les deux bus répondent à deux questions différentes. Le bus d'adresses fixe COMBIEN DE CASES on peut désigner : 20 fils, donc 2²⁰ adresses distinctes, soit 1 Mio si l'unité adressable est l'octet — ce qui est le cas des machines courantes. Le bus de données fixe COMBIEN DE BITS transitent par accès : ici 16, donc deux octets par transfert, ce qui joue sur la vitesse, pas sur la capacité. La première réponse serait juste sur une machine adressée par MOT et non par octet, architecture qui existe mais qui n'est pas celle du cours. Le même raisonnement donne les 4 Gio des systèmes 32 bits.

Le cycle recherche - décodage - exécution

Le processeur ne fait qu'une chose, indéfiniment, à chaque coup d'horloge : le même cycle en trois temps.

Recherche (fetch). Le contenu du compteur ordinal part sur le bus d'adresses ; la mémoire renvoie le mot ; ce mot entre dans le registre d'instruction. Le compteur ordinal est incrémenté immédiatement, avant même qu'on sache ce que l'instruction fait. Ce détail compte : c'est ce qui permet à un branchement, plus tard dans le cycle, d'écraser proprement cette valeur.

Décodage (decode). L'unité de commande découpe le registre d'instruction : d'un côté le code opération, de l'autre les opérandes. Le chapitre 6 détaillera ce découpage. À l'issue du décodage, les signaux de commande sont positionnés ; rien n'a encore bougé dans les données.

Exécution (execute). Le travail a lieu : l'UAL calcule, ou la mémoire est lue ou écrite, ou le compteur ordinal reçoit une nouvelle valeur s'il s'agit d'un branchement.

Puis on recommence. Une machine allumée ne fait rien d'autre — et lorsqu'elle « ne fait rien », elle exécute une boucle d'attente du système d'exploitation.

Animation · 11 étapes

Le cycle recherche-décodage-exécution, instruction par instruction

  1. Au départLe compteur ordinal (CO) contient l'adresse de la prochaine instruction, ici 0. Le registre d'instruction (RI) est vide : rien n'a encore été lu. Programme et données sont dans la MÊME mémoire — c'est tout le principe de von Neumann.
  2. Recherche — l'instruction 0 est lueLe CO est placé sur le bus d'adresses, la mémoire renvoie le mot, qui est copié dans le RI. Le CO est incrémenté AUSSITÔT, avant même de savoir ce que l'instruction fait : c'est ce qui permettra à un branchement de l'écraser proprement.
  3. Décodage — l'unité de commande lit le code opérationLe RI est découpé : le code opération dit CHARGE, le champ adresse dit 10. L'unité de commande active les signaux correspondants — lecture mémoire, écriture dans l'accumulateur. Rien n'a encore bougé dans les données.
  4. Exécution — l'accumulateur reçoit 7Second accès mémoire, cette fois à l'adresse 10, dont le contenu part vers l'accumulateur. Deux accès mémoire pour une seule instruction : c'est déjà le goulot d'étranglement de von Neumann.
  5. Recherche — l'instruction 1 est lueMême geste, à l'adresse que le CO indique désormais. Le cycle est strictement identique quelle que soit l'instruction : c'est ce qui rend l'unité de commande simple.
  6. Décodage — c'est une additionLe code opération désigne l'unité arithmétique et logique, et la commande la règle sur « additionner ». Le champ adresse 11 désigne le second opérande ; le premier est implicitement l'accumulateur.
  7. Exécution — 7 + 3 = 10L'UAL reçoit 7 depuis l'accumulateur et 3 depuis la mémoire, et son résultat repart dans l'accumulateur. C'est l'additionneur du chapitre 4, à la porte près.
  8. Recherche — l'instruction 2 est lueTroisième tour de boucle. Le CO passe à 3.
  9. Décodage — c'est une écriture mémoireLe sens du transfert s'inverse : cette fois l'unité de commande demande une ÉCRITURE, ce qui se traduit par un signal différent sur le bus de contrôle. L'adresse et la donnée circulent sur leurs bus respectifs.
  10. Exécution — le résultat est rangéLa case 12 contient 10. Remarquez que rien ne distingue physiquement cette case des cases 0 à 3 qui contiennent le programme : la mémoire ne sait pas ce qu'elle stocke.
  11. ArrêtSans cette instruction, le processeur continuerait à lire la case 4, puis 5, et exécuterait les données comme si c'étaient des instructions. C'est exactement ce que provoque un débordement de pile mal maîtrisé.

Horloge, fréquence, et le vrai goulot d'étranglement

L'horloge cadence tout. À chaque front, l'état bascule vers sa valeur suivante — c'est la bascule sur front du chapitre 4. La fréquence est le nombre de fronts par seconde : 3 GHz signifie trois milliards de cycles par seconde, soit une période de 0,33 nanoseconde.

Il faut résister à une tentation, que le chapitre 8 démontera pour de bon : la fréquence ne mesure pas la performance. Le temps d'exécution vaut

T=Ninstructions×CPIfT = \frac{N_{\text{instructions}} \times \text{CPI}}{f}

où le CPI est le nombre moyen de cycles par instruction. Deux processeurs de même fréquence peuvent différer d'un facteur trois selon leur CPI et selon la qualité du code compilé.

Reste le défaut structurel du modèle, connu sous le nom de goulot d'étranglement de von Neumann : programme et données partageant la même mémoire et le même bus, le processeur passe son temps à attendre la mémoire. L'animation ci-dessus le montre à nu — deux accès mémoire pour la seule instruction CHARGE 10, l'un pour lire l'instruction, l'autre pour lire la donnée.

L'écart s'est creusé pendant quarante ans : les processeurs ont accéléré bien plus vite que la mémoire. Deux réponses existent. L'architecture Harvard sépare physiquement mémoire d'instructions et mémoire de données, ce que font les microcontrôleurs et, à l'intérieur, les caches de premier niveau. Et le cache lui-même, qui est le sujet du chapitre 7 — sa raison d'être tient tout entière dans le paragraphe que vous venez de lire.

Quiz · 1 question

Pourquoi le compteur ordinal est-il incrémenté pendant la phase de recherche, avant même que l'instruction soit décodée ?

  • Par optimisation : cela évite une addition supplémentaire en fin de cyclegain d'une addition
  • Parce que le cas courant est de passer à l'instruction suivante ; un branchement, décodé après, n'a plus qu'à écraser cette valeur par la siennecas courant puis écrasement
  • Parce que le compteur ordinal doit rester synchrone avec le registre d'instruction, sans quoi le décodage lirait la mauvaise instructionsynchronisation

Réponse : La très grande majorité des instructions ne modifient pas le flot : le successeur naturel est l'instruction suivante. On l'anticipe donc systématiquement, dès la recherche, ce qui simplifie le circuit — un incrémenteur branché en permanence sur le CO, exactement le compteur du chapitre 4. Les branchements, minoritaires, écrasent ensuite cette valeur pendant l'exécution, et rien n'est perdu puisque le CO n'a pas encore servi. Ce pari sur le cas courant est le même qui fondera la prédiction de branchement du pipeline au chapitre 8 — et c'est aussi pourquoi un branchement y coûte plus cher qu'une instruction ordinaire.

À vous

L'exercice écrit la boucle du processeur : un tableau d'entiers qui contient à la fois le programme et les données, un compteur ordinal, un accumulateur, et une fonction qui répète recherche, décodage, exécution jusqu'à rencontrer l'arrêt.

Le programme fourni est déjà rangé en mémoire sous forme de nombres — pas de texte, pas de symboles. C'est le point du chapitre : à ce niveau, une instruction est un entier, et le dernier test le prouve en faisant modifier au programme sa propre instruction.

Exercice de code

Complétez le cycle recherche-décodage-exécution d'une machine à accumulateur.

Point de départ

// Une instruction tient dans un entier : opcode * 100 + adresse.
const STOP = 0, CHARGE = 1, AJOUTE = 2, RANGE = 3, SAUTE_SI_ZERO = 4, SOUSTRAIT = 5;

// Mémoire unique. Cases 0..7 : le programme. Cases 10.. : les données.
const MEM = [
  CHARGE * 100 + 10,      // 0 : ACC <- Mem[10]
  AJOUTE * 100 + 11,      // 1 : ACC <- ACC + Mem[11]
  RANGE  * 100 + 12,      // 2 : Mem[12] <- ACC
  STOP   * 100 + 0,       // 3 : arrêt
  0, 0, 0, 0, 0, 0,
  7,                      // 10 : donnée
  3,                      // 11 : donnée
  0,                      // 12 : résultat
];

function executer(mem, tracer) {
  let CO = 0, ACC = 0, cycles = 0;
  while (cycles++ < 100) {
    // RECHERCHE : lire l'instruction pointée par le CO, puis incrémenter.
    const RI = mem[CO];
    CO = CO + 1;

    // DÉCODAGE : séparer le code opération de l'adresse.
    const op = Math.floor(RI / 100);
    const adr = RI % 100;

    if (tracer) console.log("  CO=" + String(CO - 1).padStart(2) +
      "  RI=" + String(RI).padStart(4) + "  op=" + op + " adr=" + adr +
      "  ACC=" + ACC);

    // EXÉCUTION.
    if (op === STOP) return { ACC, mem, cycles };
    if (op === CHARGE) ACC = mem[adr];
    if (op === AJOUTE) ACC = ACC + mem[adr];
    // ← il manque RANGE, SOUSTRAIT et SAUTE_SI_ZERO
  }
  return { ACC, mem, cycles: -1 };
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Complétez l'exécution : RANGE écrit l'accumulateur en mémoire,
//    SOUSTRAIT retranche, SAUTE_SI_ZERO écrit adr dans CO si ACC vaut 0.
// 2. Écrivez en mémoire un programme qui calcule 7 - 3 et le range en 12.

const r = executer([...MEM], true);
console.log("ACC =", r.ACC, "| Mem[12] =", r.mem[12], "| attendu 10 et 10");

Solution

const STOP = 0, CHARGE = 1, AJOUTE = 2, RANGE = 3, SAUTE_SI_ZERO = 4, SOUSTRAIT = 5;

const MEM = [
  CHARGE * 100 + 10,
  AJOUTE * 100 + 11,
  RANGE  * 100 + 12,
  STOP   * 100 + 0,
  0, 0, 0, 0, 0, 0,
  7, 3, 0,
];

function executer(mem, tracer) {
  let CO = 0, ACC = 0, cycles = 0;
  while (cycles++ < 100) {
    const RI = mem[CO];
    CO = CO + 1;
    const op = Math.floor(RI / 100);
    const adr = RI % 100;

    if (tracer) console.log("  CO=" + String(CO - 1).padStart(2) +
      "  RI=" + String(RI).padStart(4) + "  op=" + op + " adr=" + adr +
      "  ACC=" + ACC);

    if (op === STOP) return { ACC, mem, cycles };
    if (op === CHARGE) ACC = mem[adr];
    if (op === AJOUTE) ACC = ACC + mem[adr];
    if (op === SOUSTRAIT) ACC = ACC - mem[adr];
    if (op === RANGE) mem[adr] = ACC;
    // Un branchement n'est rien d'autre qu'une écriture dans le CO.
    if (op === SAUTE_SI_ZERO && ACC === 0) CO = adr;
  }
  return { ACC, mem, cycles: -1 };
}

console.log("— addition —");
const r1 = executer([...MEM], true);
console.log("ACC =", r1.ACC, "| Mem[12] =", r1.mem[12]);

console.log("— soustraction 7 - 3 —");
const SOUSTRACTION = [...MEM];
SOUSTRACTION[1] = SOUSTRAIT * 100 + 11;
const r2 = executer(SOUSTRACTION, false);
console.log("Mem[12] =", r2.mem[12], "| attendu 4");

console.log("— le programme se modifie lui-même —");
// Rien ne distingue une case « programme » d'une case « donnée » : ranger
// une valeur à l'adresse 1 réécrit l'instruction 1. C'est tout von Neumann.
const AUTO = [
  CHARGE * 100 + 10,      // 0 : ACC <- 205  (= SOUSTRAIT 11, vu comme donnée)
  RANGE  * 100 + 2,       // 1 : écrit cette valeur À LA PLACE de l'instruction 2
  AJOUTE * 100 + 11,      // 2 : sera écrasée avant d'être exécutée
  RANGE  * 100 + 12,
  STOP   * 100 + 0,
  0, 0, 0, 0, 0,
  SOUSTRAIT * 100 + 11,   // 10 : une « donnée » qui est en fait une instruction
  3, 0,
];
const r3 = executer(AUTO, false);
console.log("Mem[2] est devenue", r3.mem[2], "— l'instruction a été réécrite");

En travaux pratiques

Travaux pratiques 5 · 3 h

Exécuter un programme à la main

Suivre le cycle chercher–décoder–exécuter cycle par cycle, registre par registre, jusqu'à ce que le mécanisme devienne évident — puis en mesurer le goulot.

Avant de commencer

  • Le TP 4 : registres, compteur, UAL
  • Un simulateur de machine de von Neumann, ou une feuille et un tableau de trace

Énoncé

  1. ChargerPlacez en mémoire un programme de quatre instructions qui calcule la somme de deux valeurs et la range. Notez le contenu de chaque adresse, instructions et données mélangées.
  2. La trace complèteExécutez pas à pas et remplissez un tableau : à chaque cycle, la valeur du compteur ordinal, du registre d'instruction, de l'accumulateur, et l'adresse mémoire accédée. Ne sautez aucune ligne. Indice : Le compteur ordinal s'incrémente pendant la phase de recherche, pas à la fin — c'est ce qui rend les sauts possibles.
  3. Compter les accèsSur votre trace, comptez combien de cycles accèdent à la mémoire et pour quoi faire : instruction ou donnée. Calculez la proportion.
  4. Le programme est une donnéeÉcrivez un programme qui MODIFIE une de ses propres instructions avant de l'exécuter. Faites-le tourner et expliquez pourquoi c'est possible.
  5. Ce que cela coûteÉcrivez maintenant un programme qui, par une erreur d'adresse, écrit une donnée par-dessus une instruction. Observez le comportement et rapprochez-le d'une classe de failles connue.
  6. L'autre architectureDessinez le même schéma avec des mémoires séparées pour les instructions et les données. Dites ce que l'étape 4 devient, et ce que le débit devient.
  7. Un sautAjoutez un branchement conditionnel et tracez une boucle de trois tours. Identifiez le cycle exact où le compteur ordinal cesse de s'incrémenter normalement.

C'est réussi quand

  • Votre trace ne comporte aucun trou : chaque cycle a ses quatre valeurs
  • Vous savez dire combien d'accès mémoire coûte UNE instruction de votre jeu
  • Vous savez expliquer en une phrase pourquoi une donnée peut devenir du code

Correction

Le programme, en mémoire
adresse | contenu        | rôle
 0    | LOAD  10       | instruction
 1    | ADD   11       | instruction
 2    | STORE 12       | instruction
 3    | HALT           | instruction
10    | 7              | donnée
11    | 5              | donnée
12    | (résultat)     | donnée

Rien dans la mémoire ne distingue l'adresse 0 de l'adresse 10. Le contenu de 10 est une instruction si le compteur ordinal y pointe, une donnée si une instruction la lit. C'est le principe du programme enregistré — l'idée qui distingue un ordinateur d'une machine à calculer.

La trace
cycle | phase    | CO | RI       | ACC | accès mémoire
1   | chercher |  0 | LOAD 10  |  ?  | lecture  @0 (instruction)
2   | exécuter |  1 | LOAD 10  |  7  | lecture @10 (donnée)
3   | chercher |  1 | ADD 11   |  7  | lecture  @1 (instruction)
4   | exécuter |  2 | ADD 11   | 12  | lecture @11 (donnée)
5   | chercher |  2 | STORE 12 | 12  | lecture  @2 (instruction)
6   | exécuter |  3 | STORE 12 | 12  | écriture @12 (donnée)
7   | chercher |  3 | HALT     | 12  | lecture  @3 (instruction)

Le compteur ordinal est incrémenté en phase de recherche, AVANT l'exécution : quand une instruction de saut s'exécute, elle écrase une valeur déjà avancée. C'est ce détail de chronologie qui explique que les adresses relatives se comptent depuis l'instruction suivante, et non depuis le saut lui-même.

Le goulot d'étranglement
7 cycles, 7 accès mémoire :
4 pour lire les instructions  (57 %)
3 pour les données           (43 %)

chaque instruction coûte AU MOINS un accès mémoire,
avant même de faire quoi que ce soit d'utile

Plus de la moitié du trafic mémoire ne sert qu'à savoir quoi faire. C'est le goulot de von Neumann, et il n'a pas disparu : les processeurs modernes le contournent avec un cache d'instructions séparé, une lecture anticipée et un pipeline. Le chapitre 7 mesurera ce que coûte un accès qui rate le cache.

Le code auto-modifiant, et son revers
   0 | LOAD  20     ; charge la valeur « ADD 11 »
 1 | STORE 3      ; l'écrit à l'adresse 3
 2 | LOAD  10
 3 | (vide)       ; devient « ADD 11 » avant d'être atteint
 4 | HALT

/* possible parce que STORE ne sait pas qu'il écrit du code */

Cette souplesse a été essentielle aux machines sans registre d'index. Son revers est direct : un débordement de tampon qui écrit au-delà d'une zone de données peut écraser du code ou une adresse de retour, et faire exécuter ce que l'attaquant a écrit. Toutes les protections modernes — pages non exécutables, W^X, signature de code — sont des restrictions ajoutées PAR-DESSUS ce principe, jamais une remise en cause.

Harvard, et le compromis
von Neumann : une mémoire, un bus
+ simple, souple, code auto-modifiant possible
- un seul accès à la fois : le goulot

Harvard : deux mémoires, deux bus
+ instruction et donnée lues dans le MÊME cycle
- impossible de modifier son propre code
- impossible de charger un programme comme une donnée…

en pratique : Harvard au niveau des CACHES,
von Neumann au niveau de la mémoire centrale

Les processeurs actuels ne choisissent pas : ils sont Harvard là où le débit compte (caches L1 séparés pour instructions et données) et von Neumann là où la souplesse compte (mémoire principale unifiée, ce qui permet à un système d'exploitation de charger un programme). Le compromis n'a pas été tranché, il a été déplacé.

Ce que la suite en fait

Le chapitre 6 ouvre le registre d'instruction et regarde comment un entier de 32 bits se découpe en code opération et opérandes ; il montrera aussi comment un pour de haut niveau devient une suite de branchements écrivant dans le compteur ordinal.

Le chapitre 7 s'attaque au goulot d'étranglement, et le chapitre 8 aux entrées/sorties et à la performance — dont la loi d'Amdahl, qui dira combien il est vain d'accélérer une partie de la machine sans regarder le reste.

À retenir

Flashcards · 4 cartes

Quelle est l'idée centrale du modèle de von Neumann, et sa conséquence la plus lourde ?
Ranger le programme DANS la mémoire, à côté des données. Conséquence : un programme peut en lire, écrire et produire un autre — d'où les compilateurs, les systèmes d'exploitation, et aussi les virus. Revers : rien ne distingue physiquement une instruction d'une donnée, seul le compteur ordinal en décide, ce qui rend possibles les attaques par exécution de données.
Quels sont les trois bus, et lequel fixe la quantité de mémoire adressable ?
Le bus d'adresses (unidirectionnel, du processeur vers la mémoire), le bus de données (bidirectionnel) et le bus de contrôle (lecture/écriture, horloge, interruptions). C'est le bus d'ADRESSES qui fixe la capacité : n fils donnent 2ⁿ cases — 32 fils donnent 4 Gio. Le bus de données ne fixe que la quantité transférée par accès.
Décrivez les trois phases du cycle d'exécution, et ce qui arrive au compteur ordinal.
Recherche : le CO adresse la mémoire, le mot lu entre dans le registre d'instruction, et le CO est incrémenté AUSSITÔT. Décodage : l'unité de commande découpe le RI en code opération et opérandes, et positionne les signaux. Exécution : l'UAL calcule, ou la mémoire est lue/écrite, ou le CO reçoit une nouvelle valeur s'il s'agit d'un branchement.
Qu'est-ce que le goulot d'étranglement de von Neumann, et quelles réponses lui apporte-t-on ?
Programme et données partagent la même mémoire et le même bus : le processeur passe son temps à attendre la mémoire — deux accès pour une seule instruction chargeant une donnée. Les processeurs ayant accéléré bien plus vite que la mémoire, l'écart s'est creusé. Réponses : l'architecture Harvard, qui sépare les deux mémoires, et surtout le cache (chapitre 7).

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.