Pointeurs en C

Un pointeur est l’adresse d’un objet ou d’une fonction .

Un objet du point de vue du C designe une zone de mémoire à laquelle on peut accéder via son adresse. Une fonction est egalement adressable mais il ne s’agit pas d’un objet.

Voila quelques exemples d’éléments non adressables en C :

#define def
// on ne peut pas prendre l'adresse d'une variable de preprocesseur

enum mon_enum{
  en1,
  en2,
}
// on ne peut prendre ni l'adresse de mon_enum, ni de en1 ou en2

struct {
  champ1:1;
  champ2:3;
}
// champ1 est adressable mais pas champ2.

register int ent;
// on ne peut pas adresser une variable avec une type de
// stockage << register >>, qui n'est donc pas un objet.

Voila certains éléments adressables


int ent; // une variable non register
void fun() {

}
// une fonction

"toto" // une chaine de caractère s'évalue 
// à l'adresse de son premier caractère

int tab[] = {1, 2, 3};
// tab est asdressable, ainsi que les élements consécutifs
// qui le constituent

struct stru {
  int a;
  char b;
  bool oui1:1;
  bool non:8;
  int oui2:0;
} stru;
// stru est adressable, 
// ainsi que a, b, oui1 et oui2. non est pas adressable.

inline void fun() {

}
// prendre l'adresse d'une fonction inlinée rend le 
// spécificateur caduc et empeche le compilateur d'élider
// la fonction qui est donc adressable, sans toutefois
// être un objet

Demander au programme l’adresse d’un objet ou d’une fonction empêche de nombreuses optimisations basées sur l’elision de variables ou de fonctions. Faire un cas abusif de pointeurs dans son programe aura un effet nefaste sur la performance.

Les premiers ordinateurs disposaient de mémoire tambours ou a bande magnétique. L’accès à cette mémoire se faisait de manière sérielle et non aléatoire. Voir la mémoire d’un ordinateur comme une unique bande magnétique peut être pratique pour acquérir l’intuition de la notion d’un pointeur, surtout aujourd’hui où la mémoire d’un processeur est éparpillée entre registres, caches de niveau 1, 2 et 3, et mémoire vive, généralement agencées en feuillets de matrices de nombres, cette dernière pouvant être virtualisée, accessible directement, ou par l’intermédiaire d’un système d’exploitation.

Le processeur peut stocker dans ses registres et veut stocker une valeur “quelque part” de manière rapide et y accéder rapidement, ce quelque part peut être vu de manière simplifiée comme une bande magnétique virtuelle où l’adressage se fait de manière ordinale, la première adresse se trouvant à l’adresse zéro (0), et n’importe quelle autre valeur se trouvant à la distance correspondante à son ordinal dans l’ensemble des nombres entiers finis, la taille de cet ensemble étant limitée à la taille de la bande.

Un pointeur est un objet qui occupe de la place en mémoire donc on le déclare comme d’autres objets en précisant son type, en ajoutant une étoile pour signaler qu’il s’agit d’un pointeur puis en lui donnant un nom, et éventuellement en l’initialisant :

int * ptr = 0;

On donne à un pointeur généralement l’ordinalité d’un autre objet, ou au lieu d’ordinalité on dit généralement son “addresse”. Pour obtenir l’adresse d’un objet on utilise l’opérateur & (un esperluette). Déclarer un objet sur la pile lui attribue automatiquement et de manière transparente pour nous une adresse sur la pile. On peut égalemennt considérer que la machine virtuelle du C nous fournit cette adresse. On verra comment obtenir des pointeurs de manière explicite en dehors de cette machine.

int a;
int *b = &a;

Le type d’un pointeur peut être de n’importe quel objet valide en C, y compris d’autre pointeurs : pour déclarer un pointeur de pointeur on s’y prend ainsi :

int a;  // un entier non initialisé
int *b; // pointeur vers int indéterminé
int ** c = &b; // pointeur vers un pointeur de int

int tab[3] = {1, 5, 10};  // un tableau de 3 entiers
int (*d)[3] = &tab; // pointeur vers tableau de 3 entiers
int *e[3] = {&a, &tab[0], &((*d)[1])};  // tableau de 3 pointeurs vers des entiers, attention a la priorité des opérateurs !

Pointeurs vers des fonctions

Un pointeur vers une fonction suit une notation particulière. On le déclare ainsi


int fun1(int a, char b) {
  return 1;
}

int fun2(int a, char b) {
  return a + b;
}

char fun3(int a, int b) {
  return a * b;
}

