Add memory section
This commit is contained in:
parent
d1b4c5e512
commit
b95fe8380b
12 changed files with 11500 additions and 0 deletions
470
content/systemes_exploitation/4-Memoire/index.md
Normal file
470
content/systemes_exploitation/4-Memoire/index.md
Normal file
|
@ -0,0 +1,470 @@
|
|||
---
|
||||
title: "Systèmes d'exploitation : Gestion de la mémoire"
|
||||
date: 2021-09-24
|
||||
tags: ["système", "mémoire", "pagination", "thread"]
|
||||
categories: ["Systèmes d'exploitation", "Cours"]
|
||||
---
|
||||
|
||||
Au départ étaient les systèmes mono-tâche et mono-utilisateur. Les programmes
|
||||
accédaient à toute la mémoire disponible. Il n'y avait pas besoin de mécanisme
|
||||
spécifique de gestion. Mieux, **l'adresse en mémoire de notre programme était
|
||||
connu à l'avance** au moment de la compilation. Simple quand il y a un seul
|
||||
processus en mémoire.
|
||||
|
||||
Le vénérable MS-DOS par exemple ne proposait aucune protection mémoire, **même
|
||||
la table d'interruptions** était accessible (et modifiable) par les programmes.
|
||||
Il était alors possible d'adresser 1Mo de mémoire dont 640Ko de RAM.
|
||||
|
||||
L'accès aux routines du systèmes se faisait par les interruptions en passant
|
||||
l'adresse d'une sous-routine:
|
||||
|
||||
* int 08h — Timer interrupt
|
||||
* int 10h — Video services
|
||||
* Int 16h — Keyboard services
|
||||
* int 21h — MS-DOS services
|
||||
|
||||
```asm
|
||||
; SC_PutChar == 0x02 la sous routine de notre
|
||||
; interruption 21h (MS-DOS services)
|
||||
|
||||
mov ah, 02h ; on passe la sous-routine
|
||||
mov dl, ‘A’
|
||||
int 21h
|
||||
mov ah, 4Ch ; SC_Exit == 0x4C
|
||||
mov al, 0 ; EXIT_SUCCESS
|
||||
int 21h
|
||||
```
|
||||
|
||||
## Les systèmes multi-tâches
|
||||
|
||||
Les opérations disques, à l'origine sur des bandes magnétiques, prenaient du
|
||||
temps. Afin de rentabiliser l'utilisation des processeurs qui passaient la
|
||||
plupart de leur temps à "glander", sont apparus les systèmes multi-tâches. Le
|
||||
principe est simple : permettre à **plusieurs processus de cohabiter en
|
||||
mémoire**. Plusieurs problèmes se posent alors :
|
||||
|
||||
* Comment compiler un programme alors que nous ne connaissons pas son
|
||||
emplacement en mémoire?
|
||||
* Comment éviter les problèmes de fragmentation de la mémoire?
|
||||
* Comment gérer l'expansion des processus en mémoire?
|
||||
* Comment gérer la protection de la mémoire.
|
||||
|
||||
## La compilation d'un programme multi tâche
|
||||
|
||||
Il est du coup impossible pour le compilateur de connaitre à l'avance les
|
||||
adresses mémoires utilisées par notre programme. Il faut donc trouver un
|
||||
mécanisme pour gérer ce problème.
|
||||
|
||||
Le compilateur fournit alors une liste d'emplacement des pointeurs. Il génère
|
||||
contient alors **le code plus une table de correspondance** à l'étape de
|
||||
liaison. Ces informations seront positionnée dans la section ELF `.rel.text`
|
||||
(x86_32) ou `.rela.text` (x86_64) du binaire.
|
||||
|
||||
Le problème est donc réglé non seulement au moment **de la compilation** mais
|
||||
aussi **de son chargement** par le système.
|
||||
|
||||
## Fragmentation mémoire et expansion des processus
|
||||
|
||||
Il arrive forcément un moment ou le système doit copier et / ou déplacer
|
||||
des elements en mémoire. Ce sont des opérations coûteuses.
|
||||
|
||||
Lorsque un processus demande beaucoup de mémoire à l'aide de `malloc()`, le
|
||||
système doit prendre une décision : **mettre le processus ailleurs ou en bouger
|
||||
d'autres afin de faire de la place?**
|
||||
|
||||
La fragmentation est un problème complexe, on l'abordera tout au long de ce
|
||||
chapitre.
|
||||
|
||||
## Protection de la mémoire
|
||||
|
||||
Afin de s'assurer qu'un processus *P1* ne puisse pas accéder à l'espace mémoire
|
||||
de *P2* nous devons mettre en place des protections. Doit-on le faire au moment
|
||||
de la compilation? Mais que faire des accès indirects?
|
||||
|
||||
Demander au compilateur de **lancer des vérifications lors de chaque accès
|
||||
mémoire**? En plus d'être très coûteuse, cette solution est inefficace.
|
||||
|
||||
Encore une fois la solution **vient des fabricants des microprocesseurs** pour
|
||||
nous permettre deux choses:
|
||||
|
||||
* **contrôle** des accès.
|
||||
* **relocation efficace**.
|
||||
|
||||
Seul deux registres sont nécessaires : *limit* `<` et *base* `+`. Ceux-ci sont
|
||||
renseignés à chaque changement de contexte. Le systèmes initialise l'adresse de
|
||||
base et la limite du processus en cours.
|
||||
|
||||
Une interruption *SegFault* est renvoyée si le processus ne respecte pas ces
|
||||
deux valeurs.
|
||||
|
||||

