Consignes

Le rendu de ce TP se fera uniquement par TPLab et consistera en une archive (zip, tar, etc.) contenant impérativement l'arborescence suivante :

      TP2-votre_login/
         ├─ RAPPORT
         ├─ Makefile
         ├─ question1.c
         ├─ ...
         ├─ test_memoire.c
         ├─ ...
         └─ tinygc.c

Attention :

Tout non respect d'une ou plusieurs de ces consignes entrainera automatiquement un retrait de points sur votre note de TP !

Liens utiles

1. Modèle mémoire des programmes C

Préliminaires

La mémoire des processus issus de programmes C est essentiellement divisée en trois parties :

  1. le segment text (texte) contenant code exécutable du programme,

  2. la pile d'exécution contenant les données de taille fixe du programme, les arguments de la fonction en cours d'exécution etc.,

  3. le tas contenant les données de taille dynamique du programme.

Traditionnellement, la pile d'exécution commence aux adresses hautes (0xff..) et grandit vers les adresses basses alors que le tas commence aux adresses basses (0x00..) et grandit vers les adresses hautes.

1.1. Afficher des adresses

Le programme suivant affiche l'adresse où est stockée une variable locale de type entier :

#include <stdio.h>
int main() {
  int i = 0;
  printf("@i = %18p\n", (void*)&i);
  return 0;
}
  1. Vérifiez que les allocations sur la pile (variables locales) se font bien en allant vers les adresses basses.

  2. Les adresses affichées par le programme sont elles des adresses virtuelles ou des adresses physiques ?

Pour augmenter la sécurité des programmes compilés, les architectures modernes utilisent l'ASLR (Address Space Layout Randomization).

Vous pourrez le constater en lançant le programme précédent plusieurs fois

l'adresse affichée devrait changer à chaque exécution...

Pour supprimer ce comportement, vous pouvez lancer un shell avec la commande

$ setarch $(uname -m) -R bash
  1. Où sont stockées les variables globales : plutôt aux adresses hautes, ou plutôt aux adresses basses ?

  2. Les variables globales sont séparées en deux parties : les variables initialisées (segment data), et les variables non-initialisées (segment BSS). Où se trouvent ces deux parties dans la mémoire ?

N'oubliez pas de donner un programme C pour justifier votre réponse.

Remarque : la commande

$ size FILE

affiche la taille des segments text, data et BSS d'un exécutable. (Elle affiche aussi la taille totale, en décimal et hexadécimal.)

La fonction malloc permet de faire des allocations dynamiques : cette fonction prend en argument une taille (en octets) et renvoie un pointeur vers une zone de mémoire de cette taille, réservée pour le processus.

Cette zone de mémoire se trouve dans le tas et est persistante : elle reste réservée à la sortie des blocs, contrairement aux variable locales de la pile.

Qu'affiche le programme suivant ?

#include <stdio.h>
#include <stdlib.h>

typedef void* address_t;

int main() {
  address_t *x;

  x = malloc(sizeof(address_t));
  *x = (void*)3735928559;

  printf(" x = %p\n", (address_t)x);
  printf("*x = %p\n", (address_t)*x);
  printf("&x = %p\n", (address_t)&x);
}

Expliquez précisément le résultat observé.

Faites un schéma récapitulatif (en ASCII art) des zones mémoires et de leur utilisation. Les zones suivantes devront apparaitre :

et les information suivantes également

2. Un tout petit "ramasse miettes"

Préliminaires

Un ramasse miettes (ou moins poétiquement garbage collector en anglais) est en mécanisme qui permet de libérer les zones mémoires non utilisées par un processus.

Le langage Java (ainsi que la plupart des autres langages) a un ramasse miettes.

Le langage C par contre, n'a pas de ramasse miettes : le programmeur doit explicitement utiliser la fonction free pour libérer les zones mémoires qu'il n'utilise plus. S'il ne le fait pas, le processus correspondant risque d'avoir des fuites de mémoire. (Un autre langage sans ramasse miettes est le langage Rust.)

Il est possible d'ajouter un ramasse miettes au langage C : la bibliothèque libgc (GC de Boehm) fournit une nouvelle fonction GC_MALLOC qui lui permet de gérer les zones mémoire qu'il alloue, et de les libérer lorsqu'elles ne sont plus utilisées.

L'objectif de cette seconde partie du TP est de programmer un ramasse miette simplifié pour le langage C.

Le fichier tinygc.c contient les définitions des types et fonctions que vous devrez écrire.

Sauvegardez le et modifiez le...

Attention, le code fourni ne compile pas directement avec le fichier Makefile donné : comme certaines fonctions ne sont pas écrites (ça fait partie de votre travail), le warning unused-parameter (qui est transformé en erreur) est généré.

Vous pouvez ajouter le drapeau -Wno-unused-parameter aux options de compilation pour supprimer ce warning pendant le développement...

2.1. Principe de fonctionnement

Nous allons programmer un ramasse miettes

L'idée est simple :

  1. chaque appel à la fonction GC_malloc appellera la fonction malloc du système et sauvegardera les adresses de début et de fin de la zone allouée dans une liste.

  2. la fonction GC_collect parcourra tous les mots de la pile à la recherche d'adresses :

    • si le mot correspond à une adresse contenue dans une zone allouée, la zone sera marquée comme utilisée,

    Après avoir parcouru la pile, les zones non marquées seront libérées par un appel à la fonction free du système.

Dans toute la suite, le type address_t sera un synonyme du char*, c'est à dire des pointeurs vers des octets.

typedef char* address_t;

Il serait possible d'utiliser void*, mais l'arithmétique sur les pointeurs de type void* n'est pas spécifiée par la norme du C : les résultats ne sont pas garantis, et les compilateurs vont émettre un warning...

