Aller au contenu

Introduction aux pointeurs C

En C, la gestion mémoire d'un programme est manuelle. Charge au développeur de gérer le contenu de son programme (variable, fonction ...) en mémoire, et ce, de l'allocation à la libération. C'est une des raisons pourquoi on considère le langage C comme étant "bas niveau", plus proche de la machine. Au final, les programmes C sont presque tout le temps plus léger et plus rapide (sauf l'assembleur, encore plus bas niveau). Cette gestion manuelle demande une rigueur importante pour ne faire fuiter la mémoire (memory leak) provocant un débordement de pile (stack overflow).

Les autres langages manipulent également la mémoire, mais la gestion est déléguée derrière une couche d'abstraction appelée référence. Le nettoyage est lui géré par un ramasse miette (garbage collector).

Concept En C (Pointeur) En Java/Python (Référence)
Accès Direct à l'adresse mémoire. Indirect via un nom d'objet.
Déplacement On peut faire ptr++ (dangereux). Impossible de sortir de l'objet.
Nettoyage free() manuel obligatoire. Automatique (Garbage Collector).

Exemple simple

#include <stdio.h>
#include <stdlib.h>

/**
 * @brief Analyse comparative de l'allocation Statique (Stack) et Dynamique
 * (Heap). Ce cours illustre comment la Pile gere l'adresse et le Tas gere la
 * donnee.
 */
void pointerFunc() {

  /* 1. ALLOCATION STATIQUE (SUR LA PILE / STACK)
     L'identificateur 'ptr' est lui-meme une variable locale.
     Le systeme reserve immediatement un emplacement sur la PILE pour stocker
     une adresse (8 octets sur architecture 64-bit).
     Etat : ptr est sur la Pile, mais sa valeur est 'indeterminee'.
  */
  int *ptr;

  /* 2. ALLOCATION DYNAMIQUE (LIAISON PILE-TAS)
     - malloc() : Reserve un bloc dans le TAS (Heap).
     - Affectation (=) : On ecrit l'adresse du bloc du TAS a l'interieur
       de la variable 'ptr' qui reside sur la PILE.
     Concept : La Pile contient le "GPS" (l'adresse), le Tas contient le
     "Terrain".
  */
  ptr = (int *)malloc(sizeof(int));

  /* 3. DEREFERENCEMENT (INDERECTION DE LA PILE VERS LE TAS)
     L'operateur '*' commande au processeur de lire l'adresse sur la PILE,
     de "sauter" vers cette coordonnee dans le TAS, et d'y inscrire '25'.
     Action : On modifie le Tas via un identificateur situe sur la Pile.
  */
  *ptr = 25;

  /* 4. INSPECTION DE LA DUALITE MEMOIRE
     &ptr : L'adresse du pointeur lui-meme (ou il est sur la PILE).
     ptr  : L'adresse stockee (vers ou il pointe dans le TAS).
     *ptr : La valeur finale (ce qui est reellement dans le TAS).
  */
  printf("Emplacement du pointeur (sur la PILE) : %p\n", (void *)&ptr);
  printf("Taille du pointeur (sur la PILE)      : %zu octets\n", sizeof(ptr));

  printf("Destination pointee    (dans le TAS)  : %p\n", (void *)ptr);
  printf("Taille allouee         (dans le TAS)  : %zu octets\n", sizeof(*ptr));

  printf("Valeur finale          (dans le TAS)  : %d\n", *ptr);

  /* 5. DEALLOCATION DU TAS
     free(ptr) : Libere la memoire dans le TAS.
     Note : Cette instruction ne modifie PAS la Pile.
     'ptr' existe toujours sur la Pile et contient toujours l'adresse du Tas,
     bien que celle-ci soit desormais invalide (Dangling Pointer).
  */
  free(ptr);

  /* 6. NEUTRALISATION SUR LA PILE
     ptr = NULL : On remplace l'adresse invalide par 0x0 sur la PILE.
     Le lien entre la Pile et le Tas est officiellement rompu.
  */
  ptr = NULL;

} /* FIN DE PORTEE : La variable 'ptr' est automatiquement depilee (Stack Pop).
   */