|
||||
|
||||
Le schema ci-dessus montre l'utilisation de ces deux registres afin de
|
||||
déterminer l'adresse mémoire physique à partir de l'adresse logique fournie par
|
||||
le processus. Le second accès est invalide: l'adresse demandée dépasse la
|
||||
limite, une **erreur de segmentation** est alors envoyée.
|
||||
|
||||
La conversion d'une *adresse logique* en *adresse physique* ne demande aucun
|
||||
effort supplémentaire à l'unité centrale. L'isolation mémoire des processus est
|
||||
maintenant garantie par le matériel. Mais ce mécanisme empêche tout de même le
|
||||
partage de segments mémoire entre processus (et nos librairie partagées alors?).
|
||||
|
||||
### Base et Limit par segment, gestion des droits
|
||||
|
||||
Ceci étant dit, pourquoi garder les segments mémoire (code, data, tas, pile)
|
||||
ensemble? Nous pouvons les séparer, mais pout ça il va nous falloir plusieurs
|
||||
*base* et *limit*. Du coup, lors d'un accès à la mémoire nous **devons connaitre
|
||||
le segment concerné**.
|
||||
|
||||

|
||||
|
||||
Le compilateur préfixe l'adresse mémoire par le segment.
|
||||
|
||||
Ce mode de fonctionnement permet plus de souplesses dans l'allocation de la
|
||||
mémoire. Il permet aussi le partage d'espace de segments entre processus : il
|
||||
suffit qu'ils aient les **même *base* et *limit***. Il peuvent même partager le
|
||||
code, utile pour économiser de la RAM.
|
||||
|
||||
### Droits sur les segments
|
||||
|
||||
Il est du coup intéressant d'ajouter un peu plus de protection. Un registre
|
||||
supplémentaire, *mode* : il permet de spécifier les droits en lecture, écriture
|
||||
et execution.
|
||||
|
||||

|
||||
|
||||
Ces droits sont positionnés par le CPU
|
||||
|
||||
Mais tous ces mécanismes ne permettent pas de régler le problème de
|
||||
fragmentation. pour ça il faut trouver autre chose.
|
||||
|
||||
## La pagination mémoire
|
||||
|
||||
Son principe est simple: créer des espaces mémoire de taille fixe, **une page**
|
||||
(ou frame en anglais -- 4ko sur x86). Du point de vue du système, une page est
|
||||
soit libre, soit occupée. Lors de l'allocation, le système arrondi la taille
|
||||
demandée pour déterminer le nombres de pages à allouer.
|
||||
|
||||
Il n'est pas garanti que des pages contigües soient allouées **de façon contigüe
|
||||
en mémoire**.
|
||||
|
||||
### Adresses virtuelles
|
||||
|
||||
Lorsque le processeur execute du code en espace utilisateur, il ne voit que les
|
||||
adresses memoires virtuelles. Il faut donc traduire ces adresses virtuelles en
|
||||
adresse physique (RAM).
|
||||
|
||||
Prenons l'exemple d'une variable `i` où `&i=8320`. Pour des pages de *4Ko* notre
|
||||
variable se trouvera dans la troisième (2 x 4096) page avec un décalage de 128
|
||||
bits. On utilisera ce décalage pour trouver `i` comme dans le schéma
|
||||
ci-dessous:
|
||||
|
||||

