Programmation C source/gestion de la mémoire
Apparence
#include <stdio.h>
int main(void)
{
printf("----------------------------------------\n");
printf(" Gestion de la mémoire\n");
printf("----------------------------------------\n");
/*
Pour gérer la mémoire, nous avons les fonctions suivantes,
déclarées dans l'en-tête stdlib.h :
malloc, pour l'allocation dynamique de mémoire.
free, pour la libération de mémoire préalablement allouée avec malloc.
calloc, pour allouer dynamiquement de la mémoire, comme malloc,
la zone mémoire est remplie avec des 0.
realloc, pour modifier la taille d'une zone mémoire déjà allouée.
à savoir :
La manière dont la mémoire physique d'un ordinateur est conçue, ainsi que la
façon dont le système d'exploitation la manipule, sont très variables. Cependant,
un modèle assez classique consiste à découper la mémoire en segments, segments
dont on garde les références dans des tables de pages : c'est le modèle de
segmentation / pagination. Ce modèle offre beaucoup d'avantages par rapport à un
accès purement linéaire. Décrire son fonctionnement en détail est hors de la porté
de ce cours, mais on pourra noter tout de même :
* Indépendance totale de l'espace d'adressage entre les processus :
un processus ne peut pas accèder à la mémoire d'un autre processus. C'est
pourquoi transmettre la valeur d'un pointeur à un autre processus ne servira en
général à rien, car le second processus ne pourra jamais accéder à l'emplacement
pointé.
* Gestion fine de la mémoire :
les segments sont accèdés via plusieurs niveaux d'indirection dans les tables de
pages. Cela permet de mettre virtuellement les segments n'importe où dans la
mémoire ou même sur un système de fichier. Dans la pratique, éparpiller trop les
segments (fragmenter) réduit significativement les performances.
Au niveau des inconvénients, on citera essentiellement un problème de
performances. Plusieurs niveaux d'indirection impliquent de multiple lectures en
mémoire, extrêmement pénalisant en terme de temps d'exécution, au point où des
caches sont nécessaires pour garantir des vitesses acceptables. Même si RAM veut
dire mémoire à accès aléatoire, il faut bien garder à l'esprit qu'un accès
purement séquentiel (adresse mémoire croissante) peut être jusqu'à cent fois plus
rapide qu'une méthode d'accès qui met sans cesse à défaut ce système de cache.
Un processus peut donc demander au sytème de réserver pour son usage exclusif un
secteur mémoire de taille déterminée. Il peut également demander au sytème de
modifier la taille d'une zone précédemment réservée ou de la libérer s'il n'en a
plus l'utilité.
*/
printf("----------------------------------------\n");
printf(" 1. malloc: allocation de mémoire\n");
printf("----------------------------------------\n");
#if 0
#include <stdlib.h>
void * malloc(size_t taille);
#endif
/*
remarques :
malloc renvoie une valeur de type void *, il n'est pas nécessaire de faire une
conversion explicite (cela est nécessaire en C++).
l'argument taille est de type size_t, l'appel à la fonction malloc devrait
toujours se faire conjointement à un appel à l'opérateur sizeof.
La zone mémoire allouée, si l'appel réussit, n'est pas initialisée. C'est une
erreur de conception grave que d'accéder au bloc mémoire en s'attendant à trouver
une certaine valeur (0 par exemple).
exemple :
*/
/* Déclaration et initialisation */
int * ptr = NULL;
/* Allocation */
ptr = malloc(sizeof(int));
/* On vérifie que l'allocation a réussi. */
if (ptr != NULL)
{
/* Stockage de la valeur "10" dans la zone mémoire pointée par ptr */
*ptr = 10;
/* Libérer la mémoire utilisée */
free(ptr);
/* Pour éviter les erreurs */
ptr = NULL;
}
else
{
/* décider du traitement en cas d'erreur */
}
/*
résumé : pour allouer on utilise
void * malloc(size_t taille); avec sizeof
tester si malloc à réussi
if (ptr != NULL)
{ ...
*/
printf("\n----------------------------------------\n");
printf(" 2. free: libération de mémoire\n");
printf("----------------------------------------\n");
#if 0
void free( void * pointeur );
#endif
/*
Le C ne possède pas de mécanisme de ramasse-miettes, la mémoire allouée
dynamiquement par un programme doit donc être explicitement libérée.
L'utilisation du pointeur après libération de la zone allouée (ou la double
libération d'une même zone mémoire) est une erreur courante qui provoque des
résultats imprévisibles. Il est donc conseillé :
* d'attribuer la valeur nulle (NULL) au pointeur juste après la libération de la
zone pointée, et à toute autre pointeur faisant référence à la même adresse,
* de tester la valeur nulle avant toute utilisation d'un pointeur.
De plus, donner à free l'adresse d'un objet qui n'a pas été alloué par une des
fonctions d'allocation cause un comportement indéfini.
résumé : on libère avec void free( void * pointeur );
et pour éviter les erreurs, on attribue NULL
pointeur = NULL;
*/
printf("\n----------------------------------------\n");
printf(" 3. calloc : allocation\n");
printf(" avec initialisation à 0\n");
printf("----------------------------------------\n");
#if 0
void * calloc(size_t nb_element, size_t taille);
#endif
/*
De manière similaire à malloc, calloc retourne un pointeur de type void* pointant
une zone de nb_element*taille octets allouée en mémoire, dont tous les bits
seront initialisés à 0, ou retourne un pointeur nul en cas d'échec.
*/
/* allouer un tableau de 5 entiers */
ptr = calloc ( 5, sizeof(int) );
/* Le pointeur contient l'adresse du premier élément du tableau : */
*ptr = 3; /* premier entier : 3 */
/* Le pointeur peut être utilisé comme un tableau classique pour accéder aux
éléments qu'il contient : */
ptr[0] = 3; /* équivaut à *ptr = 3; */
ptr[1] = 1; /* équivaut à *(ptr+1) = 1; */
ptr[2] = 4; /* équivaut à *(ptr+2) = 4; */
free(ptr);
ptr = NULL;
/*
Notez que calloc place tous les bits à zéro, mais que ce n'est pas nécessairement
une représentation valide pour un pointeur nul ni pour le nombre zéro en
représentation flottante. Ainsi, pour initialiser à zéro un tableau de double de
manière portable, par exemple, il est nécessaire d'assigner la valeur 0.0 à
chaque élément du tableau. Étant donné qu'on initialise chaque élément
« manuellement », on peut dans ce cas utiliser malloc plutôt que calloc (la
première étant normalement beaucoup plus rapide que la seconde).
résumé : on alloue avec
void * calloc(size_t nb_element, size_t taille);
on utilise sizeof pour taille
tous les bits sont mis à 0.
on utilise [] pour accéder aux éléments
*/
printf("\n----------------------------------------\n");
printf(" 4. realloc\n");
printf("----------------------------------------\n");
/*
Il arrive fréquemment qu'un bloc alloué n'ait pas la taille suffisante pour
accueillir de nouvelles données. La fonction realloc est utilisée pour changer
(agrandir ou réduire) la taille d'une zone allouée par malloc, calloc, ou realloc.
*/
#if 0
void * realloc(void * ancien_bloc, size_t nouvelle_taille);
#endif
/*
realloc tentera de réajuster la taille du bloc pointé par ancien_bloc à la
nouvelle taille spécifiée. À noter :
* si nouvelle_taille vaut zéro, l'appel est équivalent à free(ancien_bloc).
* si ancien_bloc est nul, l'appel est équivalent à malloc(nouvelle_taille).
* En cas de succès, realloc alloue un espace mémoire de taille nouvelle_taille,
copie le contenu pointé par le paramètre pointeur(ancien_bloc) dans ce nouvel
espace (en tronquant éventuellement si la nouvelle taille est inférieure à la
précédente), puis libère l'espace pointé et retourne un pointeur vers la
nouvelle zone mémoire.
* En cas d'échec, cette fonction ne libère pas l'espace mémoire actuel, et
retourne une adresse nulle.
Notez bien que realloc ne peut que modifier des espaces mémoires qui ont été
alloués par malloc, calloc, ou realloc. En effet, autoriser realloc à manipuler
des espaces mémoires qui ne sont pas issus des fonctions de la bibliothèque
standard pourrait causer des erreurs, ou des incohérences graves de l'état du
processus. En particulier, les tableaux, automatiques comme statiques, ne peuvent
être passés à realloc, comme illustré par le code suivant :
Avertissement Ce code contient une erreur volontaire !
*/
#if 0
void f(void)
{
int tab[10];
/* ... */
int *ptr = realloc(tab, 20 * sizeof(int));
/* ... */
}
#endif
/*
Lorsque realloc reçoit la valeur de tab, qui est un pointeur sur le premier
élément (i.e. &tab[0]), il ne peut la traiter, et le comportement est indéfini.
Sur cet exemple, il est facile de voir l'erreur, mais dans l'exemple suivant, la
situation est plus délicate :
*/
#if 0
#include <stdint.h> /* pour SIZE_MAX */
#include <stdlib.h>
/*
'double' essaye de doubler l'espace mémoire pointé par ptr.
* En cas de succès, la valeur renvoyée est un pointeur vers le nouvel espace
mémoire, et l'ancienne valeur de ptr est invalide.
* En cas d'échec, l'espace pointé par ptr n'est pas modifié, et la valeur NULL
est renvoyée.
*/
void *double(void *ptr, size_t n)
{
void *tmp = NULL;
if ((ptr != NULL) && (n != 0) && (n <= SIZE_MAX / 2))
{
tmp = realloc(ptr, 2 * n);
}
return tmp;
}
#endif
/*
La fonction double en elle-même ne comporte pas d'erreur, mais elle peut causer
des plantages suivant la valeur de ptr qui lui est passée. Pour éviter des
erreurs, il faudrait que la documentation de la fonction précise les contraintes
sur la valeur de ptr... et que les programmeurs qui l'utilisent y fassent
attention.
On peut aussi noter que, quand realloc réussit, le pointeur renvoyé peut très
bien être égal au pointeur initial, ou lui être différent. En particulier, il n'y
a aucune garantie que, quand on diminue la taille de la zone mémoire, il le fasse
« sur place ». C'est très probable, car c'est ce qui est le plus facile et rapide
à faire du point de vue de l'implémentation, mais rien ne l'empêche par exemple
de chercher un autre espace mémoire disponible qui aurait exactement la taille
voulue, au lieu de garder la zone mémoire initiale.
On peut noter le test (n <= SIZE_MAX / 2). Il permet d'éviter un débordement
entier : si n était supérieur à cette valeur, le produit n * 2 devrait avoir une
valeur supérieure à SIZE_MAX, qui est la plus grande valeur représentable par le
type size_t. Lorsque cette valeur est passée à realloc, la conversion en size_t,
qui est un type entier non signé, se fera modulo SIZE_MAX + 1, et donc la
fonction recevra une valeur différente de celle voulue. Si le test n'était pas
fait, double pourrait ainsi retourner à l'appelant une zone mémoire de taille
inférieure à celle demandée, ce qui causerait des bogues. Ce genre de bogue (non
spécifique à realloc) est très difficile à détecter, car n'apparaît que lorsque
l'on atteint des valeurs limites, ce qui est assez rare, et le problème peut
n'être visible que bien longtemps après que l'erreur de calcul soit faite.
Gestion d'erreur
Pour gérer correctement le cas d'échec, on ne peut faire ainsi:
Avertissement Ce code contient une erreur volontaire !
*/
#if 0
int *ptr = malloc(10 * sizeof(int));
if (ptr != NULL)
{
/* ... */
/* On se rend compte qu'on a besoin d'un peu plus de place. */
ptr = realloc(ptr, 20 * sizeof(int));
/* ... */
}
#endif
/*
En effet, si realloc échoue, la valeur de ptr est alors nulle, et on aura perdu
la référence vers l'espace de taille 10 * sizeof(int) qu'on a déjà alloué. Ce
type d'erreur s'appelle une fuite mémoire. Il faut donc faire ainsi:
*/
#if 0
int *ptr = malloc(10 * sizeof(int));
if (ptr != NULL)
{
/* ... */
/* On se rend compte qu'on a besoin d'un peu plus de place. */
int *tmp = realloc(ptr, 20 * sizeof(int));
if (tmp == NULL)
{
/* Exemple de traitement d'erreur minimaliste */
free(ptr);
return EXIT_FAILURE;
}
else
{
ptr = tmp;
/* On continue */
}
}
#endif
/*
Exemple
realloc peut être utilisé quand on souhaite boucler sur une entrée dont la
longueur peut être indéfinie, et qu'on veut gérer la mémoire assez finement. Dans
l'exemple suivant, on suppose définie une fonction lire_entree qui :
* lit sur une entrée quelconque (par exemple stdin) un entier, et renvoie 1 si la
lecture s'est bien passée ;
* renvoie 0 si aucune valeur n'est présente sur l'entrée, ou en cas d'erreur.
Cette fonction est utilisée pour construire un tableau d'entiers issus de cette
entrée. Comme on ne sait à l'avance combien d'éléments on va recevoir, on
augmente la taille d'un tableau au fur et à mesure avec realloc.
*/
#if 0
/* Fonction qui utilise 'lire_entree' pour lire un nombre indéterminé
* d'entiers.
* Renvoie l'adresse d'un tableau d'entiers (NULL si aucun entier n'est lu).
* 'taille' est placé au nombre d'éléments lus (éventuellement 0).
* 'erreur' est placée à une valeur non nulle en cas d'erreur, à une valeur nulle * sinon.
*/
int *traiter_entree(size_t *taille, int *erreur)
{
size_t max = 0; /* Nombre d'éléments utilisables */
size_t i = 0; /* Nombre d'éléments utilisés */
int *ptr = NULL; /* Pointeur vers le tableau dynamique */
int valeur; /* La valeur lue depuis l'entrée */
*erreur = 0;
while (lire_entree(&valeur) != 0)
{
if (i >= max)
{
/* Il n'y a plus de place pour stocker 'valeur' dans
'ptr[i]' */
max = max + 10;
int *tmp = realloc(ptr, max * sizeof(int));
if (tmp == NULL)
{
/* realloc a échoué : on sort
de la boucle */
*erreur = 1;
break;
}
else
{
ptr = tmp;
}
}
ptr[i] = valeur;
i++;
}
*taille = i;
return ptr;
}
#endif
/*
Ici, on utilise max pour se souvenir du nombre d'éléments que contient la zone de
mémoire allouée, et i pour le nombre d'éléments effectivement utilisés. Quand on
a pu lire un entier depuis l'entrée, et que i vaut max, on sait qu'il n'y a plus
de place disponible et qu'il faut augmenter la taille de la zone de mémoire. Ici,
on incrémente la taille max de 10 à chaque fois, mais il est aussi possible de la
multiplier par 2, ou d'utiliser toute autre formule. On utilise par ailleurs le
fait que, quand le pointeur envoyé à realloc est nul, la fonction se comporte
comme malloc.
Le choix de la formule de calcul de max à utiliser chaque fois que le tableau est
rempli résulte d'un compromis :
* augmenter max peu à peu permet de ne pas gaspiller trop de mémoire, mais on
appellera realloc très souvent.
* augmenter très vite max génère relativement peu d'appels à realloc, mais une
grande partie de la zone mémoire peut être perdue.
Une allocation mémoire est une opération qui peut être coûteuse en terme de
temps, et un grand nombre d'allocations mémoire peut fractionner l'espace mémoire
disponible, ce qui alourdit la tâche de l'allocateur de mémoire, et au final peut
causer des pertes de performance de l'application. Aucune formule n'est
universelle, chaque situation doit être étudiée en fonction de différents
paramètres (système d'exploitation, capacité mémoire, vitesse du matériel, taille
habituelle/maximale de l'entrée...). Toutefois, l'essentiel est bien souvent
d'avoir un algorithme qui marche, l'optimisation étant une question secondaire.
Dans une telle situation, utilisez d'abord une méthode simple (incrémentation ou
multiplication par une constante), et n'en changez que si le comportement du
programme devient gênant.
résumé : pour agrandir ou réduire
une zone allouée par malloc, calloc, ou realloc
on utilise
void * realloc(void * ancien_bloc, size_t nouvelle_taille);
en cas d'échec
cette fonction ne libère pas l'espace mémoire actuel
retourne NULL
on ne fait pas
ancien_pointeur = realloc(...);
mais
tmp = realloc(...);
if (tmp == NULL)
{
free(ancien_pointeur);
return EXIT_FAILURE;
}
else
{
ancien_pointeur = tmp;
}
*/
printf("\n----------------------------------------\n");
printf(" 5. Problèmes et erreurs classiques\n");
printf("----------------------------------------\n");
/*
Défaut d'initialisation d'un pointeur
Pour éviter des erreurs, un pointeur devrait toujours être initialisé lors de sa
déclaration :
soit à NULL,
soit avec l'adresse d'un objet,
soit avec la valeur de retour d'une fonction « sûre » comme malloc.
Exemple:
Avertissement Ce code contient une erreur volontaire !
*/
#if 0
/* Déclaration sans initialisation */
int *ptr;
int var;
/* On stocke dans var la valeur de la zone mémoire pointée par ptr
ptr n'a pas été initialisé, ptr a une valeur aléatoire ! */
var = *ptr;
#endif
/*
Référence multiple à une même zone mémoire
La recopie d'un pointeur dans un autre n'alloue pas une nouvelle zone. La zone
est alors référencée par deux pointeurs. Un problème peut survenir si on libère
la zone allouée sans réinitialiser tous les pointeurs correspondants :
Avertissement Ce code contient une erreur volontaire !
*/
#if 0
int* ptr = malloc(sizeof(int)); // allouer une zone mémoire pour un entier
int* ptr2 = ptr; // ptr2 pointe la même zone mémoire
*ptr = 5;
printf("%d\n", *ptr2 ); // affiche 5
/* libération */
free( ptr );
ptr = NULL;
*ptr2 = 10; /* <- résultat imprévisible (plantage de l'application, ...) */
#endif
/*
En règle générale, il faut éviter que plusieurs variables pointent la même zone
mémoire allouée.
Fuite mémoire
La perte du pointeur associé à un secteur mémoire rend impossible la libération
du secteur à l'aide de free. On qualifie cette situation de fuite mémoire, car
des secteurs demeurent réservés sans avoir été désalloués. Cette situation
perdure jusqu'à la fin du programme principal. Voyons l'exemple suivant:
Avertissement Ce code contient une erreur volontaire !
*/
#if 0
int i = 0; // un compteur
int* ptr = NULL; // un pointeur
while (i < 1001) {
ptr = malloc(sizeof(int)); // on écrase la valeur précédente de ptr par une
nouvelle
if (ptr != NULL)
{
/* traitement ....*/
}
}
#endif
/*
À la sortie de cette boucle on a alloué un millier d'entiers soit environ 1000 * 4
octets, que l'on ne peut pas libérer car le pointeur ptr est écrasé à chaque
itération (sauf la dernière). La mémoire disponible est donc peu à peu grignotée
jusqu'au dépassement de sa capacité. Ce genre d'erreurs est donc à proscrire pour
des processus tournant en boucle pendant des heures, voire indéfiniment.
résumé :
un pointeur doit être initialisé à NULL
éviter les références multiples à une même zone mémoire
toutes les références multiples doivent être mises à NULL après free()
attention au fuite mémoire !
on ne change pas un pointeur avant un appel à free()
*/
return 0;
}