2.2. Gestion de la liste des zones

Nous utiliserons un type de listes chainées pour représenter les zones allouées :

typedef struct cell {
    address_t start;
    size_t size;
    int in_use;
    struct cell *next;
} cell_t;

La liste des zones allouées sera stockée dans une variable globale BLOCKS:

cell_t *BLOCKS = NULL; 

Pendant le développement, il sera utile de pouvoir afficher la liste des zones allouées.

Complétez la fonction suivante dans le fichiers tinygc.c, qui fait cet affichage.

// display a visual representation of a list
void print_list(cell_t *list) {
    int index = 0;
    fprintf(stderr, "  +-------+--------------+----------+------+\n");
    fprintf(stderr, "  | index |   address    |   size   | used |\n");
    fprintf(stderr, "  +-------+--------------+----------+------+\n");
    cell_t *p;
    for (???; ???; ???) {
        fprintf(stderr, "  |  %03i  |  %10p  |  %6lu  |  %c   |\n",
                        index,
                        ???,
                        ???,
                        ???);
        n++;
    }
    fprintf(stderr, "  +-------+--------------+----------+------+\n");
}

N'oubliez pas de tester. Vous pouvez utiliser la fonction

void insert_BLOCKS(address_t start, size_t size);

qui insère des valeurs dans la liste (globale) BLOCKS.

Complétez la fonction GC_malloc qui appelle la fonction malloc du système et insère les informations pertinentes dans la liste BLOCKS grâce à la fonction insert_BLOCKS.

2.3. Parcours de la pile et marquage

Nous allons maintenant écrire la fonction

void mark_region(address_t start, address_t end) ;

qui parcourt la mémoire entre les adresses start et end, et pour chaque mot trouvé, marque la zone de mémoire correspondante (si elle existe) comme utilisée (champs in_use à 1).

La fonction mark_region est essentiellement donnée par

// mark blocks from a memory region
void mark_region(address_t start, address_t end) {
    address_t p;  // pointer to memory
    address_t v;  // value at pointer p
    for (p=start; p<end; p+=WORD_SIZE) {
        v = *p;
        mark_BLOCK(v);
    }
}

Si vous compilez ce code, le compilateur affichera un warning pour la ligne

        v = *p;                                   

Ajoutez le cast pertinent pour supprimer ce warning.

L'appel mark_BLOCK(v) parcourt la liste de zones allouées BLOCKS et marque la zone qui contient v comme utilisée (champs in_use à 1).

Complétez cette fonction.

La fonction mark_from_stack parcourt la pile pour marquer les blocs correspondants. Pour ceci, il faut appeler la fonction mark_region avec les arguments start et end appropriés.

  1. La variable globale STACK_START contient l'adresse du haut de la pile. Elle doit être initialisé dans la fonction main.

    En vous inspirant des tests faits dans la première partie du TP (affichages d'adresses de la pile), initialisez la variable STACK_START de manière appropriée. (Attention, cette variable doit être alignée sur un mot. Le plus simple est d'utiliser une variable de type int pour garantir ceci...)

  2. L'adresse du bas de la pile change au cours de l'exécution, au fil des déclarations de variables et d'appels de fonctions.

    Comment peut-on trouver l'adresse du bas de la pile à l'intérieur de la fonction mark_from_stack ?

    Appelez la fonction mark_region avec les bonnes valeurs de start et end.

N'oubliez pas de décrire ce que vous faites dans le fichier RAPPORT !

2.4. Le ramasse miettes

La fonction GC_collect (fournie) appelle la fonction mark_from_stack et libère toutes les zones non utilisées.

Vérifiez que votre ramasse miettes fonctionne correctement en ajoutant un test dans la fonction principale qui :

Si tout fonctionne, la seconde liste devrait contenir moins de zones allouées...

Les compilateurs font de nombreuses optimisations invisibles. Par exemple, les variables locales non utilisées peuvent ne pas se retrouver dans le code final !

Faites en sorte d'utiliser toutes vos variables, par exemple en les affichant avec un printf...

2.5. Parcours du tas

Le code donné dans la première partie pour initialiser un tableau dynamique à deux dimensions était

int **T = malloc(size * sizeof(int*));
for (int i=0; i<size; i++) {
  T[i] = malloc(size * sizeof(int));
}
  1. Remplacez les appels à malloc par des appels à GC_malloc et regarder ce que fait GC_collect.

    Qu'en pensez vous ?

  2. Proposez une solution et programmez une fonction mark_from_heap qui sera appelé depuis GC_collect pour corriger le problème.

2.6. Pour finir

  1. À ce stade, votre ramasse miette parcourt la pile et le tas pour libérer les zones inutilisées.

    Quelle(s) autre(s) zone(s) faudrait il parcourir pour éviter de libérer une zone encore référencée ?

  2. Il n'est pas très pratique d'avoir à appeler la fonction GC_collect manuellement. Proposez une solution simple pour pouvoir l'appeler automatiquement.

  3. Le GC de Boehm propose une fonction GC_malloc_atomic : les zones allouées par par cette fonction sont libérées automatiquement mais ne sont pas traversées pour y rechercher des adresses.

    • Donnez un exemple d'application pour cette fonction GC_malloc_atomic.

    • Ajoutez cette fonction dans votre ramasse miettes.

  4. Actuellement, tinygc.c est un programme. Transformez le en bibliothèque afin de pouvoir l'utiliser avec n'importe quel autre programme C.

    Pour ceci, il faudra écrire une fonction GC_init (avec des arguments pertinents) pour initialiser le ramasse miettes.