https://lafor.ge/feed.xml

Introduction à la Virtualisation (Partie 1)

2023-07-12

Bonjour à toutes et tous 🙂

Bienvenue dans un article encore une fois bien trop long.

Ce devait être l'introduction d'un article sur la création d'image disque dans un pipeline Gitlab CI, je vais pas vous le cacher, ça a un peu dérapé... 🤭

Je vous propose un tour d'horizon des concepts de la virtualisation que j'utiliserai par la suite dans l'article prévu au début.

Mais comme je sais pas m'arrêter et que j'aime que n'importe qui puisse comprendre quelque soit son niveau de départ, il y aura de l'extra 😁

Avant de commencer.

D'abord, un grand merci à Samuel Ortez, Julien Durillon et Pierre-Antoine Grégoire pour la présentation que je vais tenter de résumer et compléter sans déformer dans les lignes qui vont suivre.

Prenez une inspiration, on descends ! 🤿

Code Machine et Hardware

Oui, comme ça direct. ^^

Ne vous inquietez pas je vais vous guider.

Code Machine

Premièrement ce qu'il faut savoir, c'est qu'un ordinateur est extrêment idiot, il ne parle ni français ni anglais, il ne connais qu'un langage, le Code Machine, basiquement des 0 et des 1.

Impossible de lui donner à manger du C, du Java ou du JavaScript, il n'est pas fait pour ça.

Dans un ordinateur, ce qui est responsable de comprendre et d'exécuter du code se nomme un CPU (Central Processing Unit).

le CPU ne prend que du code machine

De même, un CPU parle un Code Machine bien spécifique et ne saura comprendre un Code Machine différent.

Ce code machine doit être adapté au CPU

 

J'ai fait abstraction de l'interprétation et de la semi-compilation, plus de détail dans cet article

CPU

Voyons un peu plus en détail les différents composants d'un ordinateur.

Le premier, nous l'avons déjà nommé est le CPU.

Anatomie d'un CPU

Il est composé de deux parties principales (je fais abstraction de ce qui nous intéresse pas ).

  • Les Registres : R1, R2, ... et Acc
  • L' Arithmetic Logic Unit ou ALU ou UAL en français (Unité Arithmétique et Logique)

Les Registres sont des petites mémoires qui ne permettent (en simplifiant) que de stocker une valeur.

Plus d'infos dans cet article

Ils sont en quelques sortes l'établi utiliser par un artisan pour poser ses planches avant la découpe.

Dans ces registres, un est un peu spécial et se nomme l'Accumulateur, ici je le représente par un Acc.

Si les registres sont l'établi, alors l'ALU sont les outils de l'artisan, son travail est de manipuler les registres pour faire des opérations mathématique ou des transferts de données.

RAM

Mais je l'ai dit plus haut, un registre ne contient qu'une seule valeur, ici on a 5 registres, donc on peut stocker 5 valeurs, nos programmes vont être courts 🤭

L'idée est alors de stocker ailleurs la donnée, la place sur le CPU étant limitée, on vient stocker dans un autre composant appelé la RAM.

Connexion entre le CPU et la RAM

Le CPU communique avec la RAM au moyen de deux canaux :

  • bus d'adresse : défini ce que le CPU veut atteindre
  • bus de données : transfert les données dans le sens CPU -> RAM ou RAM -> CPU

Ici je fais abstraction du cache CPU, d'ailleurs c'est en partie pour ça que les Apple M1 sont très rapides, ils ont beaucoup de cache ^^

J'assimile toute mémoire de travail à de la RAM pour plus de facilité.

Le programme dans la RAM

On peut globalement y stocker n'importe quoi, tant que ce sont des 0 et des 1. Et ça tombe bien notre programme est du Code Machine pouvant se représenter par du binaire.

Chaque donnée est adressée par un nombre.

Dans cet exemple le CPU demande le contenu de l'adresse @500 à la RAM.

Chouette on peut stocker plus de 5 valeurs. 🙂

Périphériques

Mais le CPU à part faire des calculs, il ne sert pas à grand chose, il faut au moins une imprimante comme dans l'ancien temps pour lire le résultat.

missing alt

C'est pour cela que l'on a inventé les Périphériques. Ceux-ci peuvent être n'importe quoi: un disque dur, la carte graphique, la carte réseau, l'imprimante qui marche jamais quand vous en avez besoin, etc ...

Périphériques

Assembleur et Instructions