|
||||
|
||||
Les adresses mémoire sont stockée sur 32bits. La représentation binaire notre
|
||||
adresse mémoire virtuelle correspond au schema ci-dessous:
|
||||
|
||||

|
||||
|
||||
1. Cette partie de l'adresse (20 bits) nous permet de connaitre la page
|
||||
mémoire. Comme nous le verrons plus tard, cette partie servira pour la table
|
||||
de pagination.
|
||||
2. Cette partie fait office de décalage, une fois la page physique trouvée on
|
||||
se sert de ce dernier comme indique sur le schéma précédent.
|
||||
|
||||
|
||||
### Table de pagination
|
||||
|
||||
Nous avons besoin de traduire les adresses virtuelles en adresses physiques,
|
||||
comment pouvons nous procéder? L'utilisation d'une **table de correspondance**
|
||||
est toute indiquée.
|
||||
|
||||
Une table par processus est indiquée avec une capacité de 2^20 entrées. Chaque
|
||||
entrée de ces tables a une taille de 20bits, arrondi à 32 - 4 octets. Un des
|
||||
bits est utilisé pour indiquer si la page est allouée ou non (champ *valid*)
|
||||
comme le tableau ci-dessous:
|
||||
|
||||
|
||||
page viruelle| page physique | valide |
|
||||
:---: | :---:
|
||||
0 | 4 | 1
|
||||
1 | 18| 1
|
||||
2 | | 0
|
||||
3 | 2 | 1
|
||||
4 | | 0
|
||||
5 | | 0
|
||||
6 | 23 |1
|
||||
... | ...
|
||||
|
||||
Mais nous verrons plus tard que d'autres bits seront utilisés...
|
||||
|
||||
Problème cependant, **chaque table prends donc 4Mo de place**, c'est beaucoup!
|
||||
Le CPU a besoin de connaitre la table des pages en cours. Pour cela il faut un
|
||||
**registre spécial** mis à jour à chaque changement de contexte. La table a
|
||||
juste besoin d'être référencée par un pointeur.
|
||||
|
||||
## MMU - Memory Management Unit
|
||||
|
||||
La MMU est un élément du microprocesseur, son rôle est de transformer les
|
||||
adresses logiques en adresse physique. La MMU se compose d'un registre contenant
|
||||
la page des tables (aussi appelée table de translation) et d'un circuit
|
||||
permettant la conversion.
|
||||
|
||||

|
||||
|
||||
Voici un premier schéma de fonctionnement de la MMU. Les 20 premiers bits de
|
||||
l'adresse virtuelles permettent de vérifier la table physique. lors de la
|
||||
translation, la MMU vérifie au passage **la validité de la page**.
|
||||
|
||||
Mais il faut tout de même ajouter un peu de sécurité à tout ça comme vu lors du
|
||||
chapitre sur les registres *limit*, *base* et *mode*.
|
||||
|
||||

