cours/content/systemes_exploitation/4-Memoire/index.md

19 KiB
Raw Blame History

title date tags categories
Systèmes d'exploitation : Gestion de la mémoire 2021-09-24
système
mémoire
pagination
thread
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
; 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

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

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

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

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

  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

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

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

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

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) 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:

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:

1 2 3 4 5 6 7 ;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

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:

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:

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.