Add memory section

This commit is contained in:
Yorick Barbanneau 2022-01-07 00:06:56 +01:00
parent d1b4c5e512
commit b95fe8380b
12 changed files with 11500 additions and 0 deletions

View 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.
![Exemple d'utilisation des registres *base* et *limit*](images/base_limit.svg)
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é**.
![registres *base* et *limit* pour chaque segments](images/base_limit_segments.svg)
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.
![L'ajout du registre mode](images/base_limit_right.svg)
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``&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:
![Correspondance page virtuelle / memoire](images/memoire_paginee_correspondance.svg)
Les adresses mémoire sont stockée sur 32bits. La représentation binaire notre
adresse mémoire virtuelle correspond au schema ci-dessous:
![Composition de l'adresse virtuelle](images/bits_adresse_memoire.svg)
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.
![Fonctionnemenmt de la MMU: conversion d'adresse](./images/mmu_simple.svg)
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*.
![Fonctionnemenmt de la MMU: exception liée aux
droits](./images/mmu_right_exception.svg)
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.
![Fonctionnemenmt de la MMU: deux niveau de tables](images/mmu_table_2.svg)
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.
![Pages noyau et pages utilisateur](./images/page_noyau.svg)
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.
![Pages noyau indisponible en mode utilisateur](./images/page_noyau_meltdown.svg)
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