|
||||
|
||||
Dans l'exemple ci-dessous, le processeur fait un accès mémoire en écriture. Lors
|
||||
de la traduction de l'adresse, la page est noté en lecture et exécution: **une
|
||||
exception** et lancée.
|
||||
|
||||
La MMU est un circuit matériel, il n'empêche que son fonctionnement introduit
|
||||
des accès mémoires supplémentaire et donc **penalise les performances**. Sachant
|
||||
qu'un accès mémoire consomme une centaine de cycles! Nous avons donc deux
|
||||
problème majeurs:
|
||||
|
||||
1. Les performances pénalisée
|
||||
2. L'empreinte mémoire des tables de pages
|
||||
|
||||
### Réduire l'empreinte mémoire
|
||||
|
||||
Dans les fait, une table des pages contient généralement une majorité de **pages
|
||||
invalides**. Il serait possible de la compresser, mais on perdrait l'indexation
|
||||
-- et donc les avantages d'un tableau. La solution: utiliser plusieurs niveau de
|
||||
tables organisées de façon hiérarchique. Les processeurs et systèmes modernes
|
||||
comportent en général 4 niveaux.
|
||||
|
||||
Avec cette technique, lorsqu'il n'y a que des entrées valides dans une table de
|
||||
niveau n, on pose `NULL` dans l'entrée correspondante de la table n-1. Le noyau
|
||||
alloue des tables de niveau n (ou n > 1) seulement lorsqu'il en a besoin.
|
||||
|
||||

|
||||
|
||||
On économise de la mémoire, mais au prix encore une fois de **perte de
|
||||
performances**. Il faut maintenant trois accès mémoire : un premier accès pour
|
||||
lire une entrée dans un répertoire de table de pages (page directory), un second
|
||||
pour lire une entrée dans une table de pages (page table), et un dernier pour
|
||||
accéder à la page physique.
|
||||
|
||||
### TLB -- Translation Lookaside Buffer
|
||||
|
||||
Pour éviter le problème, la MMU intègre un cache permettant de stocker les
|
||||
dernières opérations effectuées. Celui-ci mémorise *l'adresse de la page
|
||||
virtuelle*, *l'adresse de pa page physique* et *le mode*.
|
||||
|
||||
Le TLB est un cache associatif rapide, de type LRU -- *Last Recently Used* --
|
||||
lorsqu'il est plein, la ligne la plus ancienne est évincée et remplacée. Il est
|
||||
en général limité à 32 / 64 entrées (c'est un type de cache coûtant cher en
|
||||
fabrication).
|
||||
|
||||
Afin de permettre la cohabitation de plusieurs processus dans le cache, il
|
||||
enregistre une autre information : le *tag* -- un pointeur vers la page de table
|
||||
concerné par l'entrée.
|
||||
|
||||
Les processeurs modernes comporte en général deux TLB de premier niveau : un
|
||||
pour les **instructions** (iTBL) et un autre pour **les données** (dTLB). Ils
|
||||
contiennent aussi plusieurs niveau : un TLB de premier niveau privée et rapide
|
||||
puis un de second niveau partagé et plus lent.
|
||||
|
||||
## Côté du noyau
|
||||
|
||||
Nous avons vu comment la memoire est utilisée en espace utilisateur, mais
|
||||
comment tout ça se passe en espace noyau? Particulièrement lors d'un appel
|
||||
système? Car le noyau doit avoir accès **aux deux espaces**
|
||||
|
||||
Comme on pouvait se douter, le noyau utilise aussi la pagination. Il suffit de
|
||||
rajouter un bit afin de déterminer si la page appartient à l'utilisateur *(1)*
|
||||
ou au noyau *(0)*. Les entrées noyau sont positionnée en bas, comme sur
|
||||
l'exemple ci-dessous.
|
||||
|
||||

