Cours 3 · Le processeurLeçon 2 sur 2
Jeu d'instructions et assembleur
10 h de lecture9 sections Version PDF
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 + iSept 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 registres : le nombre n'est pas
choisi, il découle du format. Seize bits pour l'immédiat, donc une constante comprise entre
et ; 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 | Écriture | L'opérande est… |
|---|---|---|
| Immédiat | addi $t0, $zero, 5 | dans l'instruction elle-même |
| Registre | add $t0, $t1, $t2 | dans un registre |
| Direct (absolu) | lw $t0, 2000 | à l'adresse écrite dans l'instruction |
| Indirect par registre | lw $t0, 0($t1) | à l'adresse contenue dans un registre |
| Basé avec déplacement | lw $t0, 8($t1) | à l'adresse $t1 + 8 |
| Relatif au compteur ordinal | beq $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
» — 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.
En MIPS, quelle est la différence entre addi $t0, $t1, 8 et lw $t0, 8($t1) ?
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 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.
addi 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 à ».
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 ?
À 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.
Traduisez un si-sinon puis une boucle de haut niveau en assembleur MIPS réduit.
// ── 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");
En travaux pratiques
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.
- Le TP 5 : cycle et compteur ordinal
- Un simulateur MIPS ou RISC-V (MARS, rars) et gcc
- 1. Premiers pas
Dans 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 condition
Traduisez à la main un si-sinon en assembleur. Comptez le nombre d'étiquettes nécessaires et dites pourquoi la traduction inverse la condition.
- 3. Traduire une boucle
Traduisez 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 convention
Rendez 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'optimisation
Recompilez 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é.
- 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
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
Vous avez parcouru les 9 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.