int main() {
  pointerFunc();
  return 0;
}
sequenceDiagram
    participant S as PILE (Stack)
    participant C as CPU (Code)
    participant T as TAS (Heap)

    Note over S,T: 1. Declaration
    C->>S: Reserve espace pour ptr
    S-->>C: ptr est indefini

    Note over S,T: 2. Allocation
    C->>T: malloc request
    T-->>C: Adresse 0x100
    C->>S: Ecrit 0x100 dans ptr

    Note over S,T: 3. DEREFERENCEMENT
    C->>S: Lit adresse dans ptr
    S-->>C: Renvoie 0x100
    C->>T: Ecrit 25 a l'adresse 0x100

    Note over S,T: 4. Liberation
    C->>T: Libere zone 0x100
    Note right of S: ptr contient encore 0x100

    Note over S,T: 5. Neutralisation
    C->>S: Met ptr a NULL (0x0)

Arithmétique des pointeurs

Dans l'exemple ci-dessus, ptr est un pointeur d'entier (avec un espace alloué pour 2 entiers dans la Heap). Il pointe vers une adresse mémoire. Ainsi si on applique le code suivant :

int* ptr = malloc(2 * sizeof(int));
printf("Adresse 1 : %p\n", (void*)ptr); 
ptr++;
printf("Adresse 2 : %p\n", (void*)ptr);
Adresse 1 : 0x100a55aa0
Adresse 2 : 0x100a55aa4

ptr++ n'incrémente pas la valeur de ptr, mais décale d'un sizeof(int) l'adresse que pointe ptr.

Essayons ptr++ avec un espace alloué de seulement 1 entier :

int *ptr = malloc(sizeof(int));
printf("Adresse 1 : %p\n", (void *)ptr);
ptr++;
printf("Adresse 2 : %p\n", (void *)ptr);
return 0;
Adresse 1 : 0x102da1b00
Adresse 2 : 0x102da1b04

Le code compile parfaitement mais laisse passer une terrible erreur. Ainsi l'adresse du pointeur ptrest 0x102da1b00, 4 octets alloués spécifiquement pour un entier, jusqu'à là tout va bien. ptr++ calcule l'adresse mémoire suivante en ajoutant sizeof(int), soit 0x102da1b04. Il n'y a aucune vérification que cette adresse nous appartient, il n'y a pas eux 4 octets réservés (via malloc) et appartient peut-être à une autre variable ou à rien du tout.

Si on tente *ptr = 100 après ptr++, nous allons écrire dans une zone mémoire interdite, provoquant un dépassement de tampon (buffer overflow).

Note

Dans les années 80, il n'y avait pas de mémoire virtuelle (dédiée à un seule programme, comme dans sa bulle). Si vous faisiez un mauvais pointeur en C, vous pouviez réellement écrire sur la mémoire de la carte graphique ou corrompre le système d'exploitation, ce qui faisait "freezer" ou redémarrer tout l'ordinateur.

Tableaux

En C, un tableau n'est rien d'autre qu'un bloc de mémoire contigu alloué d'un seul tenant. La grande révélation du langage, c'est que l'identificateur d'un tableau est, en réalité, un pointeur constant vers son tout premier élément.

Lorsque tu déclares un tableau statique sur la Pile :

int tab[3] = {10, 20, 30};

Le système réserve immédiatement \(3 \times 4 \text{ octets} = 12 \text{ octets}\) consécutifs sur la PILE. L'identificateur tab équivaut mathématiquement à l'adresse du premier élément (&tab[0]).

Pour le processeur, l'écriture tab[i] n'existe pas. Elle est systématiquement traduite par une opération d'arithmétique de pointeur : *(tab + i).

Ce code met en évidence que l'indice d'un tableau et le déplacement d'un pointeur effectuent exactement le même travail en mémoire.

#include <stdio.h>
#include <stdlib.h>

/**
 * @brief Demonstration de la dualite entre tableaux et pointeurs.
 */
