Forum Security-X > Cours

Pointeurs : Les bases

(1/1)

XmichouX:
Les pointeurs :

1) Les variables dans la mémoire

Comme vu plus haut, le C nous permet de créer des variables typées, de les initialiser, de les assigner, de les comparer, etc.
Les principaux types de variable en C sont les entiers (short, int, long), les flottants (float, double) et les caractères (char).

Les variables sont caractérisées par leur adresse (l'emplacement mémoire où elles se situent), leur type (entier, caractère, etc.), leur taille étant décrite par le type, et leur valeur (ce qu'elles contiennent).
La mémoire vive est adressée en octets, c'est pourquoi chaque type de variable occupe un nombre entier d'octets (autrement dit, un octet = une "case mémoire"). Certains types de variable occupent 1 octet, 2, 4, mais jamais 1,5 octet par exemple.

Par exemple, le type int occupe 4 octets (dans une architecture 32 bits), soit 32 bits.
Sous un système 32 bits, un processus peut adresser au maximum 4Go de mémoire vive, soit 2^32 octets. C'est pourquoi une adresse mémoire s'exprime sur 32 bits (quatre octets), et non 32 octets puisque chaque adresse mémoire correspond à un octet de donnée.
Sous un système 64 bits, on aura cette fois 64 bits pour une adresse et donc un espace d'adresse bien plus volumineux.

Pour l'instruction suivante (int monEntier = 25000), si l'entier se trouve à l'adresse 0x000000D1, on peut globalement retrouver l'image mémoire suivante, sans tenir compte de l'endianness qui définit l'ordre des octets dans la mémoire (big endian, little endian) pour ne pas compliquer les choses :

Octet 0x000000D1 : 00000000 (0x00)
Octet 0x000000D2 : 00000000 (0x00)
Octet 0x000000D3 : 01100001 (0x61)
Octet 0x000000D4 : 10101000 (0xA8)

Sachant que 25000 vaut 00000000 00000000 01100001 10101000b en binaire, soit 0x000061A8 en hexadécimal. La variable monEntier se trouve donc à l'adresse 0x000000D1 et s'étend sur 4 octets.
Sur les processeurs Intel, l'endianness utilisé est le little Endian, ce qui signifie que, pour une variable donnée, l'ordre des octets est inversé (et non l'ordre des bits ; l'octet en lui-même n'est pas impacté) : L'octet de poids fort devient celui de poids faible et vice-versa.

Pour une variable de 4 octets par exemple, l'octet 1 devient l'octet 4, l'octet 2 devient 3 et inversement.
Ici, l'octet 1 est 0x00 (0000 0000), l'octet 2 est 0x00 (0000 0000), l'octet 3 est 0x61 (0110 0001) et l'octet 4 est 0xA8 (1010 1000).

Pour une variable de 2 octets, les octets 1 et 2 sont permutés, et ainsi de suite selon la taille de la variable.

En conséquence, le vrai schéma mémoire pour l'instruction est celui-ci :

Octet 0x000000D1 : 10101000 (0xA8)
Octet 0x000000D2 : 01100001 (0x61)
Octet 0x000000D3 : 00000000 (0x00)
Octet 0x000000D4 : 00000000 (0x00).

Résumé en images (mémoire et données sont en notation hexadécimale) :


Sur cette image, on voit bien qu'une variable commence à une adresse et s'étend sur le nombre d'octets indiqué par le type.
Grâce à l'adresse de la variable, le compilateur va savoir à quelle adresse mémoire celle-ci débute, et grâce à son type, il va savoir combien d'octets aller chercher à partir de cette adresse. Indiquer le type d'une variable permet également de savoir comment interpréter le contenu en mémoire. Par exemple, si on crée un char en mémoire, par exemple 'A'. Un "printf("%c", var) affichera 'A' et un printf ("%d", var) affichera 65. Voir table ascci.

Bien que ce soit le compilateur qui se préoccupe de l'endianness, il est intéressant de le savoir pour savoir ce que vous faites et mieux comprendre d'où viennent les erreurs.
Par exemple, les données échangées sur le réseau (paquets IP) sont en big-endian. Pour envoyer des données sur le réseau à partir d'un processeur Intel, il faut donc changer l'endianness. Heureusement, il existe pour cela des fonctions toute faite (vous pouvez les utiliser de n'importe quel PC si vous ne connaissez pas l'endianness de ce dernier).
Voici ici.

Notes : 
- ntohl = Network To Host Long
- htonl = Host To Network Long.

Il y a d'autres cadres où cela est utile : remplir une image pixel par pixel, conversion d'endianness, utilisation des unions, etc.

Mais pourquoi donc inverser les octets, quelle en est l'objectif ?
Certes, à chaque fois qu'on va lire une donnée en mémoire, il faut l'inverser pour la lire correctement (inconvénient).
Mais il y a un avantage (que m'avait expliqué un membre d'un forum) : Admettons qu'on ait le nombre 00 00 00 E5 à l'adresse 0x000000DD.
Si on souhaite faire du cast, par exemple d'un int (4 octets) vers un short (2 octets), il ne faut garder que les deux octets de poids faible du nombre. Cela veut dire que si on est en big endian (pas d'inversion), le compilateur doit décaler la lecture de deux octets (c'est-à-dire sur 0x000000DF) pour lire la donnée (sinon il lit les deux octets de poids fort 00 00), alors qu'en little endian, le compilateur lit directement à la même adresse, puisque les octets de poids faible sont stockés en premier (E5 00).

2) Les pointeurs

