Les pointeurs font tout à la fois la puissance et la complexité du C.
La puissance parce que cela donne les moyens d’écrire du code très optimisé, réglé aux petits oignons, mais aussi dit "bas-niveau", proche du matériel visé, ce dont ont besoin tous les systèmes d’exploitation aujourd’hui majoritairement écrits en C et C++.
La complexité est très présente via une syntaxe ardue, et des erreurs rendues très faciles -et très violentes- par la toute puissance du langage.
Les pointeurs
Les pointeurs (1)
Avancer en C
.
Par :
DIFFICULTÉ :★★★
Les pointeurs permettent de manipuler ce que tout langage évolué tente de masquer : l’organisation interne de la machine.
Avant de voir la syntaxe de ces pointeurs, un peu de géographie...
Comment ça marche...
La mémoire centrale d’un ordinateur est une vaste étendue d’octets qui sont adressables individuellement. Je parle là de la mémoire volatile, la Ram, celle qui disparaît lorsque le courant est coupé... Sur l’Arduino, il s’agit de la mémoire SRAM. Le programme est lui stocké dans une autre mémoire, la mémoire flash, qui reste chargée même si l’on coupe le courant, mais dont le contenu n’est pas modifiable pendant l’exécution du programme. Sur un Arduino Uno, il y a 2Ko de mémoire SRAM, ce qui signifie que vous avez 2048 (2 * 1024) octets pour stocker vos variables de travail et la pile pendant l’exécution de votre croquis.
Le pile est une espace réservé par le système pour la gestion de l’exécution, notamment la valeur du pointeur d’exécution pour savoir où revenir lorsqu’une fonction est appelée. La place prise par une pile est souvent de plusieurs dizaine d’octets, et peut rapidement augmenter si l’on n’y prête pas garde avec des fonctions récursives ou des données locales à la fonction trop importantes..
La mémoire est assimilable à une longue liste d’octets, linéaire et finie. Chaque octet est identifiable par une adresse individuelle exprimée par un entier. Les opérations arithmétiques sont tout à fait envisageables : si A est l’adresse d’un octet, A+1 est bien l’adresse de l’octet suivant dans la liste/mémoire.
Lorsque l’on parle de pointeurs, on parle d’adresses. Et il faut une syntaxe pour différencier l’adresse d’un octet de son contenu :
Que se passe t-il lorsque l’on utilise une déclaration simple comme byte valeurN=10;
?
Le processeur va réserver un emplacement mémoire pour cette variable, et déclarer que valeurN
représente son contenu. Qui modifie valeurN
modifie le contenu de sa mémoire directement.
Si on ajoute byte valeurM=20;
, une nouvelle portion de mémoire est réservée, pas forcément à côté de valeurN
.
Du point de vue des types de donnée : valeurN
et valeurM
sont des byte
, tandis que les pointeurs vers les bytes valeurN
ou valeurM
sont des byte *
. L’étoile signifie que l’on parle ici d’un pointeur. On peut retrouver l’adresse d’un contenu avec le &
:
Ce qui permet d’écrire :
byte valeurN = 10;
byte *pPointeurN = NULL; // initialisation à vide d'un pointeur vers un byte.
Serial.println(valeurN); // affiche 10
pPointeurN = &valeurN; // affectation de l'adresse de valeurN vers le pointeur.
Serial.println(*pPointeurN); // affiche 10
valeurN = 11;
Serial.println(valeurN); // affiche 11
Serial.println(*pPointeurN); // affiche 11
Une petite astuce pour mieux relire son code consiste à préfixer tous les noms de variables qui sont des pointeurs avec ’p’. Lorsque je vois pMonPointeurDOctet = 10;
je comprend tout de suite que quelque chose ne va pas. Sauf cas exceptionnel, on n’affecte jamais un pointeur avec une valeur fixe absolue. C’est toujours le résultat d’un &
ou un nom de liste... Donc soit on a voulu écrire Entier = 10;
et le ’p’ n’est pas justifié, soit on aurait dû écrire *pMonPointeurDOctet = 10;
avec l’étoile devant. Mais dans tous les cas, la ligne qui est pourtant correcte en syntaxe C pure doit être corrigée sous peine de changer l’adresse d’un pointeur et de ne plus du tout pointer vers ce que l’on croit. Comme pour toute règle, il y a au moins une exception. Celle-ci ne déroge pas : le seul cas classique d’attribution directe d’une adresse à un pointeur est pour l’initialiser. On met 0 (zéro) ou NULL
dans la valeur, et on dit que le pointeur est nul. Il faut prendre l’habitude d’initialiser systématiquement les pointeurs avec NULL
ou une valeur correcte. Un pointeur non initialisé n’est pas à NULL
et donc pointe sur n’importe quel emplacement mémoire. Si quelqu’un écrit *monPointeur = 10;
alors que monPointeur
n’a pas été affecté à quelque chose de cohérent, on va écrire 10 n’importe où dans la mémoire. Ça peut être à un endroit interdit, au milieu d’une autre variable ou même au milieu du code du programme sur certains systèmes. Ce n’est pas le cas sur un Arduino qui sépare bien la zone mémoire vive, la SRAM, et la zone mémoire programme. Si le pointeur est nul, initialisé à NULL
, le programme va se planter immédiatement et le problème sera beaucoup plus facile à trouver...
Un tableau, c’est un pointeur ou pas ?
Le cas des tableaux est particulier. Le nom d’un tableau se comporte comme un pointeur. La notation []
permet de parcourir le contenu du pointeur avec la valeur de l’index.
byte maListe[4] = {10, 20, 30, 40};
Serial.println( maListe[1] ); // affiche 20
Serial.println( *(maListe+1) ); // affiche 20
maListe[1] = 25;
Serial.println( *(maListe+1) ); // affiche 25
* (maListe + 1) = 26;
Serial.println( maListe[1] ); // affiche 26
Les deux notations *
ou []
sont interchangeables.
Un type de tableau très utilisé, c’est la chaîne de caractères. Il ne vous a pas échappé que la notation char name[12];
décrit bien un tableau. Le cas est particulier parce qu’une chaîne de caractères est une liste dont le dernier élément est un 0. Pas le caractère ’0’ qui est le code ASCII 48, mais bien un octet de valeur 0.
Comme tous les tableaux, la chaîne de caractère n’a pas connaissance de sa longueur. On peut donc lire et écrire n’importe où dans ce tableau, y compris avant ou après l’espace qui lui a été réservé. J’ai par exemple tout à fait le droit d’écrire name[12] = 'A';
bien que j’aie déclaré char name[12];
plus haut dans le code, avec des valeurs d’indice possibles de 0 à 11 donc... La syntaxe est correcte, le compilateur ne dira rien. Mais à l’exécution si le programme ne va probablement pas se planter tout de suite, il est presque certain qu’une donnée sera écrasée par l’affectation, et que cela se paiera plus tard... En effet, dans la mémoire les objets sont souvent contigus, et écrire au delà de la fin d’un tableau, c’est probablement détruire le début du suivant...
Tout ça pour dire que dans le cas des chaînes, on a besoin de savoir où se trouve la fin, et c’est le 0 qui l’indique puisqu’il ne représente pas un caractère valide. Il faudrait sans doute écrire un article rien que pour parler des chaînes de caractères... (On me dit dans l’oreillette que c’est déjà en cours et presque terminé. Nous vous tiendrons au courant... (J’apprend que c’est terminé et disponible là : Les chaînes de caractères)). Signalons que c’est une méthode utilisable par tout le monde, tout tableau peut être géré manuellement de cette manière en utilisant une valeur particulière pour signaler sa fin.
Tout ce que l’on vient de dire marche bien parce que l’on manipule des bytes, des octets. Mais si chaque élément de la liste est plus grand qu’un octet ? Imaginons une liste d’entiers. Chaque entier utilise deux octets pour son stockage. Que se passe t’il quand j’écrit tab[1]
?
int tab[3] = {1, 2, 3 };
Serial.println( tab[1] ); // affiche 2
Serial.println( *(tab+1) ); // affiche 2
Le langage applique ce que l’on appelle l’arithmétique de pointeur. Le pointeur est typé int
. Comme le compilateur via la déclaration en int
sait que le contenu de chaque élément dans la liste occupe deux octets, il peut multiplier l’index demandé avec la taille d’un objet, et obtenir l’adresse du début de l’élément. Même avec la notation tab+1
, il fait cette opération...
Mais à quoi peuvent bien servir les pointeurs...
On l’a vu, la première utilisation, souvent sans que le programmeur s’en doute, ce sont les tableaux. Une autre utilisation fréquente c’est un argument de fonction que l’on veut pouvoir modifier :
void GetValue(int *pRet)
{
*pRet = 10;
}
void setup()
{
int i = 0;
GetValue(&i);
Serial.println(i, DEC); // Affiche 10
}
L’argument de GetValue()
est le pointeur *pRet
. C’est le contenu du pointeur que l’on veut mettre à jour avec *pRet = 10;
. Comme déjà dit plus haut (mais il faut insister, c’est une erreur courante), si j’avais écris pRet = 10;
(sans l’étoile devant), c’est le pointeur lui même que j’aurais écrasé. Comme pRet
est une variable locale à la fonction GetValue()
et que l’on n’y fait rien d’autre sur ce pointeur, il n’y aurait pas eu d’autre conséquence qu’une mauvaise valeur retournée.. Quant à l’utilisation, on déclare la variable i
, et on en passe son adresse à GetValue()
avec &i
.
Les pointeurs peuvent servir à des objets plus complexes, comme des structures.
struct Feu
{
bool rouge;
bool jaune;
bool vert;
};
Feu feu1, feu2, feu3;
void Vert(Feu *pFeu)
{
pFeu->rouge = false;
pFeu->jaune = false;
pFeu->vert = true;
}
// utilisation
Vert(&feu1);
Vert(&feu3);
L’adresse de feu1
est passée en argument à la fonction Vert()
, et c’est donc lui que modifiera Vert()
et disant pFeu->vert = true;
. Pareil pour l’appel suivant qui va cette fois mettre à jour feu3
. Notez bien le ->
qui remplace le point habituel dans les structures et les classes et qui signale que la partie à gauche de l’expression (ici pFeu
) est un pointeur. Le compilateur signalera toute confusion : même lui ne peut pas travailler si l’on s’est trompé !
Allocation mémoire ? Quésako ?
Réserver une certaine quantité de mémoire passe souvent par la déclaration d’un tableau comme int tab[10];
. Mais comment faire si l’on ne sait pas de combien d’exemplaires on doit disposer, ou si l’utilisation de l’objet est optionnelle ? Pour cela il y a l’allocation dynamique de mémoire.
Le principe est de demander au système (ici l’Arduino) de trouver et réserver une zone de mémoire continue d’une taille donnée, et d’en retourner le pointeur de début.
int *pTableau;
pTableau = new int[10];
pTableau[0] = 20;
L’instruction new
va demander la mémoire pour dix entiers et renvoyer l’adresse de début de cette zone réservée dans le pointeur pTableau
. Ensuite manipuler le tableau se fait de la même manière que s’il était déclaré en dur.
Lorsque son utilisation est terminée, il est possible de libérer la zone de mémoire allouée avec delete[]
.
int *pTableau;
pTableau = new int[10];
pTableau[0] = 20;
...
delete[] pTableau;
pTableau = NULL;
Pour le langage C, l’allocation d’un tableau est traitée différemment d’une allocation simple comme int *pointeur = new int;
. Elle doit donc être libérée d’une manière différente aussi. L’allocation de pTableau
doit être libérée avec le mot clé delete[]
(si, si, avec les crochets...) : delete[] pTableau;
là où l’allocation simple sera libérée avec juste delete
: delete pointeur;
!
La remise à NULL
du pointeur pTableau = NULL;
évite toute tentative ultérieure d’utilisation d’une mémoire libérée... En effet rien n’empêche d’écrire le code suivant :
int *pTableau;
pTableau = new int[10];
pTableau[0] = 20;
...
delete[] pTableau;
pTableau[5] = 10;
L’affectation du sixième élément du tableau avec pTableau[5] = 10;
va certainement fonctionner parce que rien n’est fait entre la libération de la mémoire et son affectation (je passe sur une interruption qui aurait pu prendre la main et faire de nombreuses choses...). Mais si l’affectation intervient plus tard, alors que d’autres allocations ont été faites, d’autre fonctions ont été appelées, le contenu de cet emplacement mémoire a pu être réutilisé par le système pour y mettre n’importe quoi.. Et écraser une case mémoire employée par d’autres peut passer inaperçu ou planter complètement le programme, et ce de manière aléatoire. Un jour ça marche, le lendemain ça ne marche plus. Sans doute, à par les problème de timing, le plantage le plus compliqué à deboguer.
Pour éviter de tomber sur le problème, le mieux est de remettre le pointeur à NULL
. Le code pTableau[5] = 10;
ne marchera toujours pas, mais ne marchera jamais ! Le plantage sera immédiat, et une fois le problème résolu, ça marchera toujours.
L’allocation mémoire dynamique n’est pas réservée aux tableaux :
Feu *pFeu;
pFeu = new Feu();
pFeu->vert = true;
Notez le new
qui cette fois utilise les parenthèses au lieu des crochets. Ce new
appelle le constructeur de la classe Feu
. Un constructeur ? Sur une Structure ? Eh bien oui, pour le C++ une structure n’est qu’un cas particulier de classe, dont tous les éléments sont publics. Comme pour une classe, un constructeur de base sans argument et sans code est fourni par le compilateur pour chaque structure déclarée. Il est possible de lui donner un comportement de base, ou d’y ajouter un constructeur supplémentaire :
struct Feu
{
bool rouge;
bool jaune;
bool vert;
Feu();
Feu(bool aRouge, bool aJaune, bool aVert);
};
Feu::Feu()
{
rouge = false;
jaune = false;
vert = false;
}
Feu::Feu(bool aRouge, bool aJaune, bool aVert)
{
rouge = aRouge;
jaune = aJaune;
vert = aVert;
}
Comme c’est un constructeur qu’appelle le new
, il est possible d’utiliser celui que l’on veut en spécifiant les bons arguments
Feu *pFeu;
pFeu = new Feu(true, false, false);
La libération utilise le delete
simple :
Feu *pFeu;
pFeu = new Feu(true, false, false);
...
delete pFeu;
pFeu = NULL;
Pour des usages encore plus pointus (qui a dit tordus ?) lisez le second article et approfondissez le sujet !