Partie 3 - Mémoire¶
⚠️ Certains codes de cette partie sont volontairement bugués pour montrer le fonctionnement de la mémoire. Prenez donc du recul sur ce que vous faites !
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 Ko (la taille de
buf) 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). 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).
Qu’est-ce que ça veut dire ?
Le stack est la zone mémoire où l’on empile les variables propres à une fonction à chaque appel de celle-ci (qu’elle soit récursive ou non). Quand une fonction
a()est appelée :la mémoire pour toutes les variables de
a()est automatiquement réservée sur le stack (d’où le terme d’allocation automatique) ;quand
a()appelle une autre fonctionb(), la mémoire deb()est empilée sur le stack pour l’exécution de la fonction ;une fois
b()terminée, son cadre est dépilé : la mémoire réservée sur le stack est automatiquement libérée ;maintenant que la mémoire de
b()est libérée, le haut du stack correspond aux variables locales dea(), qui seront libérées à leur tour à la fin dea().
Le stack n’est pas illimité en espace mémoire car :
il ne doit stocker que les variables locales des fonctions, les adresses de retour et les arguments ;
il doit rester contigu en mémoire pour des raisons de performance. Appeler une fonction et en sortir doit être le plus rapide possible : on ne va donc pas chercher de la mémoire n’importe où en RAM. Et pour qu’il reste contigu, on ne peut pas se permettre de devoir le déplacer s’il devient trop grand.
Si on veut une zone mémoire « illimitée » (dont la seule limite est la RAM libre sur la machine utilisée), il faut utiliser la heap (le tas) avec l’allocation dynamique (
mallocen C,newen TypeScript).
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.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.
Puis :
Ouvrez un nouveau terminal à côté et lancez la commande
htop, qui affiche l’utilisation des ressources (CPU et RAM).Lancez le programme et 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).Regardez l’évolution des adresses mémoire : elles croissent, la heap grandit vers les adresses hautes.
Arrêtez le programme avec
Ctrl+Cavant de saturer la machine : quand la RAM est pleine, le système utilise le swap et peut devenir très lent.
Note
Avec la glibc, 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.
Par défaut, Linux accepte 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).
Pour observer proprement l’échec de malloc, limitez la mémoire virtuelle du terminal (environ 500 Mo ici) avant de lancer le programme :
ulimit -v 500000
./a.out
Quand l’OS refuse de fournir plus de mémoire, malloc retourne NULL et perror affiche Cannot allocate memory.
Fermez ensuite ce terminal : la limite ne s’applique qu’à lui.
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 -P p3/ https://members.loria.fr/cyril.grelier/r3_05/files/p3e3.c
wget -P p3/ https://members.loria.fr/cyril.grelier/r3_05/files/p3e4.c
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 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
p3e3.c et p3e4.c contiennent chacun 5 bugs. 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 par le pointeur 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 la pile, 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 à la première case) ;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.
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("\tpmap %d\n", getpid());
printf("Entrée pour terminer (lancer les commandes avant)\n");
getchar(); // bloque jusqu'à l'appui sur Entrée
Lancez le programme et, dans un autre terminal, lancez cat /proc/PID/maps et pmap PID.
Comparez les adresses affichées avec les plages de /proc/PID/maps et classez-les selon : segment text, data, bss, heap, stack.
Changez ensuite la TAILLE de 1 à 100000 et comparez :
gcc -std=c2x -Wall -Wextra -pedantic -g -DTAILLE=1 p3/p3e6.c -o p3e6 && size p3e6 && ls -l p3e6
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. Seuldataoccupe de la place dans le fichier : le BSS est seulement réservé (et mis à zéro) au lancement ;avec
pmap PIDpendant l’exécution : la taille des zones. AvecTAILLE=100000, le bloc de 400 Ko alloué parmallocest pris dans une zone créée parmmapet non dans[heap](voir la note de p3e2).