|
||||
|
||||
Les entrées pagination du noyau sont partagées entre toutes les pages via un de
|
||||
simple pointeurs depuis la page de niveau 1, ainsi tous les processus **"voient"
|
||||
les pages mêmes pages du noyau**. Sur Linux 32bits, 3Go sont allouées aux
|
||||
processus et 1Go au noyau. En version 64bits toute la mémoire physique est
|
||||
alloué à l'espace d'adressage virtuel du noyau.
|
||||
|
||||
## Meltdown - faille de sécurité matérielle
|
||||
|
||||
Cette faille, largement médiatisée et documentée touche les processeurs Intel,
|
||||
IBM Power et certains ARM. Elle tire parti des conditions d'execution
|
||||
particulières de code dans les processeurs modernes : ils sont capable
|
||||
d'exécuter les instructions dans le désordres ([out of order
|
||||
execution](l_woutoforder)) et même faire de l'exécution spéculative
|
||||
|
||||
L'exécution spéculative, correspondant au lancement anticipé d'instructions.
|
||||
Mais celles-ci ne doivent **pas être validée en cas d'erreur de prédiction**.
|
||||
Mais est-ce vraiment le cas?
|
||||
|
||||
### Un petit programme de test.
|
||||
|
||||
Pour bien comprendre comment fonctionne l'exécution, prenons un code d'exemple:
|
||||
|
||||
```c
|
||||
char array [N * 4096];
|
||||
// mais que vaut data?
|
||||
int data = <...>;
|
||||
char c;
|
||||
*((int *)NULL) = 12;
|
||||
|
||||
// Nous n'arriverons jamais jusqu'ici, nous
|
||||
// aurons droit a un segfault!
|
||||
c = data[data *4096];
|
||||
```
|
||||
|
||||
La première instruction va forcément produire un *segfault*, `c` ne sera pas
|
||||
modifié. Mais a cause de l'execution spéculative, la seconde instruction a été
|
||||
exécutée avant le lancement de l'exception.
|
||||
|
||||
Donc la zone mémoire `array[data * 4096]` a été lue, et son adresse est présente
|
||||
dans le cache. En mesurant les temps d'accès pour chacun des `array[ i * 4096]`,
|
||||
il nous est possible de deviner la caleur de `data`.
|
||||
|
||||
### Lire une donnée du noyau
|
||||
|
||||
Maintenant comment utiliser tout cela pour lire une donnée du noyau? voici un
|
||||
petit exemple en assembleur:
|
||||
|
||||
```asm {linenos=table}
|
||||
;rxc: adresse noyau
|
||||
;rbx: adresse de base de notre tableau
|
||||
retry:
|
||||
mov al, byte rxc ;al: partue de rax (8bits)
|
||||
shl rax, 0xcc ;decallage de 12bits - 4096
|
||||
jz retry ;reessayer l'execution spéculative
|
||||
mov rbx, qword[rbx + rax]
|
||||
```
|
||||
|
||||
La première instruction ligne 4 va générer une exeption, mais parrallèllement
|
||||
l'instruction ligne 7 sera exécutée puis annulée. Top tard, les adresses
|
||||
memoires seront dans le cache.
|
||||
|
||||
La répetition ce des commandes pour les adresses noyau permet done de lire
|
||||
**toute la memoire physique** (démarre à `0xffff 8800 0000 0000` sous Linux sans
|
||||
*Kernel Address Space Layour Ransomization*). Les auteurs de la découverte ont
|
||||
réussi à lire la memoire à une vitesse de 503 Ko/s.
|
||||
|
||||
### Et comment l'éviter?
|
||||
|
||||
Il n'est bien entendu **pas question de désactiver l'exécution spéculative**,
|
||||
les fabrivcant de puces s'y refusent. Et pour cause, l'impact sur les
|
||||
performances est trop importante.
|
||||
|
||||
Mais si on y réfléchit, Meltdown pose problème parceque **les pages du noyau
|
||||
sont projetées dans la table des processus** comme nous l'avons vu plus haut. La
|
||||
solution alors: *Kernel Page Table Isolation* ou KPTI. La table des pages du
|
||||
noyau n'est accessibles qu'en mode noyau.
|
||||
|
||||