Un pointeur est un type de variable particulier, dont le rôle est de pointer sur une autre variable, pour pouvoir accéder et modifier son contenu dans le code principal du programme (main). La particularité d'un pointeur est que sa valeur contient l'adresse d'une autre variable. De ce fait, un pointeur a une taille de 4 octets pour une raison simple : une adresse s'exprime sur quatre octets pour un système 32 bits. Pour un système 64 bits et une compilation 64 bits, on aura donc une taille de pointeur sur 8 octets.
N.B : Ceci est du à l'architecture physique des composants (processeur, RAM, bus, etc) et du système d'exploitation qui utilise cette infrastructure.

Voici comment on déclare et utilise une variable pointeur dans un programme :


--- Code: ---#include <stdio.h>

int main () {
          int monEntier = 25000;
          int *pointeurSurMonEntier = &monEntier;

          printf ("L'adresse de mon pointeur est : %p \n", &pointeurSurMonEntier);
          printf ("Adresse de monEntier = %p \n", pointeurSurMonEntier);
          printf ("monEntier = %i = % i \n", monEntier, *pointeurSurMonEntier);
         
          *pointeurSurMonEntier = 2000;
          printf ("monEntier vaut maintenant = %i = % i \n", monEntier, *pointeurSurMonEntier);
}
--- Fin du code ---

Dans la première ligne de ce programme, on déclare et on initialise la variable monEntier à 25000. Dans la deuxième, on déclare un pointeur pointeurSurMonEntier qui prend comme valeur l'adresse de la variable monEntier de type int précédemment déclarée. C'est donc un pointeur sur int (int *).
L'étoile (*) sert à indiquer qu'on crée un pointeur, le "et commercial" (&) sert à indiquer l'adresse de la variable qui suit et le type indiqué est celui de la variable sur laquelle le pointeur va pointer (ici, un entier).
On ne fait usage de l'étoile que lors de la déclaration du pointeur, et ensuite pour modifier la variable dont l'adresse est contenue dans le pointeur.
Dans le premier printf, on affiche l'adresse du pointeur avec le &, comme pour une autre variable. Dans le deuxième, on affiche le contenu du pointeur, autrement dit, l'adresse de la variable monEntier. Enfin, dans le troisième, on affiche la valeur de la variable pointée. On découvre dans cette ligne que pour accéder (lire) ou modifer le contenu de la variable pointée, on ajoute l'étoile devant le pointeur.