void arrayFunc() {

  /* 1. ALLOCATION SUR LA PILE
     Le systeme reserve 12 octets contigus sur la PILE.
     tab pointe directement sur le premier entier (10).
  */
  int tab[3] = {10, 20, 30};

  /* 2. LIAISON AVEC UN POINTEUR MUTABLE
     On cree une variable 'ptr' sur la PILE qui copie l'adresse de depart de 'tab'.
     ptr et tab pointent desormais au meme endroit.
  */
  int *ptr = tab; // Equivalent strict a : int *ptr = &tab[0];

  /* 3. INSPECTION VIA LA NOTATION TABLEAU [] */
  printf("--- Methode 1 : Notation Tableau [] ---\n");
  for (int i = 0; i < 3; i++) {
    // On accede directement par l'index
    printf("tab[%d] valeur : %d | Adresse : %p\n", i, tab[i], (void *)&tab[i]);
  }

  /* 4. INSPECTION VIA L'ARITHMETIQUE DES POINTEURS
     L'expression *(ptr + i) commande au CPU de lire l'adresse de depart de ptr,
     d'ajouter (i * sizeof(int)) octets, et de dereferencer.
  */
  printf("\n--- Methode 2 : Notation Pointeur *(ptr + i) ---\n");
  for (int i = 0; i < 3; i++) {
    printf("*(ptr + %d) valeur : %d | Adresse : %p\n", i, *(ptr + i), (void *)(ptr + i));
  }
}

int main() {
  arrayFunc();
  return 0;
}

Bien qu'ils partagent la même arithmétique, deux différences majeures séparent un tableau d'un pointeur : - L'immutabilité de l'adresse : Le nom du tableau (tab) est un pointeur constant. Tu n'as pas le droit de modifier son adresse de base. Faire tab++ provoquera une erreur de compilation ("assignment to expression with array type"), alors que ptr++ est parfaitement valide pour naviguer. - L'effondrement en pointeur (Array Decay) : C'est le piège numéro un en C. Lorsque tu passes un tableau à une fonction, il perd sa nature de tableau et "s'effondre" instantanément en un simple pointeur vers son premier élément. La fonction perd totalement la notion de la taille du tableau.

void verifierTaille(int monTableau[10]) {
  /* ERREUR CLASSIQUE : Ici, sizeof(monTableau) ne renvoie pas 40 octets (10 * 4),
     mais la taille du POINTEUR sur la Pile (8 octets sur une architecture 64-bit) !
  */
  printf("Taille recue par la fonction : %zu octets\n", sizeof(monTableau));
}

int main() {
  int tab[10];
  printf("Taille reelle dans le main  : %zu octets\n", sizeof(tab)); // Affiche 40 octets
  verifierTaille(tab);
  return 0;
}

📌 Règle d'or : Puisqu'une fonction ne sait jamais mesurer la taille d'un tableau qu'on lui donne, tu dois toujours passer sa dimension en paramètre secondaire (ex: void manipuler(int *unTableau, size_t taille)).

Pointeur de pointeur

Si un pointeur classique est une variable qui stocke l'adresse d'une donnée (comme un entier), un pointeur de pointeur est une variable qui stocke... l'adresse d'un autre pointeur.

On entre ici dans une mécanique à double indirection, symbolisée par le double astérisque. Cela sert principalement à deux choses :

  1. Modifier un pointeur à l'intérieur d'une fonction : En C, tout passage d'argument se fait par copie. Si tu passes un pointeur int *ptr à une fonction pour qu'elle lui alloue de la mémoire via malloc(), la fonction modifiera une copie de ce pointeur. Pour modifier le pointeur d'origine, tu dois passer son adresse à lui, donc un int .
  2. Créer des structures dynamiques multidimensionnelles : Comme des matrices ou des tableaux de chaînes de caractères (un tableau de pointeurs vers des tableaux de caractères).

Exemple simple : Allocation dynamique dans une fonction

Voici comment utiliser un pointeur de pointeur pour déléguer proprement l'allocation dynamique à une fonction sans faire fuiter la mémoire.

#include <stdio.h>
#include <stdlib.h>

/**
 * @brief Alloue de la memoire pour un entier VIA un pointeur de pointeur.
 * @param doublePtr L'adresse du pointeur situe dans le main.
 */
