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 :
-
RAPPORT doit être un fichier texte contenant les réponses aux questions du TP et vos commentaires / remarques / explications pertinents,
-
Makefile doit compiler tous vos fichiers C (vous pouvez vous inspirer de ce fichier Makefile), (si vous ne connaissez pas les fichiers Makefile, je vous conseille de suivre ce tutoriel)
-
votre Makefile doit compiler les programmes C avec les options --std=c99 -Wall -Wextra -pedantic -Werror,
-
un ou plusieurs fichiers C pour chaque question pertinente.
Tout non respect d'une ou plusieurs de ces consignes entrainera automatiquement un retrait de points sur votre note de TP !
Liens utiles
-
code fournit pour la seconde partie tinygc.c
-
encadrant de TP : Pierre.Hyvernat@univ-smb.fr et Clovis.Eberhart@univ-smb.fr
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 :
-
le segment text (texte) contenant code exécutable du programme,
-
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.,
-
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;
}
-
Vérifiez que les allocations sur la pile (variables locales) se font bien en allant vers les adresses basses.
-
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
-
Où sont stockées les variables globales : plutôt aux adresses hautes, ou plutôt aux adresses basses ?
-
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 :
-
pile
-
tas
-
segment text
-
segment data
-
segment bss
et les information suivantes également
-
code du programme,
-
variables globales initialisées,
-
variables globales non initialisées,
-
variables locales statiques (
static int i;
), -
variables locales,
-
tableaux statiques (
int tab[SIZE];
), -
tableaux dynamiques (
int *t = malloc(size*sizeof(int));
), -
arguments des fonctions.
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
-
bloquant (stop the world en anglais) : pendant les phases de recherche / libération des zones libres, le reste du processus ne s'execute pas,
-
traversant (tracing en anglais) : le ramasse miette parcourt les zones de mémoire pour rechercher les références aux zones allouées,
-
conservatif (conservative en anglais) : tout mot est potentiellement une adresse, et toutes les adresses sont stockées dans des mots,
-
inefficace (inefficient en anglais), parce que ça serait trop compliqué sinon. :)
L'idée est simple :
-
chaque appel à la fonction
GC_malloc
appellera la fonctionmalloc
du système et sauvegardera les adresses de début et de fin de la zone allouée dans une liste. -
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.
-
La variable globale
STACK_START
contient l'adresse du haut de la pile. Elle doit être initialisé dans la fonctionmain
.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 typeint
pour garantir ceci...) -
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 destart
etend
.
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 :
-
fait quelques allocations avec
GC_malloc
, -
écrase certains pointeurs avec, par exemple, des
NULL
, -
appelle la fonction
print_list
, -
appelle la fonction
GC_collect
, -
appelle la fonction
print_list
.
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));
}
-
Remplacez les appels à
malloc
par des appels àGC_malloc
et regarder ce que faitGC_collect
.Qu'en pensez vous ?
-
Proposez une solution et programmez une fonction
mark_from_heap
qui sera appelé depuisGC_collect
pour corriger le problème.
2.6. Pour finir
-
À 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 ?
-
Il n'est pas très pratique d'avoir à appeler la fonction
GC_collect
manuellement. Proposez une solution simple pour pouvoir l'appeler automatiquement. -
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.
-
-
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.