--- 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` 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: ![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