Allocation contiguë et fragmentation, pagination et table des pages, segmentation ; mémoire virtuelle et pagination à la demande, défauts de page, FIFO, LRU, optimal et anomalie de Belady.
Un programme compilé contient des adresses. Si ces adresses étaient celles de la mémoire physique, deux programmes ne pourraient pas coexister sans se piétiner, il faudrait recompiler un programme pour le charger ailleurs, et rien n'empêcherait l'un de lire les données de l'autre.
Le système résout les trois problèmes d'un coup en interposant une traduction : le programme manipule des adresses logiques, le matériel les convertit en adresses physiques. C'est le sujet de ce chapitre, et c'est aussi son point difficile — la conversion d'une adresse logique en adresse physique se travaille par des exercices systématiques, pas par la lecture d'un schéma. Prévoyez d'en faire beaucoup.
Allocation contiguë et fragmentation
La première idée est de donner à chaque processus un bloc de mémoire d'un seul tenant. Le matériel n'a alors besoin que de deux registres, chargés au changement de contexte : un registre de base, ajouté à chaque adresse logique, et un registre de limite, qui provoque une exception si l'adresse la dépasse. Relocation et protection en deux additions.
Le problème arrive avec les départs. Les processus se terminent en laissant des trous ; les trous ne sont pas au bon endroit. On peut avoir 500 Mio libres au total et être incapable de loger un processus de 100 Mio parce que le plus grand trou en fait 80. C'est la fragmentation externe.
Trois stratégies de placement se disputent le choix du trou, et leur classement est contre-intuitif. Le premier ajustement prend le premier trou assez grand : rapide, et en pratique le meilleur. Le meilleur ajustement prend le plus petit trou suffisant : plus lent, et il produit une poussière de trous minuscules inutilisables. Le pire ajustement prend le plus grand : il laisse des restes exploitables mais détruit vite les grands trous.
On peut compacter — déplacer les processus pour réunir les trous — mais c'est coûteux et impossible si le programme contient des adresses physiques déjà calculées. La vraie solution est ailleurs : cesser d'exiger que la mémoire d'un processus soit d'un seul tenant.
La pagination
On découpe la mémoire logique en pages et la mémoire physique en cadres, tous de la même taille — typiquement 4 Kio. Une page quelconque peut aller dans un cadre quelconque, et une table des pages, propre à chaque processus, note où chacune se trouve.
La fragmentation externe disparaît : tout trou est un cadre, donc utilisable. Il reste une fragmentation interne, la partie inutilisée de la dernière page — en moyenne une demi-page par processus, soit 2 Kio, un prix dérisoire.
Le découpage de l'adresse
C'est ici qu'il faut ralentir. Une adresse logique n'est pas un couple à calculer : c'est un simple nombre, que le matériel lit en deux morceaux.
Adresse logique sur 32 bits, pages de 4 Kio (2¹² octets) 31 12 11 0┌───────────────────────────────────────┬───────────────────────┐│ numéro de page (20 bits) │ déplacement (12 bits)│└───────────────────────────────────────┴───────────────────────┘La taille de page fixe le découpage, et rien d'autre : , donc 12 bits de déplacement, et les 20 bits restants numérotent pages.
La traduction se fait en trois gestes :
1. numéro de page = adresse ÷ taille_page (les bits de poids fort)2. déplacement = adresse mod taille_page (les bits de poids faible)3. adresse physique = table[numéro] × taille_page + déplacementLe point que tout le monde manque au premier essai : le déplacement n'est jamais traduit. Il traverse la conversion inchangé, parce qu'une page et un cadre ont la même taille — le -ième octet d'une page est le -ième octet de son cadre. Seul le numéro change.
Déroulons un exemple complet, avec des pages de 4 Kio et une table qui dit que la page 2 est dans le cadre 7 :
adresse logique 90009000 ÷ 4096 = 2 reste 808 ↳ page 2, déplacement 808 table[2] = cadre 7 adresse physique 7 × 4096 + 808 = 29 480Un contrôle qui évite bien des erreurs : le déplacement doit toujours être strictement inférieur à la taille de page. S'il ne l'est pas, la division a été faite avec la mauvaise taille.
Le coût, et la TLB
La table des pages est en mémoire. Chaque accès du programme en demande donc deux : un pour lire la table, un pour l'octet voulu. La mémoire est deux fois plus lente, ce qui est inacceptable.
D'où le tampon de traduction (TLB), petit cache totalement associatif — au sens du chapitre 7 d'architecture — qui garde les traductions récentes. Grâce à la localité, quelques dizaines d'entrées suffisent à obtenir plus de 98 % de succès : la quasi-totalité des accès se traduisent sans toucher la table.
Le TLB est vidé à chaque changement de contexte, puisque les traductions appartiennent au processus sortant. C'est un coût caché du chapitre 4, qui s'ajoute à la sauvegarde des registres — et l'une des raisons pour lesquelles un quantum trop court est ruineux.
Enfin, la table elle-même est un problème : entrées par processus, à 4 octets, font 4 Mio de table pour un programme qui n'utilise peut-être que quelques pages. On la découpe donc en plusieurs niveaux, ce qui permet de ne matérialiser que les branches réellement employées.
Pages de 1 Kio, la table indique que la page 3 est dans le cadre 5. Quelle est l'adresse physique correspondant à l'adresse logique 3500 ?
Segmentation
La pagination découpe selon une taille arbitraire, qui ne correspond à rien dans le programme. La segmentation découpe selon la structure logique : un segment pour le code, un pour les données, un pour la pile, un par bibliothèque partagée. Une adresse y est un couple (numéro de segment, déplacement), et chaque segment porte sa propre longueur et ses propres droits.
L'avantage est la protection fine et le partage naturel : marquer le segment de code en lecture seule, ou le rendre visible à plusieurs processus, devient trivial. Le défaut est que les segments sont de tailles différentes — donc la fragmentation externe revient.
D'où la combinaison retenue par les architectures réelles : segmenter puis paginer. Le programme voit des segments, chacun est découpé en pages, et les pages vont dans des cadres quelconques. On garde la protection du premier et l'absence de fragmentation du second.
Mémoire virtuelle
Dernier étage, et le plus rentable. Rien n'oblige à ce que toutes les pages d'un processus soient en mémoire. Chaque entrée de la table porte un bit de présence ; les pages absentes sont sur le disque.
Toucher une page absente déclenche un défaut de page, qui est une exception matérielle, pas une erreur du programme. Le système la traite : il choisit un cadre libre — ou en libère un —, lit la page depuis le disque, met à jour la table, et réexécute l'instruction fautive, qui réussit cette fois. Le processus n'a rien vu, sinon qu'il a été bloqué au sens du chapitre 3 pendant plusieurs millisecondes.
Trois bénéfices, considérables. Un programme peut être plus grand que la mémoire physique. Le chargement à la demande ne lit que ce qui sert réellement, donc le démarrage est immédiat. Et plusieurs processus partagent les cadres du même code en lecture seule — les trois éditeurs du chapitre 3 ne coûtent qu'une copie.
Le coût est brutal, et c'est le chiffre à retenir : un accès mémoire prend 60 ns, un défaut de page 10 ms sur un disque magnétique, soit cent mille fois plus. Un taux de défaut de un pour mille suffit à doubler le temps moyen d'accès. C'est pourquoi la mémoire virtuelle n'est viable que si les défauts sont rarissimes — ce que la localité du chapitre 7 d'architecture rend possible.
Quand elle ne l'est plus, le système s'écroule (thrashing). Trop de processus, pas assez de cadres : chacun passe son temps à provoquer des défauts, le disque sature, l'utilisation du processeur s'effondre. Le piège est que l'ordonnanceur, voyant le processeur inoccupé, admet davantage de processus — et aggrave exactement le problème. La parade est la notion d'ensemble de travail : l'ensemble des pages qu'un processus utilise réellement dans une fenêtre de temps récente. Tant que la somme des ensembles de travail tient en mémoire, tout va bien ; dès qu'elle déborde, il faut suspendre un processus entier plutôt que d'en admettre un de plus.
Choisir la victime
Reste la question la plus concrète : quelle page évincer ? La même qu'au chapitre 7 d'architecture pour le cache, mais avec un enjeu cent mille fois supérieur.
FIFO évince la plus anciennement chargée. Simple, et mauvais : l'ancienneté du chargement ne dit rien de l'utilité, et une page chargée tôt mais utilisée en permanence sera sacrifiée.
Optimal évince celle dont la prochaine utilisation est la plus lointaine. Il est démontrablement le meilleur, et irréalisable — il faudrait connaître l'avenir. Il sert d'étalon : un algorithme réel se juge à son écart avec lui.
LRU évince la moins récemment utilisée. C'est le pari sur la localité temporelle, et il approche l'optimal de près. Sa version exacte coûte cher — il faudrait horodater chaque accès —, donc on l'approche : le matériel maintient un simple bit de référence par page, mis à 1 à chaque accès, et l'algorithme dit de la seconde chance parcourt les pages en cercle, remet à 0 les bits qu'il trouve à 1, et évince la première page dont le bit était déjà à 0.
Enfin, une bizarrerie de FIFO qui achève de le disqualifier : l'anomalie de Belady. Sur certaines suites de références, augmenter le nombre de cadres augmente le nombre de défauts. La suite 1 2 3 4 1 2 5 1 2 3 4 5 produit 9 défauts sur trois cadres et 10 sur quatre. LRU et l'optimal n'ont pas ce défaut.
Les trois cadres sont vides : le premier accès est nécessairement un défaut. On charge la page 1 dans le cadre 0, et le pointeur de victime avance.
Un serveur ralentit brutalement : l'utilisation du processeur tombe à 10 % alors que le disque est saturé. L'administrateur lance davantage de tâches, puisque le processeur semble libre. Que se passe-t-il ?
À vous
L'exercice attaque le point qui coince de front. Première partie : la traduction d'adresses, avec une table donnée, sur une série de cas dont certains doivent produire une exception — déplacement hors bornes, page absente, page en lecture seule écrite.
Seconde partie : les trois algorithmes de remplacement sur la même suite de références, avec le comptage des défauts. Puis la vérification de l'anomalie de Belady, en faisant varier le nombre de cadres de 3 à 5 — et en constatant que la courbe de FIFO n'est pas monotone, alors que celle de LRU l'est.
Traduisez des adresses logiques avec leurs exceptions, puis comparez FIFO, LRU et optimal.
// ── 1. Traduction d'adresses ────────────────────────────────────────────── const TAILLE_PAGE = 1024; // 1 Kio, donc 10 bits de déplacement // Une entrée de table : le cadre, la présence, les droits. const TABLE = [ { cadre: 5, present: true, ecriture: false }, // page 0 : code, lecture seule { cadre: 9, present: true, ecriture: true }, // page 1 : données { cadre: 2, present: false, ecriture: true }, // page 2 : sur le disque { cadre: 7, present: true, ecriture: true }, // page 3 ]; function traduire(adresseLogique, ecrit = false) { const page = 0; // ← à écrire const deplacement = 0; // ← à écrire if (page >= TABLE.length) return { erreur: "hors de l'espace d'adressage" }; const e = TABLE[page]; if (!e.present) return { erreur: "DÉFAUT DE PAGE sur la page " + page }; if (ecrit && !e.ecriture) return { erreur: "violation : écriture en lecture seule" }; return { page, deplacement, cadre: e.cadre, physique: 0 }; // ← à écrire } const CAS = [ { a: 3500, ecrit: false }, // page 3, déplacement 428 -> 7 x 1024 + 428 { a: 0, ecrit: false }, { a: 1023, ecrit: false }, // dernier octet de la page 0 { a: 1024, ecrit: false }, // premier octet de la page 1 { a: 500, ecrit: true }, // écriture en lecture seule { a: 2100, ecrit: false }, // page absente { a: 9000, ecrit: false }, // au-delà de la table ]; for (const c of CAS) { const r = traduire(c.a, c.ecrit); console.log(String(c.a).padStart(5) + (c.ecrit ? " (écriture)" : " ") + " -> " + (r.erreur ?? "page " + r.page + ", dépl. " + String(r.deplacement).padStart(4) + ", cadre " + r.cadre + " => physique " + r.physique)); } // ── 2. Remplacement de pages ────────────────────────────────────────────── const SUITE = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5]; function fifo(suite, cadres) { const memoire = []; let defauts = 0; for (const page of suite) { if (memoire.includes(page)) continue; defauts++; if (memoire.length === cadres) memoire.shift(); // le plus ancien CHARGÉ memoire.push(page); } return defauts; } function lru(suite, cadres) { return 0; } // ← à écrire function optimal(suite, cadres) { return 0; } // ← à écrire // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Complétez traduire() : numéro de page, déplacement, adresse physique. // 2. Écrivez lru (évincer la moins récemment UTILISÉE) et optimal (évincer // celle dont la prochaine utilisation est la plus lointaine). // 3. Faites varier le nombre de cadres de 3 à 5 et cherchez l'anomalie de // Belady : chez qui la courbe n'est-elle pas décroissante ? for (const c of [3, 4, 5]) console.log(c + " cadres : FIFO " + fifo(SUITE, c) + " défauts");
En travaux pratiques
La mémoire qu'on croit avoir
Constater que l'adresse manipulée par un programme n'existe pas, mesurer le coût d'un défaut de page, et faire paginer la machine volontairement.
- Le TP 7 d'Architecture : hiérarchie mémoire
- gcc, et l'accès à /proc
- 1. La même adresse deux fois
Affichez l'adresse d'une variable globale. Lancez le programme dans deux terminaux en même temps et comparez. Que concluez-vous ?
- 2. Voir la carte
Faites afficher par votre programme le contenu de son propre fichier maps dans /proc. Identifiez le code, les données, le tas, la pile, et les bibliothèques.
- 3. Allouer sans consommer
Allouez un gigaoctet avec malloc sans y écrire, et regardez la mémoire réellement utilisée. Écrivez ensuite un octet toutes les 4096 positions et regardez de nouveau.
- 4. Compter les défauts de page
Mesurez les défauts de page mineurs et majeurs de votre programme. Distinguez-les et dites lequel coûte cher.
- 5. Faire paginer la machine
Allouez et écrivez plus que la mémoire physique disponible, dans une machine virtuelle. Observez le système. Notez le moment exact où la réactivité s'effondre.
- 6. Projeter un fichier
Lisez un gros fichier de deux façons : avec read dans un tampon, et avec mmap. Chronométrez et expliquez la différence.
- 7. Le partage invisible
Après un fork, faites afficher par le père et le fils l'adresse et la valeur d'une même variable. Modifiez-la dans le fils et réaffichez les deux.
- Deux exécutions simultanées affichent la même adresse pour une variable différente
- Vous montrez qu'un gigaoctet alloué ne consomme presque rien
- Vous savez distinguer un défaut mineur d'un majeur et donner leur coût respectif
Ce que la suite en fait
Le bloc V descend d'un cran. La mémoire virtuelle de ce chapitre suppose un disque où ranger les pages absentes ; le chapitre 7 explique comment ce disque est organisé, et le chapitre 8 comment on lui parle.
Le lien est plus étroit qu'il n'y paraît. Le fichier projeté en mémoire, que le chapitre 7 présentera, applique la mécanique de la pagination à un fichier ordinaire : on l'ouvre, on obtient une plage d'adresses, et les lectures disque se font toutes seules par défaut de page. Le système de fichiers et la mémoire virtuelle sont deux visages du même mécanisme.
À retenir
Vous avez parcouru les 9 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.