int main() {

  int (pf*)(int, char); /// pointeur vers une fonction retournant un `int` et prennant en paramètre un int et un char

  pf = fun1;
  printf("%d\n", pf(10, (char) 2)); /// 1 

  pf = fun2;
  printf("%d\n", pf(10, (char) 2)); /// 12

  ///pf = fun3;  /// interdit

}

Un pointeur de fonction déclaré comme pointant sur une fonction retournant un type donné et prenant des paramètres avec des types donnés ne peut pointer que sur des fonctions qui correspondent à cette signature . Attention utiliser des pointeurs de fonctions rend plus difficile les optimisations consistant à inliner (applatir) une fonction à son lieu d’appel. Afficher la valeur d’un pointeur de fonction (avec printf ou au sein d’une expression produisant un changement visible du comportement du programme) interdira dentièrement toute optimisation d’aplatissement. Tout comme une variable déclarée register ne pourra jamais se faire pointer dessus par un pointeur, une fonction déclarée inline ne pourra se faire pointer dessus par un pointeur de fonction, ce qui permet au compilateur d’effectuer des optimisation d’applatissement agressives sur celle-ci.

États des pointeurs

Les pointeurs existent dans 3 états: valides, nuls, indeterminés (soit qu’ils soient non initialisés, “abandonnés” ou contiennent simplement une valeur ne correspondant à un objet valide pour quelque raison que ce soit).

Un pointeur nul est de type “pointeur sur void” et il a une valeur de zero. Il s’écrit ainsi (void *)0. La macro NULL dans la librairie <stdlib.h> a cette valeur.

Un pointeur valide pointe sur un objet qui est dans sa durée de vie (lifetime). Un objet valide possède une addresse fournie par l’environnement où s’exécute le programme.

Un pointeur indéterminé ne réference pas un objet valide.

int *a; /* indéterminé */
int *b = 0; /* pointeur sur int déterminé à la valeur zéro */
void *d = 0; /* pointeur nul */
{
  int c;
  b = &c; /* b devient un pointeur valide après l'exécution de cette expression d'assignement */
}
/* b devient un pointeur indéterminé après l'exécution de la sortie du bloc */

int *e = malloc(4); /* soit valide soit nul*/
free(e); /* e devient indéterminé */

Attention: un programme déréférencant un pointeur indéterminé a un comportement indéterminé. Un programme qui déréférence un pointeur nul est généralement terminé par le système d’exploitation : il s’agit d’une erreur irrécupérable et terminale : bien que spectaculaire il s’agit d’un comportement moins risqué et pernicieux que de le laisser s’exécuter comme ce serait le cas dans le cas d’un pointeur indéterminé. Il faut garder en tête que déréférencer un pointeur est une chose dangeureuse.

Valeur interne

La représentation binaire et la taille en mémoire d’un pointeur est opaque et varie selon les architectures et il ne faut pas s’en soucier.

La norme C demande qu’on puisse les convertir vers une valeur entière et assigner une valeur entière à un pointeur.

C’est à dire le programme suivant sur une architecture 32 bits est valide.


int main() {
  char * toto = 42;
  int pointeur = toto;

  *toto = 24;

  printf("toto = %dn", *(*char)pointeur); // 24
}

En programmation système ou embarquée cela est utile pour obtenir des pointeurs sur des régions connues et documentées de l’architecture courante.

Pointeur et register

Il n’est pas possible de déclarer de pointeur sur une variable de type register. En sachant qu’aucun autre pointeur ne pourra acceder ou modifier cette variable, des optimisations de compilateur deviennent possibles.

Pointeurs restrict

On peut restreindre depuis le C99 la possibilité de donner un alias à un pointeur avec le mot clé restrict.

Tout comme pour la classe de stockage register ce qualificateur ne modifie pas le comportement visible du programme mais permet certaines optimisations.

Nullabilité des pointeurs

On peut spécifier depuis le C11 qu’une fonction n’accepte que des pointeurs pointant sur une région alouée de la mémoire, il faut declarer le paramètre comme un tableau de taille 1, et précéder la taille du mot clé static.

#include <stdlib.h>

/* Le pointeur doit pointer sur une adresse valide */
void fun(int param[static 1]) {

  if(param == NULL) {
    // vu que selon les règles du langage
    // le comportement du programme si le
    // pointeur param est nul est indéfini,
    // le compilateur peut supprimer tout
    // le code présent dans ce bloc, il
    // est donc inutile voire dangeureux 
    // d'écrire un tel test
  }
}

void null_ok(int * param) {
  if(param == NULL) {
    // le traitement à faire si pointeur nul
  }

  // ...
}