Allez, je suis gentil, je remets le schéma précédent, en ajoutant la notion de pointeur :


Ainsi, pointeurSurMonEntier permet de connaître l'adresse de la variable et *pointeurSurMonEntier permet d'accéder au contenu de la variable pointée : *(pointeur) = valeur correspondant à l'adresse contenue dans le pointeur.
C'est de cette manière, que dans la ligne suivante, on modifie le contenu de la variable monEntier, non pas en écrivant "monEntier = 2000", mais "*pointeurSurMonEntier = 2000". On voit dans la ligne suivante que la variable a bien été modifiée.


Sur cette image, on constate que l'adresse (dans l'espace d'adressage du processus) du pointeur est 0x0028FF18 et que l'adresse de la variable pointée est 0x0028FF1C, ce qui montre bien que le pointeur prends 4 octets, puisque 0028FF1C - 0028FF18 = 4. Ici, il se trouve que notre variable et notre variable pointeur sont contiguës en mémoire, donc ne soyez pas étonné si vous n'avez pas la même chose.
Note : Pour coder proprement, si à la création du pointeur, on ne lui assigne pas de valeur précise, il est conseillé de l'initialiser à NULL. (int *pointeur = NULL)

3) Les fonctions

Maintenant, quel est l'intérêt d'utiliser les pointeurs ?

Prenons une fonction dont le but est de permuter le contenu de deux variables (a devient b, et b devient a) :


--- Code: ---#include <stdio.h>

/* On met dans une variable tampon param1 pour ne pas perdre son contenu, puis on permute les contenus de a et b grâce à tmp */
void swap(int param1, int param2) {
int tmp;
tmp = param1;
param1 = param2;
param2 = tmp;
}

int main () {
        int a =20, b=40;
        swap (a, b);

        printf ("a vaut maintenant %i et b vaut maintenant %i\n", a, b);
        return 0;
}

--- Fin du code ---

Cela affiche : "a vaut maintenant 20 et b vaut maintenant 40".

Comme on le remarque, a et b n'ont pas été permutés ! Pourtant, le code de la fonction est correct. Revenons un peu à des notions générales.

Un processus (programme en exécution) voit son espace d'adressage de 4 Go (de 0x00000000 à 0xFFFFFFFF) divisé en quatre zones :
- la zone de code : Statique, elle contient les instructions du programme à exécuter,
- la zone de données : Statique, elle contient certaines données, notamment les variables globales,
- la zone de la pile d'exécution : Dynamique et suivant le fonction FILO (First In, Last Out), elle est utilisée par toutes les fonctions appelées (y compris le main, fonction principale), notamment pour stocker les variables locales et paramètres.
- la zone du tas : Dynamique, elle sert notamment à l'allocation dynamique de mémoire (malloc, free ..).

Schéma :


Note : En considérant que plus on monte en hauteur, plus l'adressage du processus est élevé, on remarque que la zone de pîle croît à l'envers (vers le bas, donc l'adresse du sommet de la pile diminue au lieu d'augmenter).

N.B : Chaque processus possède son propre espace d'adressage de la mémoire depuis que la mémoire virtuelle a été mise en place. Pour résumer de manière ultra-simplifiée, une adresse mémoire dans l'espace d'adressage de votre processus va correspondre en réalité à une toute autre adresse dans la mémoire physique grâce à des mécanismes de traduction (voir ici). Pour ainsi dire, les schémas que j'ai mis au-dessus sont "vrais" (possible) uniquement pour un schéma d'adressage virtuel relatif au processus. Dans la mémoire physique, l'adresse du pointeur sera peut-être bien éloignée de celle de l'entier sur lequel il pointe, etc.