void allocationSecure(int **doublePtr) {
  /* 1. VERIFICATION DE SECURITE
     On verifie que le pointeur de pointeur lui-meme n'est pas NULL.
  */
  if (doublePtr == NULL) return;

  /* 2. ALLOCATION DANS LE TAS VIA DOUBLE INDIRECTION
     *doublePtr designe le pointeur du main (ptr).
     On ecrit l'adresse generee par malloc() DIRECTEMENT dans le ptr du main.
  */
  *doublePtr = (int *)malloc(sizeof(int));

  /* 3. AFFECTATION DE LA VALEUR
     **doublePtr commande au CPU de sauter de doublePtr vers ptr,
     puis de ptr vers le Tas pour y ecrire '42'.
  */
  if (*doublePtr != NULL) {
    **doublePtr = 42;
  }
}

int main() {
  /* ALLOCATION STATIQUE DU POINTEUR (SUR LA PILE)
     'ptr' attend une adresse, pour l'instant il vaut NULL.
  */
  int *ptr = NULL;

  /* APPEL DE LA FONCTION
     On envoie l'adresse de 'ptr' (&ptr) sur la PILE.
     La fonction recoit donc un int**.
  */
  allocationSecure(&ptr);

  /* VERIFICATION DE LA DUALITE
     &ptr : Adresse de ptr sur la Pile (ce que valait doublePtr dans la fonction).
     ptr  : Adresse du bloc alloue dans le Tas.
     *ptr : Valeur 42 stockee dans le Tas.
  */
  printf("Adresse de ptr (sur la PILE)          : %p\n", (void *)&ptr);
  printf("Adresse stockee dans ptr (dans le TAS) : %p\n", (void *)ptr);
  printf("Valeur lue dans le TAS                 : %d\n", *ptr);

  // Nettoyage obligatoire du Tas
  free(ptr);
  ptr = NULL;

  return 0;
}
sequenceDiagram
    participant M as MAIN (Pile)
    participant F as FONCTION (Pile)
    participant T as TAS (Heap)

    Note over M,F: 1. Initialisation dans le main
    Note over M: ptr est sur la Pile (vaut NULL)

    Note over M,F: 2. Appel de allocationSecure(&ptr)
    M->>F: Copie de l'adresse de ptr (&ptr)
    Note over F: doublePtr contient l'adresse de ptr

    Note over F,T: 3. Allocation Dynamique
    F->>T: malloc request
    T-->>F: Adresse du bloc Tas (ex: 0x555)
    F->>M: Écrit 0x555 directement dans ptr (via *doublePtr)

    Note over F,T: 4. Écriture de la valeur
    F->>T: Écrit 42 à l'adresse 0x555 (via **doublePtr)

Pointeur générique

En C, le typage est strict : un int * pointe sur un entier, un char * sur un caractère. Le compilateur a besoin de ce type pour savoir de combien d'octets il doit se déplacer lors d'une arithmétique (souviens-toi du ptr++).

Pourtant, il existe un électron libre : le pointeur générique, représenté par le type void *. C'est un pointeur "universel" qui stocke une adresse brute, sans aucune information sur la nature ou la taille de la donnée qui s'y trouve.

Le pointeur void * est l'outil indispensable pour le polymorphisme et l'abstraction en C. C'est d'ailleurs le type de retour de malloc() ! Quand malloc() te donne un bloc de mémoire dans le Tas, il n'a aucune idée de ce que tu vas mettre dedans (des entiers ? des structures ?). Il te renvoie donc un void * (une adresse brute), et c'est à toi de le transtyper (caster) dans le bon type.

Il y a deux règles d'or absolues avec le void * :

  1. Impossible de le déréférencer directement : Si tu écris *ptr alors que ptr est un void *, le compilateur va hurler. Pourquoi ? Parce qu'il ne sait pas s'il doit lire 1 octet (char), 4 octets (int) ou 8 octets (double).
  2. Pas d'arithmétique directe : Faire ptr++ sur un void * est interdit (ou donne un comportement indéfini), car le compilateur ne sait pas de combien d'octets sauter.

Pour l'utiliser, tu dois obligatoirement lui appliquer un redimensionnement de type (Type Casting) pour lui remettre son "mode d'emploi".

Voici comment un void * permet de créer une fonction capable de manipuler n'importe quel type de donnée en mémoire.

#include <stdio.h>

