Partie 3 - Mémoire¶
⚠️ Certains codes de cette partie sont volontairement bugués pour montrer le fonctionnement de la mémoire. Lisez attentivement le code avant de l’exécuter et ne le réutilisez pas tel quel.
p3e1 - Évolution du stack¶
On va chercher à visualiser la croissance du stack ou call stack (la pile d’appels ou pile d’exécution en français) en créant un code bugué.
Créez une fonction récursive sans condition d’arrêt, qui déclare une variable demandant « beaucoup » de mémoire (par exemple,
char buf[1024]), puis affiche le niveau de récursion et l’adresse de la variable.Compilez le programme sans optimisation. Selon la version de GCC, les flags
-Wall -Wextra -pedanticaffichent un avertissement (warning)warning: infinite recursion detected, mais le programme est quand même compilé.Exécutez le programme, qui va rapidement planter, et regardez l’évolution des adresses : elles décroissent d’environ 1 Kio (la taille de
buf, soit0x400en hexadécimal) plus quelques octets à chaque appel. Le stack croît vers les adresses basses, comme sur le schéma du cours.En plantant, le programme affiche une erreur du type
Erreur de segmentation (core dumped)(core dumped : le noyau a éventuellement écrit une image mémoire du processus, un core dump, exploitable avec un debugger). Lancez-le avec Valgrind :gcc -std=c2x -Wall -Wextra -pedantic -g p3/p3e1.c && valgrind ./a.out
Un message vous donnera plus d’informations sur le problème :
Stack overflow in thread #1: can't grow stack to 0x1ffe801000On voit une erreur de type stack overflow (débordement de pile).
Le stack a dépassé sa taille maximale : relisez la section Le stack pas à pas du cours pour comprendre pourquoi chaque appel récursif consomme de la place sur le stack et pourquoi cette place est limitée.
p3e2 - Évolution de la heap¶
On va maintenant chercher à visualiser la croissance de la heap, toujours avec un code bugué.
Écrivez une longue boucle (
1000000000000itérations : unintne suffit pas, utilisez un compteur de typesize_t, affiché avec%zu).À chaque itération, demandez un bloc de 64 Kio avec
malloc(64 * 1024)dans un pointeurchar *et testez simalloca renvoyéNULL(dans ce cas, appelezperroret quittez).Écrivez dans chaque case du bloc (par exemple
p[j] = 1;dans une boucle) : tant qu’une page n’a pas été écrite, Linux ne lui attribue pas de mémoire physique (voir demand paging).Affichez le numéro de l’itération et l’adresse du bloc.
Ne libérez pas la mémoire et passez à l’itération suivante.
Compilez le programme (
gcc -std=c2x -Wall -Wextra -pedantic -g p3/p3e2.c), sans le lancer.
⚠️ Sans limite, ce programme peut remplir la RAM puis le swap en quelques secondes et bloquer la machine. On limite donc d’abord la mémoire disponible.
ulimit -v N limite la mémoire virtuelle des programmes lancés depuis ce terminal à N Kio. La limite ne s’applique qu’à ce terminal : fermez-le à la fin de l’exercice.
Puis :
Ouvrez un nouveau terminal à côté et lancez la commande
htop, qui affiche l’utilisation des ressources (CPU et RAM). Pour n’afficher que votre programme, appuyez surF4et tapez son nom (a.out).Dans le terminal du programme, limitez la mémoire à environ 2 Gio (2 000 000 Kio ≈ 1,9 Gio), puis lancez le programme :
ulimit -v 2000000 ./a.out
Regardez, dans
htop, la consommation mémoire de votre processus : colonnesVIRT(mémoire virtuelle réservée) etRES(mémoire physique réellement utilisée, voir demand paging).Regardez l’évolution des adresses mémoire : elles croissent, la heap grandit vers les adresses hautes.
Quand la limite est atteinte, l’OS refuse de fournir plus de mémoire :
mallocretourneNULLetperrorafficheCannot allocate memory. Vous pouvez aussi arrêter le programme avant avecCtrl+C.
Note
Avec la glibc, par défaut, les blocs de 128 Kio ou plus ne sont pas pris dans la heap mais dans une zone créée par mmap : avec malloc(1024 * 1024), les adresses seraient en 0x7... et décroissantes. C’est pour cela que l’on demande des blocs de 64 Kio.
Sans ulimit, Linux accepte par défaut de réserver plus de mémoire qu’il n’en a (overcommit) : malloc échoue rarement, et quand la mémoire est vraiment épuisée, le processus est plutôt tué par le noyau (OOM killer, voir la partie sur les processus), après que le système a fortement ralenti à cause du swap.
p3e3-4 - Codes bugués¶
Résolution de codes bugués : téléchargez les fichiers suivants dans le dossier p3/ de votre répertoire de travail :
wget https://members.loria.fr/cyril.grelier/r3_05/files/p3e3.c -P p3/
wget https://members.loria.fr/cyril.grelier/r3_05/files/p3e4.c -P p3/
Ils contiennent des bugs mémoire : trouvez ces bugs et résolvez-les. Notez les erreurs dans le code et laissez des commentaires qui expliquent le problème.
Vous pouvez commencer par compiler sans les flags -Wall -Wextra -pedantic et regarder les erreurs à l’exécution, avant d’activer les flags (ils n’affichent ici aucun avertissement : ces bugs ne se voient qu’à l’exécution ou à la lecture du code), puis d’utiliser les outils vus en cours :
gcc -std=c2x -Wall -Wextra -pedantic -g p3/p3e3.c && valgrind --leak-check=full ./a.out
gcc -std=c2x -Wall -Wextra -pedantic -g -fsanitize=address,undefined p3/p3e3.c && ./a.out
Faites de même pour p3e4.c. Ne lancez pas Valgrind sur un exécutable compilé avec -fsanitize : les deux outils ne fonctionnent pas ensemble, recompilez sans -fsanitize avant de lancer valgrind.
p3e3.c et p3e4.c contiennent chacun 5 bugs (comptés par ligne fautive : une même ligne peut provoquer plusieurs erreurs). Attention : les outils ne détectent pas tous les bugs, certains ne se voient qu’en lisant le code.
p3e5 - Déréférencement de pointeur invalide¶
Créez un pointeur sur
intinitialisé àNULLet modifiez la valeur pointée avec l’opérateur de déréférencement, comme*p = 42;.Observez le message d’erreur.
Lancez le programme avec Valgrind :
valgrind ./a.out, et interprétez le rapport.Changez le type en pointeur sur
doubleet cherchez la différence dans le rapport de Valgrind (regardez la taille indiquée dansInvalid write of size N).Bonus : recommencez avec un pointeur non initialisé (
int *p;). Le résultat n’est plus prévisible :pcontient ce qui traînait sur le stack, le programme peut planter ou non selon cette valeur.
p3e6 - Mémoire et adresses¶
Créez un programme C p3/p3e6.c avec des tableaux de TAILLE int. Pour changer facilement la taille, ajoutez au début du fichier :
// permet de changer la valeur à la compilation avec -DTAILLE=1000
#ifndef TAILLE
#define TAILLE 1
#endif
L’option -DTAILLE=1000 de gcc définit la macro TAILLE avec la valeur 1000, comme si #define TAILLE 1000 était écrit au début du fichier : le #ifndef ne la redéfinit alors pas.
Créez les tableaux suivants :
g_init: un tableau global initialisé (défini avant lemainavec une valeur par défaut ; vous pouvez juste donner une valeur non nulle à la première case, par exemple= {1}: un tableau initialisé à 0 serait placé dans le BSS) ;s_bss: un tableau statique non initialisé (défini avant lemain, avec le mot-cléstaticet sans valeur). Hors d’une fonction,staticlimite la visibilité de la variable au fichier ; comme une globale non initialisée, elle ira dans le segment BSS ;local: un tableau local aumain(allocation automatique sur le stack) ;heap: un tableau alloué dynamiquement avecmalloc.
Remplissez local et heap avec une boucle (par exemple local[i] = i;), puis affichez quelques valeurs de chaque tableau (les 5 premières au plus) pour vérifier leur initialisation, en particulier celle de g_init et s_bss, que vous n’avez pas remplis.
Affichez leurs adresses avec printf("type de variable : %p\n", (void *)var);.
Affichez aussi l’adresse de la fonction main avec printf("code (text) main : %p\n", (void *)(uintptr_t)&main); (ajoutez #include <stdint.h> pour uintptr_t).
La norme C interdit de convertir directement un pointeur de fonction en void * (-pedantic le signale) : on passe donc par un entier assez grand pour contenir une adresse, uintptr_t.
Pour pouvoir observer le /proc/PID/maps du programme, affichez son PID (Process ID, identifiant de processus, voir la partie sur les processus) et mettez le programme en attente :
#include <unistd.h> // pour importer getpid()
printf("pid du programme : %d\n", getpid());
printf("lancer les commandes :\n");
printf("\tcat /proc/%d/maps\n", getpid());
printf("Entrée pour terminer (lancer les commandes avant)\n");
getchar(); // bloque jusqu'à l'appui sur Entrée
Compilez le programme avec TAILLE=1 :
gcc -std=c2x -Wall -Wextra -pedantic -g -DTAILLE=1 p3/p3e6.c -o p3e6 && size p3e6 && ls -l p3e6
Lancez le programme (./p3e6) et, dans un autre terminal, lancez cat /proc/PID/maps (voir la lecture de /proc/PID/maps dans le cours).
Comparez les adresses affichées avec les plages de /proc/PID/maps : une adresse appartient à une ligne si elle est comprise entre l’adresse de début et l’adresse de fin.
Recopiez et complétez ce tableau dans un fichier p3/p3e6.md :
Variable |
Adresse affichée |
Ligne de |
Segment |
|---|---|---|---|
|
|||
|
|||
|
|||
|
|||
|
Data et BSS se trouvent dans la même ligne rw-p de l’exécutable : /proc/PID/maps ne permet pas de les distinguer, seule la commande size les sépare.
Bonus : changez la TAILLE de 1 à 100000 et comparez :
gcc -std=c2x -Wall -Wextra -pedantic -g -DTAILLE=100000 p3/p3e6.c -o p3e6 && size p3e6 && ls -l p3e6
avec
size: l’évolution des colonnestext,dataetbss;avec
ls -l: la taille du fichier exécutable. Contrairement au BSS,dataoccupe de la place dans le fichier : le BSS est seulement réservé (et mis à zéro) au lancement.
Pour aller plus loin : /proc/PID/maps avec TAILLE=100000
Le bloc de 400 000 octets (≈ 391 Kio) alloué par
mallocest pris dans une zone créée parmmapet non dans[heap](voir la note de p3e2).pmap PIDaffiche la heap et les zonesmmapde la même façon ([ anon ]) : seul/proc/PID/mapsmontre la différence.Le BSS (400 000 octets) ne tient plus dans la ligne
rw-pde l’exécutable : il se prolonge dans une zone anonyme sans nom juste après, qu’il ne faut pas confondre avec la heap.