Programmation C/Gestion de la mémoire
La gestion dynamique de la mémoire en C se fait à l'aide de principalement deux fonctions de la bibliothèque standard :
malloc
, pour l'allocation dynamique de mémoire ;free
, pour la libération de mémoire préalablement allouée avecmalloc
.
Deux autres fonctions permettent de gérer plus finement la mémoire :
calloc
, pour allouer dynamiquement de la mémoire, commemalloc
, qui a préalablement été initialisée à 0 ;realloc
, pour modifier la taille d'une zone mémoire déjà allouée.
Ces deux dernières ne sont pas toujours implémentées, contrairement aux deux premières. Si c'est le cas elles peuvent être remplacées par une séquence de malloc
/memset
, et malloc
/memmove
/free
.
Toutes ces fonctions sont déclarées dans l'en-tête <stdlib.h>
.
Gestion dynamique de la mémoire
Les déclarations de variables en C et dans beaucoup d'autres langages ont une limitation très importante : la taille des variables doit être connue à la compilation. Cela pose problème quand on ne sait qu'à l'exécution le nombre de données qu'on doit traiter. Pour résoudre ce problème, et pouvoir décider durant l'exécution d'un programme du nombre de variables à créer, il faudra nécessairement passer par de l'allocation dynamique de mémoire. Avec ce système, le programmeur dispose de fonctions qui permettent de demander au système une zone mémoire d'une certaine taille, qu'il pourra utiliser comme il le souhaite. En C, ces fonctions sont disponibles dans l'en-tête <stdlib.h>
.
L'un des principaux avantages qu'offre le langage C est sa capacité à fournir au programmeur un contrôle poussé sur la gestion de la mémoire. Cette liberté nécessite néanmoins une grande rigueur, tant les problèmes pouvant survenir sont nombreux et souvent difficiles à diagnostiquer. On peut dire sans prendre beaucoup de risque que la plupart des erreurs de programmation en C, ont pour origine une mauvaise utilisation des fonctions de gestion de la mémoire. Il ne faut pas sous-estimer la difficulté de cette tâche. Autant cela est trivial pour un programme de quelques centaines de lignes, autant cela peut devenir un casse-tête quand ledit programme a subi des changements conséquents, pas toujours faits dans les règles de l'art.
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ée de cet ouvrage, 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 système de réserver pour son usage exclusif un secteur mémoire de taille déterminée. Il peut également demander au systè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é.
Fonctions fournies par <stdlib.h>
malloc
: allocation de mémoire
La fonction la plus simple d'allocation mémoire est malloc
:
#include <stdlib.h>
void * malloc(size_t taille);
Elle prend en argument la taille que l'on veut allouer et renvoie un pointeur vers une zone mémoire allouée ou un pointeur nul si la demande échoue (la cause d'erreur la plus courante étant qu'il n'y a plus assez d'espace mémoire disponible). Trois remarques peuvent être faites :
malloc
renvoie une valeur de typevoid *
, 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 fonctionmalloc
devrait toujours se faire conjointement à un appel à l'opérateursizeof
. - 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).
Par exemple, si on souhaite réserver une zone mémoire pour y allouer un entier :
/* 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;
}
else
{
/* décider du traitement en cas d'erreur */
}
Si on souhaite allouer de l'espace pour une structure de données plus complexe :
typedef struct{
double b;
int a;
MaStructure *suivant;
} MaStructure;
/* Déclaration et initialisation */
MaStructure *ptr = NULL;
/* Allocation */
ptr = (MaStructure *)malloc(sizeof(MaStructure));
if (ptr != NULL)
{
/* Initialisation de la structure nouvellement créée */
ptr->a = 10;
ptr->b = 3.1415;
ptr->suivant = NULL;
}
free
: libération de mémoire
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. La fonction free
permet de faire cette libération.
void free( void * pointeur );
La fonction prend en argument un pointeur vers une zone mémoire précédemment allouée par un appel à malloc, calloc ou realloc et libère la zone mémoire pointée.
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)
{
/* ... utilisation de la zone allouée ... */
/* Libérer la mémoire utilisée */
free(ptr);
ptr = NULL; /* Pour éviter les erreurs */
}
else
{
/* décider du traitement en cas d'erreur */
}
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 à tout 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.
calloc
: allocation avec initialisation à 0
La fonction calloc
permet d'allouer une zone mémoire dont tous les bits seront initialisés à 0. Son prototype est légèrement différent de celui de malloc
, et est pratique pour l'allocation dynamique de tableaux.
Syntaxe :
void * calloc(size_t nb_element, size_t taille);
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.
Exemple :
/* allouer un tableau de 5 entiers */
int* 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; */
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).
realloc
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
.
Syntaxe :
void * realloc(void * ancien_bloc, size_t nouvelle_taille);
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)
. Ce comportement est toutefois spécifique à certains compilateurs et ne doit pas être substitué à unfree
dans un programme portable. - 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 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 :
void f(void)
{
int tab[10];
/* ... */
int *ptr = realloc(tab, 20 * sizeof(int));
/* ... */
}
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 :
#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;
}
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 :
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));
/* ... */
}
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 :
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 */
}
}
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
.
/* 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;
}
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 appellerarealloc
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.
Problèmes et erreurs classiques
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
. Méditons sur l'exemple suivant :
/* Déclaration sans initialisation */
int *ptr;
int var;
/* On stocke dans var la valeur de la zone mémoire pointée par ptr*/
var = *ptr;
Ce code va compiler! Si on est chanceux l'exécution de ce code provoquera une erreur à la troisième étape lorsqu'on déréférence ptr
. Si on l'est moins, ce code s'exécute sans souci et stocke dans var
une valeur aléatoire, ce qui pourrait provoquer une erreur lors d'une utilisation ultérieure de la variable var
alors que l'erreur réelle se situe bien plus en amont dans le code! Une variable (automatique) de type pointeur est en effet comme toutes les autres variables : si elle n'est pas initialisée explicitement, son contenu est indéfini ; son déréférencement peut donc causer n'importe quel comportement.
Lorsqu'on déclare une variable ou qu'on alloue une zone mémoire, on ne dispose d'aucune garantie sur le contenu de cette zone ou de cette variable. Initialiser systématiquement les variables dès leur déclaration, en particulier lorsqu'il s'agit de pointeurs, fait partie des bons réflexes à prendre et pour la plupart des applications ne dégrade pas le temps d'exécution de manière significative. Ce type d'erreur peut être extrèmement difficile à localiser à l'intérieur d'un programme plus complexe.
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 :
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, ...) */
En règle générale, il faut éviter que plusieurs variables pointent la même zone mémoire allouée.
L'utilisation d'un outil de vérification statique permet de détecter ce genre d'erreur.
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 :
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 ....*/
}
}
À la sortie de cette boucle on a alloué un millier d'entiers soit environ 2000 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. Si son caractère cumulatif la rend difficile à détecter, il existe de nombreux utilitaires destinés à traquer la moindre fuite.