int main(void) {
  int *toto;
  int **tata = &toto;

  fun(NULL); // avertissement du compilateur
  fun((void*)0); // avertissement
  fun((void*)42); // ok, pointeur sur void mais non nul
  fun((int*)0); // ok, un pointeur nul mais sur int
  fun(toto); // ok, un pointeur non initialisé est autorisé

  fun(*tata); // ok, pointeur valide

  null_ok(NULL); // ok
  null_ok(*tata); // ok

}

On voit donc que [static N] peut aider à documenter l’intention d’une fonction mais n’est pas une garantie contre un mauvais usage. Ce n’est qu’une indication du compilateur qui n’ajoute aucun coût au runtime, mais passer un pointeur nul à une telle fonction causera un comportement indéfini alors que ce n’est pas le cas dans une fonction acceptant juste un pointeur : il sera donc tâche au code qui appelle une telle fonction de faire un “NULL check” avant d’apeller la fonction.

Un pointeur devrait correspondre exactement au type pointé

Alors que les entiers sont tolérants vis à vis des valeurs qu’ils acceptent en les convertissant silencieusement si besoin : un long int peut accueillir un entier “plus petit” tel que signed char ou un int, un pointeur devrait strictement correspondre au type pointé : cela est nécessaire pour l’arithmétique des pointeurs et que les valeurs dans un tableau soient placées au bon offset.

En sorte :

int main() {

  long int li = 't'; // ok : une chaine de caracteres est de type "int", converti implicitement en "long int"
  signed char sc = 1234;  // avertissement la conversion de 1234 en signed char peut entrainer une perte de données
  long long int * lli = &li;  // avertissement : l'arithmetique de pointeurs aura un pas différent du type pointé
}

Parité des pointeurs

Le processeur essaye toujours de placer les variables à des valeurs qui sont des multiples de deux. C’est un dire que la valeur d’un pointeur sera toujours un nombre pair, sauf si il s’agit d’un pointeur vers char. Le pointeur vers char est le seul pointeur dont l’adresse peut être impaire.

Une valeur paire aura toujours le bit de poids faible à zéro, donc on peut imaginer que pour optimiser la place un ordinateur ne stocke la valeur du dernier bit d’un pointeur que si il s’agit d’un pointeur vers char. En règle générale on peut considérer la valeur d’un pointeur comme étant opaque et utiliser la simplification de la considérér comme étant une valeur entre 0 et la taille de la mémoire comme étant une simplification pour aider à l’enseignement du C.

Astuces

Un pointeur peut prendre l’addresse d’une variable scalaire, d’un tableau (et d’une chaîne de caractères), d’une structure, d’une fonction, mais pas d’une variable de type register ou d’une fonction déclarée inline.

Une fois un pointeur déclaré, il ne pourra pas changer de type, sauf cast. On peut créer autant de pointeurs sur autant de types que l’on veut.

Un pointeur void quand a lui peut changer de type. Par definition on ne peut creer de variable de type void, et donc un pointeur void ne peut correspondre a un objet : on peut par contre le cast ou le transferer vers tout autre pointeur, void ou non.

Finalement le pointeur sur void de valeur 0 void * pointeur = 0 est appellé le pointeur nul, il est utilisé par de nombreuses fonctions de la librairie standart du C et déréférencer un tel pointeur cause l’arrêt du programme. Il est interdit de déréférencer un pointeur sur un type void en règle générale, et sa valeur étant zero elle na validera pas une expression conditionelle. La librairie standart du C définit NULL dans stdlib.h comme étant un pointeur de ce type.

Parler de pointeurs de pointeurs, pointeurs de fonctions, cdecl.org

Extensions non standard

Nullabilité des pointeurs en Clang

Clang permet de spécifier si un pointeur peut être nul _Nullable, jamais nul _Nonnull ou si pour des raions de maintenabilité ou d’opacité du code cela est impossible à dire _Null_unspecified.1

Dans les librairies MacOS, il est courant de voir du code ainsi, qui ne compilera qu’avec clang.

// passer un pointeur nul à cette fonction
// provoque un comportement indéterminé
int fetch(int * _Nonnull ptr) { return *ptr; }

// 'ptr' peut être nul.
int fetch_or_zero(int * _Nullable ptr) {
  return ptr ? *ptr : 0;
}

// Un pointer nulable sur des pointeurs non nullable sur des
// caractères constants.
const char *join_strings(
  const char * _Nonnull * _Nullable strings, unsigned n
);

Il existe une notation standart depuis le C11 pour signifier qu’on se souhaite pas qu’un pointeur soit non nul.

Pointeurs near, far et huge

A l’epoque où les processeurs 16 bits étaient la norme, il était possible de spécifier la représentation interne du pointeur selon les opérations qu’on souhaitait faire dessus.

Plus récemment alors que les pointeurs de 16 octets comment à apparaître certains compilateurs autorisent un qualificateur far sur les pointeurs.