L'utilisation des pointeurs en C est incontournable pour qui veut coder proprement et efficacement. Nous proposons ici une approche simple pour les découvrir, par des exemples et la comparaison au codage sans pointeur.
Il n'est pas question ici de faire un cours magistral sur les pointeurs mais seulement d'en comprendre l’intérêt et les principes de base, sans se perdre dans les détails.
Une fois cet article assimilé, vous pourrez découvrir la suite :
Les pointeurs, approfondissement (Partie 2 sur 2)
Cet article est articulé en quelques points principaux :
- Une courte intro
- Un petit rappel sur les adresses et la déclaration de variables
- La découverte des pointeurs et de leur déclaration
- Le passage de pointeurs à une fonction
- Un résumé
Intro
Cet article utilise certaines notions considérées comme acquises, telles que la déclaration et l’initialisation d’une variable, l’appel d’une fonction, le passage de valeurs à une fonction.
Il en sera fait un rapide rappel, si vous ne vous sentez pas à l’aise avec ces notions, commencez par lire d’autres articles afin de vous familiariser avec les bases du C.
Une histoire d’adresse
La mémoire d’un micro-contrôleur, tout comme celle d’un ordinateur peut être représentée sous forme d’un tableau de 2 colonnes et d’un grand nombre de lignes (surtout pour un ordinateur), voici un exemple totalement arbitraire :
Adresses | Contenu |
0 | 255 |
1 | 3 |
2 | 123 |
3 | 42 |
4 | 12 |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
Au démarrage, les valeurs contenues dans la RAM (mémoire volatile) sont totalement aléatoires tant qu’elles ne sont pas initialisées dans le programme. Ce n’est pas le cas de la FLASH (mémoire non volatile, contenant le programme).
Dans votre programme, contenant la célèbre fonction « main », vous pouvez trouver :
void main(void)
{
unsigned char ma_variable = 10;
}
Dans cet exemple, il est demandé au
compilateur d’allouer un espace dans la mémoire dont il dispose et de lui donner la valeur "10".
Cette déclaration étant faite à l’intérieur d’une fonction, la variable est dite "Locale", contrairement aux variables déclarées hors des fonctions qui sont dites "Globales".
Admettons que le compilateur décide de placer cette variable à l’adresse 0, voici à quoi pourrai ressembler le début de la mémoire de notre micro-contrôleur :
(Dans le cas, ou l’utilisateur n’a pas imposé de mapping particulier au compilateur).
Adresses | Contenu |
0 | 10 |
1 | 3 |
2 | 123 |
3 | 42 |
4 | 12 |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
Les données sont disponibles tant que le programme se trouve à l’intérieur de la fonction ayant demandé l’allocation de la mémoire. Par contre, une fois sorti de cette fonction, le compilateur va considérer qu’elles n’ont plus lieu d’être utilisées, et pourra utiliser cet espace mémoire à d’autres fins.
Pour mettre en avant cette notion, voici concrètement un exemple :
Dans cet
exemple qui nous servira de fil rouge durant cet article, nous allons écrire un petit programme simple ayant pour but de convertir des minutes en heures et minutes (Exemple : 90 minutes, correspondent à 1 heure et 30 minutes).
Dans un premier essai, admettons que l’on ait déclaré deux variables de façon globale :
unsigned char Heures = 0 ;
unsigned char Minutes = 90 ;
Et que l’on utilise une fonction comme celle-ci pour convertir des données :
void conversion(unsigned void)
{
Heures = Heures + Minutes / 60 ; //1 heure = 60 minutes
Minutes = Minutes % 60 ; //minutes = reste de minutes / 60
}
Dans le "main", on appelle alors cette fonction :
void main(void)
{
conversion() ;
}
Nous aurons bien, Heures = 1, et Minutes = 30.
Le problème est que si plusieurs conversions sont nécessaires, les contenus de "heures" et "minutes" (variables globales) seront écrasés à chaque conversion. Et que seules ces variables peuvent être utilisées pour faire cette conversion.
Pour éviter cela nous pouvons passer des valeurs à la fonction de conversion, afin qu’elle n’utilise pas de variables globales, ce qui donnera plus de portabilité à la fonction.
Voici la fonction qui sera chargée de faire cette conversion :
void conversion(unsigned char heures, unsigned char minutes)
{
heures = heures + minutes / 60 ; //1 heure = 60 minutes
minutes = minutes % 60 ; //minutes = reste de minutes / 60
}
Et la fonction main, qui initialise le programme, et appelle la conversion :
void main(void)
{
unsigned char heures = 0 ;
unsigned char minutes = 90 ;
conversion(heures, minutes) ;
}
Lignes par lignes, le programme commence dans le "main", il va demander au compilateur de lui allouer deux espaces en mémoire ("heures" et "minutes") et de leurs donner les valeurs 0 et 90.
Puis il appelle la fonction "conversion" en lui passant ces deux valeurs.
Dans cette fonction les nombres d’heures et de minutes passés en paramètre vont être convertis comme demandé, puis la fonction se termine et le programme retourne dans le "main".
Si à ce moment là on décide d’afficher le nombre d’heures et de minutes qui ont été calculées précédemment, voici ce que donnerai le résultat :
heures = 0 ;
minutes = 90 ;
Et là je vous vois faire de grands yeux !!! Juste avant, ces valeurs ont été modifié dans la fonction "conversion" non ?!
Réponse : hé bien non ! Voici le pourquoi…
A la fonction "conversion", on passe les valeurs de "heures" et "minutes" qui valent bien respectivement 0 et 90. Hors dans cette fonction, sont déclarées deux autres variables ayant pour noms "heures" et "minutes".
Mais où sont elles déclarées, me direz-vous !
A cette ligne, je vous répondrais :
void conversion(unsigned char heures, unsigned char minutes)
De ce fait, le compilateur va allouer deux autres espaces mémoires, qui prendront pour valeurs celles que l’on passera à la fonction lors de son appel, ici 0 et 90. Puis il utilisera ces deux nouvelles variables pour faire ses calculs, mais sans toucher à celles crées dans le "main"! Elles peuvent avoir le même nom, mais pour le programme, elles n’ont absolument rien à voir !
Pourquoi utiliser des pointeurs ?
Pour faire rapide, l’intérêt d’une fonction est de regrouper dans un même "bloc" une suite d’instructions qui vont être régulièrement utilisées, et de les appeler au besoin sans avoir à tout réécrire.
La possibilité de leur passer des valeurs permet de rendre ce système encore plus pratique et lui donner une capacité d’adaptation.
Comment faire pour récupérer le résultat qu’une fonction a calculé ?
Autant il est facile de faire renvoyer une seule valeur à une fonction :
unsigned char test(unsigned char entree)
{
entree = entree * 3 ;
return entree ;
}
Cette fonction va recevoir en paramètre une valeur "entree", qu’elle va multiplier par trois avant de retourner le résultat.
Comment faire pour qu’une fonction renvoie plusieurs valeurs ?
Vous pouvez retourner le problème dans tous les sens, il est impossible en C de faire retourner plus d’une seule valeur à une fonction.
Dans la suite de cet article, nous verront comment il est possible de contourner ce problème grâce aux pointeurs.
On ne montre pas du pointeur !
Comme le dit le titre, un pointeur ça montre, ou plutôt ça pointe.
Pour reprendre l’exemple ci-dessus, nous avions dans le "main" :
unsigned char heures = 0 ;
unsigned char minutes = 90 ;
Disons que le compilateur décide de placer "heures" à l’adresse 0, et "minutes" à l’adresse 1 ; voilà à quoi ressemblerait notre mémoire :
Adresses | Contenu |
0(heures) | 0 |
1(minutes) | 90 |
2 | 123 |
3 | 42 |
4 | 12 |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
Introduisons maintenant une nouvelle notion,
le pointeur :
unsigned char *pointeurSurheures = NULL;
Il s’agit là aussi d’un
emplacement en mémoire qui va être alloué par le compilateur, sauf qu’au lieu de contenir une valeur, elle
va contenir une adresse.
Les pointeurs DOIVENT être initialisés, avec la valeur NULL (en majuscules) à défaut d’avoir directement leur valeur utile.
Un pointeur se reconnait à sa déclaration grâce à l’
* devant son nom.
Ecrivons maintenant ceci dans notre programme :
void main(void)
{
unsigned char heures = 0 ;
unsigned char minutes = 90 ;
unsigned char *pointeurSurheures = NULL ;
pointeurSurheures = &heures ;
}
Le symbole "&" signifie "adresse de".
En toutes lettres, nous demandons au compilateur d’allouer deux emplacements en mémoire de type "unsigned char", nommés "heures" et "minutes", ainsi qu’un troisième emplacement mémoire de type "unsigned char *", soit un pointeur sur un "unsigned char" nommé "pointeurSurheures".
Puis ce dernier reçoit la valeur "&heures", c'est-à -dire l’adresse de "heures", soit 0.
Voici donc notre mémoire après cela :
Adresses | Contenu |
0(heures) | 0 |
1(minutes) | 90 |
2(pointeurSurHeures) | 0(adresse de "heures" |
3 | 42 |
4 | 12 |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
NOTE : En fait, ce tableau n’est pas correct.
Sur un microcontrôleur 8bits (du moins chez Freescale, à vérifier pour d’autres fondeurs), les adresses des emplacements mémoire sont codés sur 16 bits, et vont de 0x0000 à 0xFFFF (0 à 65535 en base décimale, soit 65536 adresses).
Hors un pointeur est sensé contenir l’adresse de n’importe quel élément en mémoire, il doit dont contenir une valeur comprise entre 0x0000 et 0xFFFF, pour cela il lui faut utiliser
deux octets en mémoire.
Sur un micro-contrôleur 32 bits, les adresses sont codées sur 32 bits, soit 4 octets, les pointeurs occupent dont 4 emplacements mémoire. (Même remarque, valable chez Freescale).
Ainsi, sur un micro-contrôleur 8 bits, le tableau de mémoire serait en réalité ceci :
Adresses | Contenu |
0(heures) | 0 |
1(minutes) | 90 |
2(pointeurSurHeures) | 0(adresse haute de "heures" |
3(pointeurSurHeures) | 0(adresse basse de "heures" |
4 | 12 |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
Sur un microcontrôleur 32 bits, le tableau de mémoire deviendrait :
Adresses | Contenu |
0(heures) | 0 |
1(minutes) | 90 |
2(pointeurSurHeures) | 0(adresse haute de "heures" |
3(pointeurSurHeures) | 0(adresse haute de "heures" |
4(pointeurSurHeures) | 0(adresse basse de "heures" |
5(pointeurSurHeures) | 0(adresse basse de "heures" |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
Cela dit, dans un souci de clarté et de simplicité, nous ne représenterons les pointeurs que sur un seul octet dans cet article ; le but étant d’illustrer le principe. Fin de l’apparté.
Comment utiliser cette adresse ?
Si on veut modifier la valeur de la variable « heures », on peut écrire ceci :
heures = 3 ;
Ce qui va mettre la valeur 3 dans la case mémoire correspondant à la variable "heures". Mais on peut aussi utiliser le fait que l'on connait désormais son adresse, contenue dans la variable "pointeurSurheures".
On peut donc écrire ceci :
*pointeurSurheures = 3 ;
Ce qui signifie : mettre la valeur 3, à l’emplacement dont "pointeurSurheures" contient l'adresse, soit mettre 3 à l’adresse 0.
Abracadabra !!!
Nous avons la solution à notre problème du départ qui consistait à faire retourner deux valeurs à une fonction.
Si nous passons des valeurs à cette fonction, elle peut toujours modifier ses variables locales, cela n’a aucun impact sur les variables passées et déclarées à l’extérieur.
Mais si on passe les adreses des paramètres à la fonction, elle va alors pouvoir modifier directement le contenu des variables d’origines !
Un petit détail : lors de la
déclaration d'un pointeur le type de données vers lequel pointe le pointeur doit figurer devant le pointeur.
Un pointeur vers une variable de type "unsigned char" :
unsigned char *pointeur = NULL ;
Un pointeur vers une variable de type "float" :
float *pointeur = NULL ;
Un pointeur vers une structure de type "mastructure" :
struct mastructure *pointeur = NULL ;
etc…
Pouvez-vous me passer le pointeur, s’il vous plait ?
Il est aussi possible de passer un pointeur à une fonction.
Au lieu de déclarer une fonction ne renvoyant rien, et à qui l’on passe deux "unsigned char" ainsi :
void conversion(unsigned char heures, unsigned char minutes)
On va déclarer une fonction à qui on va passer deux pointeurs vers des variables de type "unsigned char" :
void conversion(unsigned char *heures, unsigned char *minutes)
Ainsi notre programme du début deviendra ceci :
void conversion(unsigned char *ptheures, unsigned char *ptminutes)
{
*ptheures = *ptheures + *ptminutes / 60 ; //1 heure = 60 minutes
*ptminutes = *ptminutes % 60 ; //minutes = reste de minutes / 60
}
void main(void)
{
unsigned char heures = 0 ;
unsigned char minutes = 90 ;
unsigned char *pointeurSurheures = &heures ;
unsigned char *pointeurSurminutes = &minutes ;
conversion(pointeurSurheures, pointeurSurminutes) ;
}
Pareil que tout à l’heure, on déclare deux variables de type "unsigned char", nommées "heures" et "minutes", initialisées à 0 et 90.
Mais aussi des pointeurs vers des "unsigned char" nommés "pointeursVersheures" et "pointeursVersminutes",
initialisés avec les adresses de "heures" et de "minutes".
Notre mémoire (simplifiée) ressemble alors à ceci :
Adresses | Contenu |
0 (heures) | 0 |
1 (minutes) | 90 |
2 (pointeurSurHeures) | 0 (adresse de "heures" |
3 (pointeurSurMinutes) | 1 (adresse de "minutes" |
4 | 12 |
... | ... |
1000 | 0 |
... | ... |
(Très grand nombre) | 42 |
Ou encore pour le « main » :
void main(void)
{
unsigned char heures = 0 ;
unsigned char minutes = 90 ;
conversion(&heures , &minutes ) ;
}
Ainsi, on passe les adresses de "heures" et de "minutes" à la fonction "conversion", qui va modifier le contenu de la mémoire à ces adresses.
La ligne :
*ptheures = *ptheures + *ptminutes / 60 ;
Signifie : le contenu de l’adresse "ptheures" vaut le contenu de l’adresse "ptheures" plus le contenu de l’adresse "ptminutes" divisé par 60.
Avec au départ "ptheures" = adresse de "heures", soit 0, et "ptminutes" = adresse de "minutes", soit 1.
Nous avons donc "ptminutes" = 1, et "*ptminutes" = 90.
Pour résumer
Lorsque l’on appelle une fonction déclarée ainsi :
void conversion(unsigned char heures, unsigned char minutes)
Deux variables dites locales vont être crées, et elles n’auront rien à voir avec d’autres variables crées à l’extérieur, même si elles ont le même nom.
La ligne :
unsigned char mavariable = 0 ;
Déclare une variable de type "unsigned char" et l’initialise avec la valeur 0.
unsigned char *pointeur = NULL ;
Déclare un pointeur vers une variable de type "unsigned char".
pointeur = &mavariable ;
Affecte à "pointeur" l’adresse de la variable "mavariable"
*pointeur = 5 ;
Affecte la valeur 5 à l’adresse sur laquelle "pointeur" pointe ; ici "mavariable" prendra la valeur 5.
A savoir aussi que le passage par paramètres, c'est-à -dire sans utilisation de pointeurs, a deux autres inconvénients :
-Il y a copie des données en RAM, donc doublement de l’utilisation de celle-ci.
-Cette copie prend du temps au CPU.
Alors que avec
le passage par référence (c'est le vrai nom du passage par pointeur) les données sont modifiées sur leur lieu d'origine ce qui induit ni surconsommation de RAM ni temps additionnel d'exécution.
N'hésitez pas à laisser des commentaires ci dessous si cet article vous a intéressé.