wiki:InsiaProgCMemoire

Sommaire

Pointeurs et gestion de la mémoire

Jusqu'ici nous prenions soin de déclarer les variables que nous allions utiliser, y compris la taille des tableaux qu e nous manipulions. Si dans de nombreux cas ce mécanisme est suffisant (compteur de boucle, variables temporaires, etc.), ceci ne permet pas de manipuler des variables dont l'existence même, le nombre et la taille ne sont pas connues avant l'exécution du programme.

1. Les zones mémoire

Il existe trois zones de mémoire distinctes utilisables par un programme C: la zone initialisée, la pile et le tas.

Mémoire initialisée

Cette mémoire est utilisée pour les variables globales initialisées. Ces variables possédant une valeur définie avant l'exécution même du programme, et en dehors du contexte de toute fonction, elles font partie intégrante du programme. Cela signifie que dans le fichier exécutable, en plus du code (langage machine), on trouve également des données qui sont chargées en mémoire automatiquement lors de l'exécution. Donc ces variables globales initialisées ont un impact sur la taille du fichier exécutable.

int constantes[2000] = { 42, 56, 33 }; /* Provoque l'enregistrement dans l'exécutable
                                          des 2000 entiers d'initialisation */

int main(int argc, char** argv) {
  return  0;
}

Note: en général, le compilateur utilise des 0 pour les valeurs non définies (plutôt que d'enregistrer des valeurs aléatoires dans le fichier exécutable), il est donc possible qu'une déclaration du type int vecteur[2000] = { 0 }; soit optimisée et ne provoque pas la présence de 2000 entiers nuls dans le fichier exécutable.

La pile

Il s'agit de la mémoire la plus simple à manipuler, et que nous avons utilisée jusqu'ici implicitement lors de toutes nos déclarations de variables locales (tous les blocs et en particulier les fonctions). Cette mémoire tient son nom de par son utilisation:

  • lorsque l'on rentre dans un bloc, les variables locales qui y sont définies sont dites empilées
  • lorsque l'on quitte un bloc, ces mêmes variables locales qui étaient au sommet de la pile sont alors dépilées

La pile est un espace mémoire de taille fixe pré-allouée au début du programme. Ainsi, à chaque entrée de bloc, allouer de la mémoire pour les variables locales consiste simplement à "rallonger" la pile (on dit empiler). Chaque bloc est responsable de la libération de sa mémoire, et doit dépiler ce qu'il a empilé. Le principe de programmation structurée du C impose que si on rentre dans des blocs dans un certain ordre, alors on doit nécessairement en ressortir dans l'ordre inverse (les blocs sont toujours imbriqués et/ou adjacents).

Cela veut dire que plus les blocs sont imbriqués, plus la pile va s'allonger. C'est en particulier vrai dans les cas de programmation récursive, où une fonction s'appelle elle-même un grand nombre de fois.

int square(int x) {
  /* A ce stade la pile contient:
     ...
     #4   Adresse de l'appelant de square()
     #5   int (x)
   */
  return x * x;
}

int main(int argc, char** argv) {
  int exit_code = 0;
  /* A ce stade la pile contient:
     #0   Adresse de l'appelant de main()
     #1   char** (argv)
     #2   int    (argc)
     #3   int    (exit_code)
  */
  exit_code = square(argc);
  /* A ce stade, la pile contient toujours la même chose */
  return exit_code;
}

Note: il est important de savoir que lorsqu'on rentre dans une fonction, une variable spéciale invisible est toujours empilée, il s'agit de l'adresse de l'appelant - un pointeur spécial (voir les détails sur les trous de sécurité).

Le tas

Il s'agit d'un type de mémoire que nous n'avons pas encore sollicité. Il est associé à la gestion de mémoire dite dynamique, et doit probablement son nom à l'absence d'organisation apparente de cette mémoire.

Le tas désigne toute la mémoire potentiellement disponible sur l'ordinateur (appelée mémoire virtuelle). Nous pouvons facilement réclamer de la mémoire par segments continus à l'aide de la fonction standard malloc:

int *vecteur = NULL;                /* C'est un pointeur. C'est aussi potentiellement un tableau,
                                       si on alloue de la mémoire pour ses éléments */
vecteur = malloc(10 * sizeof(int)); /* Alloue de la mémoire pour 10 * éléments (unité: octets) */
if (vecteur == NULL) exit(1);       /* Echec d'allocation (plus assez de mémoire dispo) */ 
vecteur[5] = 42;

Voici les règles d'utilisation de la mémoire dynamique:

  • On est obligé de remplacer le type de notre variable par un pointeur sur ce type, car nous allons manipuler d'abord une adresse
  • On doit calculer l'espace mémoire disponible en octets, l'opérateur sizeof est alors indispensable
  • L'opération peut échouer ! Pas assez de mémoire disponible.
  • On accède ensuite normalement à la variable (en la déréférençant comme appris précédemment): *p_variable ou p_variable[0]

Quand on déréférence un pointeur nul, le système d'exploitation déclenche automatiquement une segmentation fault, bus error ou address violation (suivant le système).

2. Utilisation du tas

Initialisation du pointeur

Il n'existe que peu de façons "sûres" d'initialiser un pointeur:

  • en lui assignant la valeur spéciale NULL
  • en lui assignant le résultat d'un appel à malloc
  • en lui assignant l'adresse d'une variable déjà allouée sur le tas (p_variable = &variable;)

Il existe bien d'autres façons, mais elles sont rapidement très délicates à manipuler (voir l'arithmétique sur les pointeurs).

