Partie 3 - Mémoire¶
Support présentationIntroduction¶
Dans un système multi-tâche et multi-utilisateur, la mémoire est une ressource partagée entre plusieurs processus (navigateur internet, jeu, IDE, serveur web, …) et le noyau du système. Le système doit la gérer efficacement et isoler correctement chaque processus.
Plutôt que de fournir directement les adresses physiques (adresse réelle en RAM) aux programmes, l’OS leur attribue une mémoire virtuelle : chaque processus dispose ainsi d’un espace d’adressage indépendant, ce qui offre ces avantages :
Isolation : un processus ne peut pas lire ou modifier la mémoire d’un autre.
Flexibilité : déplacement de processus en mémoire sans modification du code.
Optimisation : partage possible de certaines zones (ex. code exécutable) entre processus.
Support du swap : possibilité d’exécuter des programmes plus grands que la RAM.
Hiérarchie mémoire¶
Les temps d’accès mémoire ne sont pas tous équivalents en termes de vitesse et de capacité. Ils dépendent de la zone où les informations sont gardées. Vous aurez plus de détails sur le fonctionnement du cache et des registres dans les cours d’assembleur.
La hiérarchie avec une estimation des temps d’accès est la suivante :
Registres CPU ~ 0,3 ns (le plus rapide)
Cache L1 ~ 1 ns
Cache L2 ~ 3-5 ns
Cache L3 ~ 10-15 ns
RAM ~ 50-100 ns
SSD NVMe ~ 50-100 µs
HDD ~ 5-10 ms (le plus lent)
Swap similaire au disque utilisé
Plus on monte dans la hiérarchie (vers le CPU et en particulier l’unité de calcul), plus c’est rapide mais c’est une mémoire coûteuse, limitée en place et donc de faible capacité. Plus on descend, plus c’est lent mais peu coûteux avec moins de problèmes de place.
Différences de prix :
cache : 550€ pour quelques Mo pour les processeurs AMD Ryzen 7 9800X3D ou Intel Core i9 14900K (bien entendu il n’y a pas que la mémoire cache qui rentre en compte dans le prix)
barrettes de RAM : environ 200€ pour 2x32Go
un SSD NVMe : environ 150€ pour 2 To
un HDD : environ 130€ pour 4 To
Mécanismes de base¶
Pagination et segmentation
Sous les systèmes Linux modernes, la mémoire est découpée en pages de taille fixe (souvent 4 Ko, vérifiable avec getconf PAGESIZE) gérées par le système.
Historiquement, certains systèmes utilisaient aussi la segmentation matérielle (division de la mémoire en taille variable, géré par le compilateur, peut causer des problèmes de segmentation), mais les systèmes modernes reposent principalement sur la pagination.
Rôle de la MMU
La MMU (Memory Management Unit), ou unité de gestion de mémoire, est un composant matériel chargé de traduire les adresses virtuelles en adresses physiques en fonction de la table des pages du processus. Si un processus tente d’accéder à une page qui ne lui appartient pas, le noyau provoque une erreur de segmentation (segmentation fault).
Zones logiques et espace d’adressage d’un processus¶
Chaque processus possède son propre espace d’adressage virtuel, divisé en plusieurs zones de mémoire ou segments mémoire :
Text segment : code exécutable du programme, généralement en lecture seule pour éviter des modifications accidentelles ou malveillantes du code
Rodata segment (read-only data segment) : les données constantes du programme (littéraux de chaînes comme « Hello world! », constantes, tables de constantes générées par le compilateur) en lecture seule.
Data segment : variables globales et statiques initialisées
BSS segment (Block Started by Symbol) : variables globales/statiques non initialisées (mises à zéro au démarrage du programme)
Heap : (le tas en français) zone d’allocation dynamique (
malloc/free)Stack : (la pile en français) variables locales et contexte d’appels de fonctions (LIFO), gérée automatiquement par le système. Les données sont empilées à chaque appel de fonction puis dépilés à la fin de la fonction (principe LIFO, Last In, First Out)
En plus de ces segments principaux, un processus peut aussi contenir :
Zones mappées (memory mapped regions) : créées par
mmap, elles peuvent contenir du code (bibliothèques partagées), des fichiers mappés, ou de la mémoire anonyme utilisée par certains allocateurs pour de gros blocs.Bibliothèques partagées (shared libraries) : zones contenant du code et des données chargées dynamiquement (souvent situées entre le heap et le stack).
Thread-Local Storage (TLS) : mémoire privée à chaque thread, utilisée pour stocker des variables locales au thread.
I/O Memory : zones mappées pour la communication directe avec des périphériques.
Kernel space : zone mémoire réservée au noyau (non accessible depuis l’espace utilisateur), parfois mappée dans l’espace virtuel pour des raisons d’efficacité sur certaines architectures.
En pratique¶
Le pseudo-système de fichiers /proc permet d’inspecter l’espace mémoire d’un processus.
La commande suivante affiche la cartographie mémoire du processus courant (ici cat, self fait référence au processus directement, pour regarder pour un autre processus, utilisez son pid):
> cat /proc/self/maps
5b07fff55000-5b07fff57000 r--p 00000000 fc:01 11797247 /usr/bin/cat
5b07fff57000-5b07fff5b000 r-xp 00002000 fc:01 11797247 /usr/bin/cat
5b07fff5b000-5b07fff5d000 r--p 00006000 fc:01 11797247 /usr/bin/cat
5b07fff5d000-5b07fff5e000 r--p 00007000 fc:01 11797247 /usr/bin/cat
5b07fff5e000-5b07fff5f000 rw-p 00008000 fc:01 11797247 /usr/bin/cat
5b0814475000-5b0814496000 rw-p 00000000 00:00 0 [heap]
79f615800000-79f615ff0000 r--p 00000000 fc:01 11799968 /usr/lib/locale/locale-archive
79f616000000-79f616028000 r--p 00000000 fc:01 11813669 /usr/lib/x86_64-linux-gnu/libc.so.6
79f616028000-79f6161bd000 r-xp 00028000 fc:01 11813669 /usr/lib/x86_64-linux-gnu/libc.so.6
79f6161bd000-79f616215000 r--p 001bd000 fc:01 11813669 /usr/lib/x86_64-linux-gnu/libc.so.6
79f616215000-79f616216000 ---p 00215000 fc:01 11813669 /usr/lib/x86_64-linux-gnu/libc.so.6
79f616216000-79f61621a000 r--p 00215000 fc:01 11813669 /usr/lib/x86_64-linux-gnu/libc.so.6
79f61621a000-79f61621c000 rw-p 00219000 fc:01 11813669 /usr/lib/x86_64-linux-gnu/libc.so.6
79f61621c000-79f616229000 rw-p 00000000 00:00 0
79f61637d000-79f6163a2000 rw-p 00000000 00:00 0
79f6163b7000-79f6163b9000 rw-p 00000000 00:00 0
79f6163b9000-79f6163bb000 r--p 00000000 fc:01 11808014 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
79f6163bb000-79f6163e5000 r-xp 00002000 fc:01 11808014 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
79f6163e5000-79f6163f0000 r--p 0002c000 fc:01 11808014 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
79f6163f1000-79f6163f3000 r--p 00037000 fc:01 11808014 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
79f6163f3000-79f6163f5000 rw-p 00039000 fc:01 11808014 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffc7627f000-7ffc762a0000 rw-p 00000000 00:00 0 [stack]
7ffc762ed000-7ffc762f1000 r--p 00000000 00:00 0 [vvar]
7ffc762f1000-7ffc762f3000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
les premières lignes correspondent au segments du binaire :
r-xp= section exécutable → textrw-p= données modifiables → data et BSSr--p= données en lecture seule → rodata et autres données en read-only
[heap]: la zone d’allocation dynamiqueLignes avec
/usr/lib/...so: bibliothèques partagées[stack]: pile du processus.[vvar]: variables noyau exposées en lecture seule (timers, horloge).[vdso]: bibliothèque spéciale du noyau pour certaines fonctions système rapides (ex.gettimeofdaysans syscall).[vsyscall]: ancien mécanisme x86 32/64 bits, maintenu pour compatibilité.
Note
Sous Linux, les programmes sont généralement au format ELF (Executable and Linkable Format).
Ce format découpe le code et les données en sections (.text, .data, .bss, .rodata, etc.).
Au lancement, le loader ELF mappe ces sections en mémoire virtuelle, ce qui explique la structure observée dans /proc/self/maps.
Exemples de sections ELF typiques :
.text→ le code exécutable.rodata→ données constantes en lecture seule (chaînes, tables).data→ variables globales/statiques initialisées.bss→ variables globales/statiques non initialisées.eh_frame→ infos pour exceptions/débogage.symtabet.strtab→ tables des symboles et des noms
Les permissions affichées dans /proc/self/maps (r-xp, r--p, rw-p) correspondent aux droits d’accès de ces sections une fois mappées en mémoire.
Optimisations matérielles et logicielles¶
TLB (Translation Lookaside Buffer) Petit cache matériel dans la MMU qui garde les correspondances récentes virtuel→physique.
TLB hit : traduction immédiate
TLB miss : consultation de la table des pages (plus lente)
Un taux élevé de TLB miss peut ralentir fortement des accès mémoire dispersés.
Demand Paging & Lazy Loading Au lancement, Linux ne charge pas toutes les pages d’un programme.
Les pages sont chargées uniquement à leur première utilisation (page fault).
Lazy loading : retard volontaire du chargement de certaines données jusqu’à leur usage.
Démarrage plus rapide, mais premier accès plus lent.
À noter qu’une page fault n’est pas « grave » et est un comportement normal de la machine à l’opposé d’une segmentation fault qui est un réel problème d’accès en mémoire dans le code.
Copy-On-Write (COW)
Lors d’un fork(), les pages sont partagées en lecture seule.
Elles sont copiées uniquement si l’un des processus les modifie.
Ce qui évite les copies inutiles de grandes zones mémoire et accélère le fork + exec.
Voir plus d’informations dans la partie sur les processus
Swap Le swap est un espace disque utilisé comme extension de la RAM. Lorsqu’il n’y a plus de place en mémoire physique, le noyau déplace certaines pages rarement utilisées vers le swap. Cela permet :
D’exécuter plus de programmes simultanément
Mais au prix de temps d’accès beaucoup plus lents (disque)
Un usage excessif du swap (thrashing) ralentit fortement le système.
Allocation mémoire¶
Il existe plusieurs manières pour un programme de demander de la mémoire au système :
Allocation statique : espace réservé à la compilation (variables globales/statiques dans le
data segmentouBSS segment).Allocation automatique : sur la pile (stack), pour variables locales et les appels de fonction, libérées automatiquement en fin de portée. Un exemple d’erreur sur cette allocation est le dépassement de la taille de pile (stack overflow) lors de l’appel à une fonction récursive sans condition d’arrêt :
// fonction récursive sans condition de fin void f(int n) { f(n + 1); } int main() { f(5); return 0; }
L’execution affichera
Erreur de segmentation (core dumped).valgrinddonnera un peu plus d’informations avec :==43722== Stack overflow in thread #1: can't grow stack to 0x1ffe801000 ==43722== ==43722== Process terminating with default action of signal 11 (SIGSEGV) ==43722== Access not within mapped region at address 0x1FFE801FFC ==43722== Stack overflow in thread #1: can't grow stack to 0x1ffe801000 ==43722== at 0x109135: f (in test_stack_overflow) ==43722== If you believe this happened as a result of a stack ==43722== overflow in your program's main thread (unlikely but ==43722== possible), you can try to increase the size of the ==43722== main thread stack using the --main-stacksize= flag. ==43722== The main thread stack size used in this run was 8388608.
Allocation dynamique : sur le tas (heap), via
malloc,calloc,realloc, nécessite unfreemanuel (voir Gestion mémoire en C).
En plus de ces trois méthodes d’allocations les plus fréquemment utilisées, on retrouve aussi :
Allocation via
mmap: utilisée par certains allocateurs internes pour de gros blocs.Allocation alignée (
posix_memalign,aligned_alloc) : utile pour SIMD et I/O directes.Allocation persistante (via mémoire non-volatile ou fichiers mappés).
À noter que les temps d’accès au différents types de mémoire ne sont pas les mêmes, une allocation automatique (stack) ou dynamique (heap) sera plus rapide qu’une allocation statique :
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N 10000000000UL
long global_variable = 0;
int main() {
long stack_variable = 0;
static long static_local = 0;
long *dynamic_variable = malloc(sizeof(long));
*dynamic_variable = 0;
printf("Adresses :\n");
printf("Automatique (stack) : %p\n", (void *)&stack_variable);
printf("Dynamique (heap) : %p\n", (void *)dynamic_variable);
printf("Statique locale : %p\n", (void *)&static_local);
printf("Globale : %p\n", (void *)&global_variable);
printf("Temps :\n");
clock_t start;
clock_t end;
size_t i = 0;
start = clock();
for (i = 0; i < N; i++)
stack_variable++;
end = clock();
printf("Automatique (stack) : %.3f sec\n",
(double)(end - start) / CLOCKS_PER_SEC);
start = clock();
for (i = 0; i < N; i++)
(*dynamic_variable)++;
end = clock();
printf("Dynamique (heap) : %.3f sec\n",
(double)(end - start) / CLOCKS_PER_SEC);
start = clock();
for (i = 0; i < N; i++)
static_local++;
end = clock();
printf("Statique locale : %.3f sec\n",
(double)(end - start) / CLOCKS_PER_SEC);
start = clock();
for (i = 0; i < N; i++)
global_variable++;
end = clock();
printf("Globale : %.3f sec\n",
(double)(end - start) / CLOCKS_PER_SEC);
free((void *)dynamic_variable);
return 0;
}
Résultat à l’exécution (sans optimisations) :
Adresses :
Automatique (stack) : 0x7ffd7d2c1c50
Dynamique (heap) : 0x64749b2132a0
Statique locale : 0x647470a26020
Globale : 0x647470a26018
Temps :
Automatique (stack) : 6.837 sec
Dynamique (heap) : 8.458 sec
Statique locale : 30.910 sec
Globale : 30.795 sec
Comme on peut le voir ici, les adresses sur le stack sont plus hautes (0x7...) que celles des autres segments.
La variable dynamique (heap) se trouve dans une zone d’adresses inférieure, mais distincte, et les variables globale et statique sont dans la même zone (segment de données).
Au niveau des performances, on observe :
Stack → accès le plus rapide : l’adresse est calculée comme un simple décalage par rapport au pointeur de pile (RSP, voir le cours d’assembleur), et la variable reste souvent en cache L1 ou même dans un registre tout au long de la boucle.
Heap → légèrement plus lent : nécessite de charger l’adresse pointée puis d’accéder à la valeur, mais reste généralement en cache si elle est utilisée intensivement.
Globales et statiques → beaucoup plus lents : le compilateur ne peut pas toujours les garder dans un registre, car elles pourraient être modifiées ailleurs dans le programme (signal ou autre fonction, par exemple), il doit donc les relire régulièrement depuis la mémoire (RAM).
Fuites et erreurs mémoire¶
Une mauvaise gestion (dans votre code, pas par le système) peut entraîner :
memory leak : bloc non libéré
invalid access : lecture/écriture hors zone allouée
Double free : libération deux fois de la même zone
Use-after-free : utiliser un pointeur après avoir libéré la mémoire
Buffer overflow / underflow : écriture hors des limites d’un tableau alloué
Memory corruption : écrasement de métadonnées de l’allocateur (dangereux)
Dangling pointer : pointeur qui référence une zone invalide
Data race : accès concurrent non synchronisé
Bonnes pratiques :
Toujours libérer après usage
Mettre le pointeur à
NULLaprèsfreeUtiliser
valgrindouAddressSanitizerpour détecter les problèmes