C'est bien beau tout ça mais le binaire et l'esprit humain cela fait 2 (sans mauvais jeu de mots ^^'), c'est pour cela que l'on ne manipule pas directement les 0 et les 1.

On le remplace par une sorte de langage qui est transcriptible en 0 et 1.

Assembleur

Ce langage se nomme de l'Assembleur ou ASM

Il existe autant d'assembleur qu'il y a de de Code Machine différents et plusieurs Assembleur différents peuvent mener au même Code Machine.

Cet assembleur est la première abstraction créé par l'Humain pour l'aider à parler le Machine sans devenir fou!

Un exemple d'assembleur (rassurez-vous on détaille après 🙂).

LOD @5

Va être transcrit en utilisant le Code Machine de notre CPU fictif en 0101010

Load opcode

Notre ligne de programme en Assembleur peut alors se décomposer en deux parties

  • OpCode : ce que l'on doit faire
  • Opérandes : avec quoi ?

Un autre exemple d'instruction

STD @5

Qui devient

Store opcode

On voit ici que le OpCode change la transcription binaire, il aurait été de même si l'opérande avait également évolué.

La langue du Code Machine est appelé un set d'instructions.

Instructions

Nous avons l'alphabet attaquons-nous aux mots.

La langue du CPU est décomposé en instructions, voici celle de notre CPU fictif.

OpCodeOperande 1Operande 2Signification
MOVRXDDéplace la valeur D vers le registre RX, D peut être un autre registre
LODRX@ACharge la valeur à l'adresse @A depuis la RAM vers le registre RX
STDRX@AStocke la valeur contenu dans le registre RX à l'adresse @A de la RAM
ADDXAjoute la valeur X à Acc, X peut être un registre
OUTX#VXEcrire X sur le périphérique #VX
JMP@ASauter à l'instruction à l'adresse @A
JNZRX@ASauter à l'instruction à l'adresse @A, si la valeur dans le registre RX n'est pas 0
HLTStop le CPU et met fin au programme

Maintenant que nous avons des mots, nous allons pouvoir faire des phrases.

Exécution d'un programme

Ces phrases vont être notre programme.

Je vous propose celui-ci.

Programme d'exemple

Chaque ligne du programme est stockée à une adresse, ici on débute à l'adresse @500, biensûr c'est la représentation binaire qui est stockée et non l'assembleur.

Nous avons donc 3 questions à résoudre :

  • La ligne du programme est dans la RAM, comment la transmettre au CPU ?
  • Une fois dans le CPU, comment fait-il pour comprendre la ligne ?
  • Que se passe-t-il à l'exécution de la ligne ?

Pour cela, nous allons introduire 3 notions:

  • Fetch
  • Decode
  • Exec
Fetch - Decode - Execute qui se chaînent avec des flèches

Notre curseur d'exécution est à l'adresse @500, ce qui veut dire que la ligne d'instruction à rechercher dans la RAM est à l'adresse @500.

Le CPU demande donc le contenu de l'adresse @500 à la RAM au travers du bus d'adresses, la RAM lui renvoie ce contenu.

Cette opération est appelée le Fetch.

Opération de Fetch

Une fois le contenu binaire de l'adresse @500 en main, celui-ci est décodé.

Opération de Decode

Ici le décodage nous annonce que l'opération à réaliser de mettre la valeur 1 dans le registre Acc.

MOV ACC, 1

Maintenant que le CPU, sait quoi faire, il va pouvoir l'exécuter.

Opération de Decode

Dans le cas présent, mettre un 1 dans le registre Acc.

On passe alors à la ligne suivante en incrémentant le curseur d'exécution.

Et du coup, fetch de l'adresse @501 cette fois-ci.

Opération de Fetch

Décodage

Opération de Decode

en

MOV R1, 4

Puis exécution

Opération d'Execute MOV

Nous avons maintenant un 1 dans l'accumulateur et un 4 dans le registre R1.

On déroule le programme.

Celui-ci nous demande d'additionner le contenu de l'accumulateur avec le contenu du registre R1 et de mettre le résultat dans l'accumulateur.

ADD R1
Opération d'Execute ADD

Nous avons maintenant 5 dans l'accumulateur.

On continue.

Plus compliqué, on nous demande de stocker le contenu de l'accumulateur dans la RAM à l'adresse @15.

STD ACC, @15
Opération d'Execute STD

Le CPU va adresser la RAM pour que la valeur 5 contenue dans l'accumulateur soit stockée à l'adresse @15.

Ligne suivante, on fait le chemin inverse, on demande de charger le contenu de l'adresse @15 dans le registre R1.

Opération d'Execute LOD

Nous avons maintenant 5 à la place de 1 dans le registre R1

On déroule.

Nous devons déplacer ce qui est dans R1 dans R2. Facile.

MOV R2, R1
Opération d'Execute MOV

Nous avons maintenant 5 dans le registre R2.

Plus difficile, nous avons utilisé les registres, l'ALU, la RAM, mais pas encore les périphériques.

Nous allons demander d'écrire le contenu du registre R2 sur le périphérique #12.

Ici, nous allons dire que c'est du disque dur.

Chaque périphérique est un micro ordinateur, qui possède également des registres.

OUT R2, #12 

Vient écrire sur le périphérique en voie 1 dans le registre X2, la valeur contenue par le registre R2.

Opération d'Execute OUT

5 est maintenant écrit dans le registre, le disque viendra alors faire quelque chose avec cette valeur, mais ceci est hors scope de notre article.

La dernière instruction, éteint le CPU.

HLT

Multi-Processus

Race condition

Généralement, nous n'avons pas qu'un seul programme qui tourne sur un ordinateur.

Chacun de ces programmes sont stockés dans la RAM à des addresses différentes.

  • Notre programme 1 débute à l'adresse @126
  • Tandis que le programme 2 à l'adresse @500
Deux programme en RAM

Comme nous n'avons qu'un seul CPU, et qu'il ne peut pas faire deux choses en même temps, il faut faire les choses de manière séquentielle.

Nous allons départager le temps que nous passons dans chaque programme.

D'ailleurs lorsqu'un programme tourne, nous allons l'appeler un processus.

Schedule

Chaque passage entre processus sera appelé un context switch, on verra par la suite de quoi il en retourne.

Voici les deux programmes en questions.

Maintenant expert en assembleur, vous devriez les comprendre. ^^

Deux programmes

On accélère la montre pour se placer à l'instruction

STD ACC, @15

du programme 1, celle-ci écrit comme tout à l'heure la valeur 5 à l'adresse @15.

C'est alors qu'intervient le context switch, le processus 1 doit laisser la main au processus 2.

Instruction STD processus 1

Nous sommes juste avant le début du programme 2, le switch de contexte ne peut pas faire grand chose, il est sensé remettre les valeurs du contexte d'exécution du processus 2.

Mais comme le programme n'a jamais tourné, c'est plus du bruit qui est défini ou bien les valeurs précédentes restent inchangées.

En tout cas, en l'état le processus 2, ne sait pas ce qu'il y a dans les registres.

Context switch entre processus 1 et processus 2

Le programme 2 se déroule et atteint une instruction étrangement familière.

STD R1, @15

Comme un ordinateur est déterministe, une même action, provoque le même résultat.

A ceci prêt que l'opération précédente avait défini la valeur de R1 comme étant 0

Instruction STD processus 2

Donc maintenant l'adresse @15 vaut 0 !!!

Nous venons de créer une race condition : deux acteurs qui utilisent la même ressources sans se synchroniser sur l'écriture.

Temps écoulé pour le processus 2, il passe la main.

Le context switch restaure les états des registres pour que le processus 1 puisse de nouveau travailler.

On remet :

  • 4 dans le registre R1
  • 5 dans l'accumulateur

On restaure également le pointeur d'exécution.

Context switch entre processus 2 et processus 1

On incrémente le curseur d'exécution.

LOD R1, @15
LOD processus 1

Ah bah oui, mais non ...

Le 5 n'est plus là. Il a été précédemment remplacé.

Le programme 1, n'est désormais plus déterministe, il est capable de faire n'importe quoi.

Echec et mat !

Sommes-nous condamner à n'exécuter qu'un seul programme par CPU.

Non, biensûr que non.

Mais pour ça, nous allons avoir besoin de plus de choses.

Kernel et MMU

Il va nous falloir un chef de gare pour gérer un peu la discipline dans les rangs.

Je vous le présente, il s'agit du Kernel.

Kernel

Son rôle va être de gérer les processus et de les empêcher de faire n'importe quoi.

Kernel et processus

Et par n'importe quoi, je veux dire écrire n'importe où dans la mémoire.

Le but étant d'éviter la race condition, mais plus généralement que chaque processus reste chez lui et ne puisse pas aller taper de la mémoire qui ne lui appartient pas ou qui est déjà utilisée.

Il a encore un milliard de rôles différents supplémentaires, mais on reste simple, c'est de la vulgarisation, pas un cours.

Pour l'aider dans sa tâche de cloisonnement, le Kernel peut faire appel à un outil.

Celui-ci se nomme la Memory Management Unit.

MMU

Il s'agit globalement d'une liste d'adresse virtuelles qui pointent sur des adresses physique.

Pourquoi virtuelle ? Parce qu'elles n'ont aucune existence physique, elle n'existent pas dans la RAM, au contraire de l'adresse physique, qui est et bien physiquement présente dans le silicium.

Pour permettre de s'y retrouver et aussi d'avoir plusieurs fois la même adresse virtuelle pour deux adresses physiques différentes, on découpe la MMU en tables.

Ici par exemple on a deux tables, dans ces deux tables l'adresses virtuelle 15 existe, mais ne pointe pas vers la même adresse physique.

Table du MMU

Dans la table 1

$$15 \Rightarrow 1005$$

Dans la table 2

$$15 \Rightarrow 22$$

La MMU est un composant physique à la différence du Kernel qui est du logicielle.

La MMU en action

Nous allons utiliser cette propriété pour résoudre notre problème de tout à l'heure.

Revenons dans le temps.

Nous sommes dans le processus 1, juste avant le stockage de la valeur en RAM.

Nous allons interposer la MMU sur le chemin du bus d'adressage.

Le CPU fait ce qu'il faisait avant, demander de stocker à l'adresse @15, mais la MMU est configurée par le Kernel pour utiliser la table p1.

Ainsi, l'adresse subit la modification suivante:

$$15 \Rightarrow 1013$$

Translation d'adresse

Notre 5 est alors stocké à l'adresse physique @1013 et non @15.

Cette fois-ci, c'est au processus 2 de vouloir stocker son 0 à l'adresse @15.

Mais même principe, le Kernel défini la table de la MMU sur p2.

Ainsi, l'adresse réellement utilisée devient

$$15 \Rightarrow 2862$$

Translation d'adresse

Son 0 est alors stocké à l'adresse physique @2862 et non @15.

Lorsque le processus 1 revient à la vie.

Il va lors du chargement de l'adresse @15, là aussi passer par le MMU.

Ainsi ce n'est pas @15 mais à nouveau @1013 qui est utilisé pour adresser la RAM.

Translation d'adresse

Et donc c'est bien un 5 et non un 0 qui est désormais stocké dans le registre R1.

Le programme peut de nouveau continuer. 🌞

  • La MMU est un composant physique.

  • La MMU est une partie essentielle de la sécurité d'un ordinateur, sans lui un programme malveillant ou mal conçu pourrait réécrire la mémoire d'un autre programme ou même le programme lui-même.

  • Du fait que l'isolation soit matérielle, cela rend très difficile le contournement de la sécurité.

Syscall

De la même manière que nous ne voulons pas qu'un processus prenne la main sur la mémoire de ses petits camarades, nous allons isoler nos processus de l'accès direct aux ressources physiques.

Pour 2 raisons:

  • Un programme ne sait pas forcément parler "Disque dur"
  • On veut pas qu'il puisse effacer ce qu'il veut le Kernel compris (oui les programmes sont sur disque avant d'arriver dans la RAM ^^)
Syscall

Lorsque qu'un processus désire réaliser une manipulation qui nécessite autre chose que de la RAM, ce processus va réaliser une doléance au Kernel, cette demande est appelée un Syscall.

Le syscall que nous allons utiliser se nomme read. Son boulot est demander la lecture d'une ressource un fichier par exemple qui serait sur le disque dur.

Mais de la même manière que le processus ne savait pas parler "Disque Dur", le Kernel ne sait pas non plus.

Heureusement, le Driver lui parle à la fois Kernel et Disque Dur, se sera donc notre interprète dans la transaction.

Pour les besoins du dessin je simplifie tout, mais il y a bien plus de chose à dire ^^

Détaillons ce qu'il se passe lors de la lecture de ce fichier.

Syscall

Le processus vient définir qu'il a besoin en mettant l'information dans un registre (ici pour l'exemple R2 ), le nombre est ce qu'on appelle un file descriptor, c'est un identifiant qui est connu du Kernel et qu'il associe à une ressource, ici un fichier déjà ouvert par un précédent syscall open.

Syscall

Un JMP est une instruction appelée un Jump, celui-ci vient déplacer le curseur d'exécution à l'adresse définie ici @809 qui correspond au début de notre syscall imaginaire et simplifié read_fd.

Le syscall fait des trucs, dont stocker de la donnée à l'adresse @10005.

C'est alors que des mécanismes un peu complexes et que je n'ai pas totalement saisis vont aller réveiller le Kernel qui piquait une sieste en attendant qu'il se passe quelque chose d'intéressant.

Syscall

Cette magie noire se nomme une interruption.

Les instructions qui vont suivre sont appelées privilégiées car elle ne peuvent être exécuté que dans un mode spécifique du CPU, mode qui ne peut être atteint que par le Kernel et ses modules dont les drivers.

Il va alors faire ce que le syscall lui demande, lire le fichier, mais comme il ne sait toujours pas parler Disque dur, il fait appel au Driver qui lui sait.

Syscall

Finalement le Disque dur est lu, au travers de l'opération

OUT R2, #12

Le trajet de retour vers le processus est une autre histoire, peut-être un jour dans un autre article ^^"

Petit récapitulatif de ce qui s'est déroulé.

Syscall

Conclusion

Ainsi s'achève la première partie de notre épopée dans la Virtualisation, vous avez l'intuition de ce qui se passe dans un ordinateur physique.

Dans la seconde partie on commencera à esquisser le fonctionnement de la virtualisation elle même.

Merci de votre lecture ♥️

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.