|
||||
|
||||
Comme on peu le voir sur le schema ci-dessus, les pages noyau sont invisible en
|
||||
mode utilisateur, ainsi il est impossible de lire des pages mémoire noyau en
|
||||
utilisant Meltdown. Conéquence directe : **une perte de performance entre 5 et
|
||||
25%** msesuré sur une architecture Inter Haswell / Skylake.
|
||||
|
||||
## Optimiser la pagination
|
||||
|
||||
Dans le but d'accélerer l'allocation mémoire qui console beaucoup de cycle CPU,
|
||||
les systèmes utilisent des optimisations. Dans cette partie, nous allons
|
||||
explorer deux pistes:
|
||||
|
||||
* l'allocation paresseuse / à la demande
|
||||
* le copy on write
|
||||
|
||||
### L'allocation à la demande
|
||||
|
||||
Son principe est simple: **reporter à plus tard** l'allocation des pages qu'un
|
||||
processus y accèdera pour la première fois.
|
||||
|
||||
Il est rare qu'un processus utilise les sements de pile qui luis sont allouées
|
||||
par défaut sous *Linux*. Il est aussi possible que certain tableaux alloués
|
||||
statiquements ne soient pas utilisés. Dans le même ordre d'idée il y a des
|
||||
fonctions d'un programme que l'on utilise pas.
|
||||
|
||||
Bien entendu un minimum de pages sont allouées au départ afin de permettre le
|
||||
bon fonctionnement du processus. Mais alors **comment distinguer les erreur de
|
||||
segmentation d'une page non encore allouée?** Car une page non allouée est
|
||||
marquée **invalide** par la MMU.
|
||||
|
||||
Le cheminement doit être le suivant:
|
||||
|
||||
1. On accède à une page non encore allouée
|
||||
2. Une exception **page invalide**
|
||||
3. La MMU positione l'adresse mémoire dans un registre
|
||||
4. Le noyau vérifie si l'adresse virtuelle est présent dans une structure VMA
|
||||
5. Si c'est le cas lancement d'un `get_free_page()` et correction de la table
|
||||
des page. Sinon on envoi un `SIGSERV` au processus.
|
||||
|
||||
#### la structure Virtual Memory Area
|
||||
|
||||
Elle est évoquée ci-dessus, cette struture permet au noyau de garder une trace
|
||||
des pages virtuelles qui lui sont allouées, elle prend la forme suivante:
|
||||
|
||||
```c
|
||||
struct vm_area_struct {
|
||||
unsigned lzong vm_start;
|
||||
unsigned long vm_end;
|
||||
pgprot_t vm_page_prot;
|
||||
unsigned short vm_flags;
|
||||
struct file * vm_file;
|
||||
```
|
||||
#### conséquences
|
||||
|
||||
Les larges allocations mémoires sont faites à la demande, une page à la fois,
|
||||
évitant de nombreux cycle CPU au démarrage du processus.
|
||||
|
||||
### Copy on write
|
||||
|
||||
Le fonctionnement historique de la création de processus sous Unix (encore en
|
||||
place aujourd'hui) se fait à base de `fork()` et `exec()`:
|
||||
|
||||
* `fork()` créer un espace d'adressage contenant une copie de son processus
|
||||
père, un clone en fait.
|
||||
* `exec()` charge un nouveau programme (dans le segment code).
|
||||
|
||||
Le fonctionnent du `fork` et du `exec` a déjà été abordé lors du [chapitre des
|
||||
processus]({{< ref "../../progsys/5_les-processus_legers/index.md#création-de-processus">}}
|
||||
des cours le Lpro. en voici un exemple en C:
|
||||
|
||||
```c
|
||||
int main (int argc, char *argv[])
|
||||
{
|
||||
pid_t pid = fork ();
|
||||
if (pid) { // Parent
|
||||
wait (NULL);
|
||||
} else { // Child
|
||||
execl ("/bin/ls", "ls", "-l", NULL);
|
||||
perror ("ls");
|
||||
exit (EXIT_FAILURE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
La séquence formée de ces deux fonctions est coûteuse (surtout la copie des
|
||||
espaces d'adressage), y-a-t-il un moyen de faire mieux. Surtout qu'après le
|
||||
`fork()`, `exec()` va reinitialiser la plupart des pages, c'est donc en prime
|
||||
inefficace.
|
||||
|
||||
La table des pages n'est que pointeurs, alors pourquoi ne pas la copier du
|
||||
processus père vers le fils? C'est là que le *Copy on Write* entre en jeu :
|
||||
|
||||
1. La table des pages est dupliquée
|
||||
2. Les pages passent en lecture seule
|
||||
3. Lorque'un processus veux accéder à une page **en écriture**, le noyau lui
|
||||
donne sa copie à lui.
|
||||
4. Le noyau corrige la table des pages.
|
||||
|
||||
Pour s'assurer que l'accès est autorisé en écriture, le système utilise la
|
||||
structure *Virtual Memory Area*.
|
||||
|
||||
[l_woutoforder]:https://fr.wikipedia.org/wiki/Ex%C3%A9cution_dans_le_d%C3%A9sordre
|
Loading…
Add table
Add a link
Reference in a new issue