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é.

  1. 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.

  2. Compilez le programme sans optimisation. Selon la version de GCC, les flags -Wall -Wextra -pedantic affichent un avertissement (warning) warning: infinite recursion detected, mais le programme est quand même compilé.

  3. 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.

  4. 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 0x1ffe801000
    

    On 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 :

    1. la mémoire pour toutes les variables de a() est automatiquement réservée sur le stack (d’où le terme d’allocation automatique) ;

    2. quand a() appelle une autre fonction b(), la mémoire de b() est empilée sur le stack pour l’exécution de la fonction ;

    3. une fois b() terminée, son cadre est dépilé : la mémoire réservée sur le stack est automatiquement libérée ;

    4. maintenant que la mémoire de b() est libérée, le haut du stack correspond aux variables locales de a(), qui seront libérées à leur tour à la fin de a().

    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 (malloc en C, new en TypeScript).

p3e2 - Évolution de la heap

On va maintenant chercher à visualiser la croissance de la heap, toujours avec un code bugué.

  1. Écrivez une longue boucle (1000000000000 itérations : un int ne suffit pas, utilisez un compteur de type size_t, affiché avec %zu).

  2. À chaque itération, demandez un bloc de 64 Kio avec malloc(64 * 1024) dans un pointeur char * et testez si malloc a renvoyé NULL (dans ce cas, appelez perror et quittez).

  3. É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.

  4. Affichez le numéro de l’itération et l’adresse du bloc.

  5. Ne libérez pas la mémoire et passez à l’itération suivante.

Puis :

  1. Ouvrez un nouveau terminal à côté et lancez la commande htop, qui affiche l’utilisation des ressources (CPU et RAM).

  2. Lancez le programme et regardez, dans htop, la consommation mémoire de votre processus : colonnes VIRT (mémoire virtuelle réservée) et RES (mémoire physique réellement utilisée).

  3. Regardez l’évolution des adresses mémoire : elles croissent, la heap grandit vers les adresses hautes.

  4. Arrêtez le programme avec Ctrl+C avant 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

  1. Créez un pointeur sur int initialisé à NULL et modifiez la valeur pointée par le pointeur avec l’opérateur de déréférencement, comme *p = 42;.

  2. Observez le message d’erreur.

  3. Lancez le programme avec valgrind : valgrind ./a.out, et interprétez le rapport.

  4. Changez le type en pointeur sur double et cherchez la différence dans le rapport de valgrind (regardez la taille indiquée dans Invalid write of size N).

  5. Bonus : recommencez avec un pointeur non initialisé (int *p;). Le résultat n’est plus prévisible : p contient 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 le main avec 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 le main, avec le mot-clé static et sans valeur). Hors d’une fonction, static limite la visibilité de la variable au fichier ; comme une globale non initialisée, elle ira dans le segment BSS ;

  • local : un tableau local au main (allocation automatique sur le stack) ;

  • heap : un tableau alloué dynamiquement avec malloc.

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 colonnes text, data et bss ;

  • avec ls -l : la taille du fichier exécutable. Seul data occupe de la place dans le fichier : le BSS est seulement réservé (et mis à zéro) au lancement ;

  • avec pmap PID pendant l’exécution : la taille des zones. Avec TAILLE=100000, le bloc de 400 Ko alloué par malloc est pris dans une zone créée par mmap et non dans [heap] (voir la note de p3e2).