Si un pointeur n'est pas correctement initialisé, il peut désigner un endroit aléatoire de la mémoire, et au moment d'accéder à cette adresse il peut se passer différents scenarii:

  • le pointeur vaut NULL (par chance!), une segmentation fault est déclenchée
  • le pointeur désigne un espace mémoire non réservé par le système, celui-ci le détecte et une segmentation fault est déclenchée
  • le pointeur désigne un espace mémoire accessible (par malchance!), et le programme adopte un comporte aléatoire - souvent très difficile à diagnostiquer.

Initialisation de la mémoire

La mémoire allouée par malloc n'est pas initialisée, et contient en général des valeurs aléatoires. On retombe donc sur le même problème que la déclaration de variable locales utilisant le tas.

Notons qu'il existe une variante permettant d'allouer de la mémoire tout en initialisant ses octets à zéro. Sa forme varie un peu, car elle prend un nombre d'éléments et la taille d'un élément en paramètre (la fonction effectue donc la multiplication pour nous):

int* vecteur = NULL;

vecteur = calloc(10, sizeof(int)); /* Alloue 10 entiers et initialise chaque octet à zéro */

Cette forme, utilisant la fonction standard memset est équivalente (mais vous pouvez changer la valeur d'initialisation de chaque octet):

int* vecteur = NULL;

vecteur = malloc(10 * sizeof(int));
if (!vecteur) exit(1);
memset(vecteur, 0, 10 * sizeof(int));

Attention: l'initialisation se fait par octet, ce qui n'a pas forcément de sens pour le type que vous manipulez. Pour les entiers, signés ou non, cela revient effectivement à initialiser avec des entiers nulls. Pour les valeurs réelles, cela peut dépendre de la plateforme (0.0 n'est pas forcément représenté comme un ensemble de bis à zéro). Pour vos structures, vous devez analyser chaque champ et voir si "mettre à zero ses octets" à un sens.

Redimensionnement de la mémoire

A utiliser avec précaution: il est possible de changer la taille d'un segment de mémoire alloué avec malloc ou calloc. A la différence d'une désallocation suivie d'une réallocation, ce mécanisme garanti que le minimum d'espace mémoire entre l'espace alloué et le nouvel espace réalloué est préservé:

int* vecteur;

vecteur = malloc(10 * sizeof(int));
...
vecteur = realloc(vecteur, 20 * sizeof(int)); /* Les 10 premiers entiers qui étaient
                                                 déjà alloués sont préservés */

Attention: l'adresse de l'espace mémoire retourné peut changer, faire donc très attentions aux éventuelles copies du pointeur. Cette opération peut échouer (retour de la fonction NULL), dans ce cas la mémoire déjà allouée n'a pas été modifiée (conserver donc soigneusement la valeur du pointeur).

Libération de la mémoire

La mémoire issue du tas est allouée pour la durée du programme. En particulier, en sortie de bloc, si le pointeur qui est issu du tas est bien libéré, la mémoire qu'il désigne ne l'est pas du tout ! Ceci est à la fois un avantage et un inconvénient.

En effet, une fonction ne peut pas renvoyer un type composé, même par pointeur, si celui-ci est alloué sur le tas:

struct complexe_t { float re, im; };

complexe_t* somme_complexe(complexe_t* a, complexe_t* b) {
  complexe_t resultat;

  resultat.re = a->re + b->re;
  resultat.im = a->im + b->im;
  return &resultat; /* Erreur ! Cette variable n'existe plus
                       une fois sortie de la fontion */
}

Il est alors possible d'écrire notre fonction comme suit:

complexe_t* somme_complexe(complexe_t* a, complexe_t* b) {
  complexe_t* resultat;

  resultat = malloc(sizeof(complexe_t));
  if (resultat == NULL)
    return NULL;
  resultat->re = a->re + b->re;
  resultat->im = a->im + b->im;
  return resultat; /* OK, mais l'appelant devra penser à
                      libérer cette mémoire */
}

int main(int argc, char** argv) {
  complexe_t a = {1, 5}, b = {0.2, -3};
  complexe_t* somme;

  somme = somme_complexe(&a, &b);
  if (somme != NULL) {
    printf("Résultat: %f + %f*i\n", somme->re, somme->im);
    free(somme);
  }
  return 0;
}

Dans cet exemple, nous avons libéré la mémoire à l'aide de la fonction free. Nous constatons au passage que ce programme est vite lourd et complexe, en particulier être obligé de gérer les erreurs d'allocation à chaque calcul de somme est peu pratique. On verra donc souvent cette façon d'écrire:

int somme_complexe(complexe_t* a, complexe_t* b, complexe_t* resultat) {
  resultat->re = a->re + b->re;
  resultat->im = a->im + b->im;
  return 1; /* Valeur booléenne "vraie" (différente de zéro): une addition ne peut échouer! */
}

int main(int argc, char** argv) {
  complexe_t a = {1, 5}, b = {0.2, -3};
  complexe_t somme;
  if (somme_complexe(&a, &b, &somme)) ...
}

Note: toute la mémoire allouée par un programme dans le tas est automatiquement libérée par le système d'exploitation lorsque le programme se termine. Il est parfois considéré correct de ne pas explicitement libérer la mémoire lorsqu'un programme et simple et sa durée de vie très courte (exemple: ls). Par contre sur les programme à durée de vie longue (serveur - démons), c'est une erreur grave qui provoque ce que l'on appelle une "fuite de mémoire" (memory leak).

Note: on ne peut appeler free que sur une adresse renvoyée par les fonctions d'allocation ou de réallocation. En particulier, appeler free sur l'adresse d'une variable de la pile peut provoquer un comportement imprévisible.

3. Arithmétique des pointeurs

Jusqu'ici nous nous sommes contentés d'assigner des valeurs à des pointeurs via un référencement ou un appel à la fonction free, ou bien de les déréférencer pour pouvoir exploiter la mémoire qu'ils désignent. On peut également effectuer certaines opérations sur ces pointeurs. Le sujet est délicat, mais il est très utilisé en pratique !

Note: il est impossible d'aditionner des pointeurs.

Décalage

Il est possible d'ajouter ou retirer une valeur entière à un pointeur. Si on considère un pointeur sur un type T comme un "tableau d'éléments de type T", modifier la valeur d'un pointeur de N unités revient à référencer l'élément d'indice N.

L'exemple suivant affiche world! en créant un nouveau pointeur décalé de 6 éléments (donc de 6 caractères):

char texte[] = "Hello world!";
char* extrait = texte + 6;

printf("Extrait: %s\n", extrait);

Exemple plus classique et illustrant une propriété importante des chaînes de caractères: celle-ci doivent toujours se terminer par le caracère de valeur 0 (noté \0). Cette boucle va donc se finir en fin de chaîne:

char texte[] = "Hello world!";
char* c;

for (c = texte; *c != '\0'; c++) {
  printf("%c", *c);
}

Note: ce comportement implique que le compilateur utilise le type pour déduire la taille de l'élément pointé, et multiplie automatiquement un décalage de pointeur par la taille de cet élément pour obtenir l'adresse ad hoc. Il est donc en particulier impossible d'effectuer des décalage sur des pointeurs de type void*, bien que la plupart des compilateurs l'acceptent (en considérant sizeof(void)=1).

Différence

On peut effectuer la différence de deux pointeurs, on obtient alors un "nombre d'éléments" si on reprend notre analogie du pointeur et du tableau ci-dessus. Si on veut en déduire l'espace mémoire utilisé entre ces deux pointeurs (cas le plus courant), il faut multiplier par la taille du type pointé. Exemple:

char* une_fonction() {
  char une_locale;

  return &une_locale;
}

int main(int argc, char** argv) {
  char dans_main;
  char* dans_fonction;

  dans_fonction = une_fonction();
  printf("Quantité de pile utilisée lors du dernier appel (en octets): %d\n", &dans_main - dans_fonction);
  return 0;
}
Last modified 14 years ago Last modified on Nov 5, 2006, 8:34:04 PM