Les fonctions, comme void swap (int, int), se servent de cette zone, et non de la zone de code ou de données. Ainsi, toutes les variables manipulées à l'intérieur de la fonction, ainsi que les paramètres sont locales !
La zone de pile fonctionne comme un tas d'assiettes. Lorsque le programme principal (ou même une fonction) appelle une fonction, les paramètres (assiettes) sont empilées sur le tas déjà présent. Lors de la création de variables à l'intérieur de la fonction, de nouvelles assiettes sont empilées. À la fin de la fonction, toutes les assiettes (concernant la fonction) sont dépilées et donc détruites.
C'est pourquoi on parle de variables locales ! Les paramètres (a et b dans notre cas), sont donc des copies des vraies variables a et b, pour que nos variables ne soient pas détruites lorsqu'on appelle une fonction. Généralement, pour résoudre ce problème, on utilise des fonctions qui retourne un certain type de donnée. Le problème est qu'on ne peut retourner qu'un type de donnée avec une fonction. C'est l'une des raisons pour laquelle on utilise les pointeurs. Le main est lui aussi exécuté en pile, sauf qu'on s'en fiche que les variables soient locales, puisque lorsque le main finit, le programme finit.

Il existe deux types de paramètres pour une fonction : les paramètres qu'on passe par valeur/copie et ceux que l'on passe par adresse/référence. L'utilisation des pointeurs va consister à ne plus mettre en paramètres les valeurs des variables qui vont être impactées dans la fonction, mais directement leur adresse (dans l'espace d'adressage du processus). De cette manière, on va pouvoir modifier directement le contenu des variables à l'intérieur des fonctions à l'aide de pointeurs. Pour résumer les paramètres par valeur/copie, tout ce qui est fait à l'intérieur de la fonction est détruit à la fin de celle-ci. À part pour une fonction d'affichage, il faudra donc généralement toujours utiliser le passage par adresse (pointeurs) ou ajouter un retour à la fonction selon vos besoins.

Notre fonction devient donc :


--- Code: ---#include <stdio.h>

void swap(int *param1, int *param2) {
int tmp;
tmp = *param1;
*param1 = *param2;
*param2 = tmp;
}

int main () {
        int a =20, b=40;
        swap (&a, &b);

        printf ("a vaut maintenant %i et b vaut maintenant %i\n", a, b);
}
--- Fin du code ---

Ce code affiche "a vaut maintenant 40 et b vaut maintenant 20". Cette fois-ci, ça marche bien.

Dans le prototype de la fonction, on indique des pointeurs en paramètre, puisqu'on va recevoir des adresses. Lors de l'appel de la fonction, le pointeur param2 prend la valeur de l'adresse de a, et respectivement pour param2 et b. C'est comme si on avait "int *param1 = &a; int *param2 = &b;"
Si vous avez compris l'utilisation des pointeurs plus haut, je n'ai pas besoin d'expliquer plus en détails. Faites des tests !

Petite note additive :

J'espère maintenant que vous comprenez pourquoi la fonction scanf par exemple s'utilise comme suit :


--- Code: ---int nombre=0;
printf("Choisis un nombre : ");
scanf("%d", &nombre);
--- Fin du code ---

En effet, si la fonction scanf veut "insérer la frappe du clavier" (selon le type renseigné) dans la variable transmise en paramètre, il faut qu'elle ait son adresse pour se dire "je dois écrire à cette adresse-là". Si on avait mis en paramètre "nombre", on lui aurait envoyé 0 (la valeur de la variable nombre). On voit alors difficilement comment ça peut marcher. Scanf attend donc en paramètre un type de pointeur particulier (une adresse) selon ce que vous spécifiez en premier paramètre (%d= entier, %c = char, etc), qui définit le type de frappe attendue. Par exemple, si vous écrivez en premier paramètre "%d", scanf va alors attendre en paramètre suivant un pointeur sur entier (l'adresse d'un entier). Le paramètre de la fonction est un pointeur, dont la valeur est l'adresse transmise à l'appel de fonction.
Le premier paramètre est un char * (pointeur sur caractères), car on entre une chaine de caractères, ici "%d". Effectivement, il y a plus qu'un caractère (là, il y en a deux : '%' et 'd'), nous verrons ça dans la suite.

igor51:
UP !

Navigation

[0] Index des messages

Sortir du mode mobile