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/
         ├─ Makefile
         ├─ question1.c
         ├─ question2.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.

    Attention, le compilateur peut changer l'ordre des variables dans un même bloc. Pour voir l'évolution des adresses de la pile, il est nécessaire de créer des blocs imbriqués :

    • soit à la main avec des accolades (for(...){...}),

    • soit avec des appels de fonctions.

  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 dans un fichier question4.txt) 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 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 : on suppose que toutes les adresses utilisées apparaissent dans la mémoire. Considérons l'allocation

  int *T = malloc(SIZE * sizeof(int));     // allocation d'un tableau de ``SIZE`` entiers

Si l'hypothèse que les adresses utilisées apparaissent dans la mémoire n'est pas vérifiée (par exemple parce que vous utilisez des liste doublement chainées par XOR), notre ramasse miettes risque de libérer une zone un utilisation !

Plus concrètement,

  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 flags;          // le bit de poids faibles indique si le bloc est référencé
    struct cell *next;  // pointeur vers la cellule suivante (ou NULL en fin de liste)
} 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");
}

Voici un exemple de ce que ma fonction print_list affiche

$ ./tinygc
+-------+----------------------+----------+------+
| index |       address        |   size   | used |
+-------+----------------------+----------+------+
|  000  |      0x5557353b5010  |       1  |  *   |
|  001  |      0x5557353b5060  |       1  |      |
|  002  |      0x5557353b50b0  |       1  |  *   |
|  003  |      0x5557353b5510  |      20  |  *   |
+-------+----------------------+----------+------+

N'oubliez pas de tester. Vous pouvez pour ceci 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 (bit de poids faible du champs flags à 1).

La fonction mark_region est 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 corriger ce problème. (Attention, seul le typage de cette ligne est incorrect : il ne faut pas modifier son contenu "calculatoire" en changeant les variables ou en ajoutant / supprimant des * ou &.)

L'appel mark_BLOCK(v) parcourt la liste de zones allouées BLOCKS et marque la zone qui contient v comme utilisée (bit de poids faible du champs flags à 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_TOP 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_TOP 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 commenter ce que vous faites !

Ne faites pas de tests dans votre fonction main, mais utilisez bien la fonction test.

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

On peut initialiser un tableau dynamique à deux dimensions avec

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.