/**
 * @brief Affiche une variable cachee derriere un pointeur generique.
 * @param data L'adresse de la donnee (type inconnu au depart).
 * @param type Un caractere specifiant le type reel ('i' pour int, 'f' pour float).
 */
void printGenericData(void *data, char type) {
  /* 1. VERIFICATION DE SECURITE */
  if (data == NULL) return;

  /* 2. TRANSTYPAGE ET DEREFERENCEMENT
     Le CPU ne peut pas lire data directement. On doit lui dire : 
     "Regarde cette adresse comme s'il s'agissait d'un pointeur de type X, 
     puis lis la valeur (*)."
  */
  if (type == 'i') {
    // 1. On caste en (int *) 
    // 2. On dereference avec l'asterisque globale
    printf("Donnee de type Entier : %d\n", *(int *)data);
  } 
  else if (type == 'f') {
    // On caste en (float *) puis on dereference
    printf("Donnee de type Float  : %.2f\n", *(float *)data);
  }
}

int main() {
  /* ALLOCATION STATIQUE SUR LA PILE */
  int age = 25;
  float pi = 3.14f;

  /* UTILISATION DU POINTEUR GENERIQUE
     On envoie l'adresse de nos variables (&age et &pi) a la fonction.
     Le compilateur accepte implicitement la conversion de n'importe quel 
     pointeur vers un void*.
  */
  printGenericData(&age, 'i');
  printGenericData(&pi, 'f');

  return 0;
}
Avantages Inconvénients
Flexibilité totale : Permet d'écrire des fonctions génériques (comme qsort() de la bibliothèque standard ou ton propre malloc). Danger maximal : Le compilateur ne te protège plus. Si tu castes un void * (qui contient un float) en un int *, le CPU lira la mémoire de travers sans générer d'erreur de compilation.
Abstraction : Idéal pour masquer la structure interne des données (Pointeurs opaques). Lisibilité : La syntaxe de double opération *(int *)ptr peut vite devenir difficile à déchiffrer.
## Pour finir

Dans le paragraphe Tableaux, nous avons vu les tableaux statiques (sur la Pile). Dans le paragraphe Arithmétique, nous avons vu comment allouer de l'espace pour 2 entiers.

Le tableau dynamique, c'est l'union de ces deux mondes : utiliser un pointeur sur la Pile pour gérer un tableau de taille variable dans le Tas.

#include <stdio.h>
#include <stdlib.h>

void tableauDynamique() {
    int taille = 5; // Cette taille peut etre decidee par l'utilisateur a l'execution !

    // 1. Allocation d'un bloc contigu pour 5 entiers dans le TAS
    int *tab = (int *)malloc(taille * sizeof(int));

    if (tab == NULL) return; // Securite anti-crash

    // 2. Remplissage : On utilise la notation tableau classique !
    // Le compilateur fait l'arithmetique *(tab + i) en tâche de fond.
    for (int i = 0; i < taille; i++) {
        tab[i] = (i + 1) * 10; 
    }

    // 3. Libération du bloc entier
    free(tab);
    tab = NULL;
}

Jusqu'ici, nous avons dit que les pointeurs stockent l'adresse de données (variables dans la Pile ou le Tas). Mais en C, le code lui-même est stocké en mémoire (dans le segment de texte / Code Segment).

Un pointeur de fonction stocke l'adresse de la première instruction d'une fonction. Cela permet de passer une fonction en paramètre d'une autre fonction (ce qu'on appelle un Callback).

#include <stdio.h>

int addition(int a, int b) { return a + b; }
int soustraction(int a, int b) { return a - b; }

/**
 * @brief Une fonction qui pilote d'autres fonctions via un pointeur.
 * @param f Pointeur vers une fonction qui prend deux int et renvoie un int.
 */
void executerCalcul(int (*f)(int, int), int x, int y) {
    // Appel de la fonction cachee derriere le pointeur
    int resultat = f(x, y);
    printf("Resultat de l'operation : %d\n", resultat);
}

int main() {
    // On passe l'adresse des fonctions sans les parentheses
    executerCalcul(addition, 10, 5);     // Affiche 15
    executerCalcul(soustraction, 10, 5); // Affiche 5
    return 0;
}