Introduction à la mémoire et aux pointeurs sur Arduino

Suite à la demande de Zarb94 et leSept et vu le temps pluvieux cette après midi, je me suis dit que j'allais essayer de documenter un peu ces histories de pointeurs en C et C++ sur nos petits Arduinos.

Il existe le document de 52 pages rédigé par @osaka que vous pouvez lire mais il semble qu'une "courte" démystification sur la mémoire et les pointeurs pourrait être utile pour les débutants et moins débutants.

Voici donc ma contribution qui se veut plus simpliste sur le sujet.


On ne peut pas parler de pointeurs sans parler de mémoire dans les ordinateurs.

La mémoire vous permet d'organiser l'information, en la rangeant dans des cases successives.

Le nombre de cases dont vous disposez définit la capacité de stockage de votre ordinateur. Comme dans un système de mesures où vous allez avoir des grammes, des kilogrammes, des tonnes etc, les informaticiens ont aussi défini une nomenclature pour mesure l'espace disponible.

En simplifiant la plus petite information représentable est le bit - qui stocke une valeur 0 ou 1.

Les informaticiens aiment les puissances de 2 et comme jouer avec juste des 0 ou des 1 était limité, les informaticiens ont décidé que l'unité courante (ce n'est pas tout à fait exact car il existe des système différents) serait un groupe de 8 bits (8 = 23). En groupant 8 bits ensemble on définit un octet ou byte en anglais. On utilise en français le symbole o minuscule (octet) pour dire 1 octet. (les anglophones utiliseront la notation b pour bit et B pour Byte=octet).

Bien sûr un système avec un seul octet ne servirait pas à grand chose et donc pour représenter plus d'octets d'un coup, ils ont défini le kilo octet comme étant 210 octets, soit 1024 octets. On le notera ko (et donc les anglophones noteront KB ou s’ils utilisent le symbole international kB)

Si vous avez beaucoup de kilo-octets, les informaticiens ont défini le méga octet comme état 210 kilo-octets, soit 1024 x 1024 octets = 1048576 octets.

Cette notation a déchainé les passions... Dans les unités du standard international, on parle en puissance de 10 pour le kilo = 103, le méga = 106, le giga = 109... Une nouvelle norme a donc été créée en 1998 pour noter les multiples de 210: les kibi (kilo binaire), mébi, gibi etc. Je vous laisse lire plus d'information sur wikipedia mais il faut savoir que ce n'est pas utilisé, c'est juste si vous voulez frimer lors de votre prochain dîner mondain :slight_smile:


Bon, OK, c'est bien beau tout cela mais donc j'ai potentiellement des dizaines de milliers de cases mémoires. Comment je m'y retrouve dans toutes ces cases ?

Comme dans une rue où les maisons sont numérotées, pour repérer une case on utilisera une adresse. La première case mémoire a l'adresse 0, la deuxième l'adresse 1, la troisième l'adresse 2 etc... c'est simple non ? et les micro-processeurs ont des instructions spécifique pour dire "va chercher l'octet situé à l'adresse 227" par exemple.

Dans notre monde Arduino et des micro-processeur AVR, sur un Arduino UNO à base de ATmega328P on a 3 types de mémoire:

  • Une mémoire pour les programmes, connue sous le nom de mémoire Flash, qui a 32 Ko
  • Une mémoire pour les données, connue sous le nom de SRAM, qui a 2 Ko
  • Une mémoire pour les données, connue sous le nom de mémoire EEPROM, qui a 1 Ko

La mémoire Flash et l'EEPROM disposent de propretés physiques qui font que même si vous coupez le courant les valeurs des bits (0 ou 1) resteront mémorisées alors que la SRAM sera perdue. Pour que les données soit persistantes dans ces mémoires, il faut appliquer des champs électriques forts pour forcer des électrons à aller à un endroit ou à un autre ce qui fait que ces opérations ne sont pas rapides et au final abiment un peu à chaque fois la case mémoire.

La mémoire Flash pourra supporter environ 10,000 écritures et l'EEPROM environ 100,000 cycles d'écritures. Si vous programmez souvent votre arduino - genre chargez un programme toutes les 5 minutes et ce pendant 8 heures (soit 8x12=96 fois par jour) --> vous aurez usé votre Arduino en 10000 / 96 = 104 jours. si vous faites cela que le week end comme passe-temps, ça donne quand même 2 ans de vie à votre Arduino... (bien sûr dans la vraie vie on ne charge pas un programme toutes les 5 minutes pendant 8h et donc votre arduino durera bien plus longtemps).

La mémoire SRAM elle s'efface quand on coupe le courant, pour mettre un bit à 0 ou 1 on aura à appliquer moins de courant et donc la durée de vie de cette mémoire est super longue, on n'a pas à s'en faire. Comme en plus il n'y a pas de courant fort à créer, c'est une opération rapide et donc c'est pratique de stocker dans cet espace des choses dont on a besoin souvent avec un accès en lecture ou écriture rapide --> c'est pour cela qu'on y met les données de notre programme.


Bon, OK, c'est bien beau tout cela, j'ai des octets qui ont une adresse en mémoire. OK. et j'en fais quoi ?

8 bits peuvent coder 256 possibilités (chacune des 8 cases pouvant prendre la valeur 0 ou 1 on a 28 possibilités).

Comme ce serait limité si on ne pouvait travailler qu'avec 256 possibilités, les langages de programmation définissent des types de valeurs plus complexes, tenant sur plusieurs octets.

Sur nos Arduinos genre UNO ou MEGA, le type int par exemple va être stocké sur 2 octets. on a donc 16 bits qui peuvent prendre la valeur 0 ou 1, soit 216 possibilités, donc 65536 valeurs représentables. Comme l'indique la documentation, les programmeurs ont décidé d'une représentation pour les nombres positifs et négatifs et donc nos 65536 valeurs représentables vont se répartir entre -32768 et 32767.

Vous pouvez aussi avoir le type entier non signé, unsigned int qui est aussi stocké sur les mêmes 2 octets mais comme on ne représente pas les nombres négatifs, on pourra y représenter une valeur entre 0 et 65535 (toujours 65536 valeurs).

En disant au compilateur le type de la variable, on fige donc les valeurs représentables dans le programme.


Bon, OK, c'est bien beau tout cela, j'ai des octets qui ont une adresse en mémoire et que je peux grouper pour représenter des valeurs plus grandes. dois-je savoir autre chose ?

Oui, il y a encore une chose à savoir - connue sous les doux noms des petits et grands indiens :slight_smile:

Quand vous représentez un nombre sur 2 octets, donc 65536 valeurs possibles, qu'on représente en base deux sous la forme 0000 0000 0000 00002 à 1111 1111 1111 11112 il va falloir définir quels sont les 8 bits qui vont dans quel octet. Met-on les 8 bits de droite (ce que l'on appelle les poids faible) dans le premier octet ou le second en mémoire ? Grave question... et les informaticiens n'ont pas réussi à se mettre d'accord (en fonction de ce qui était plus efficace pour certains types de micro-processeurs). On s'est retrouvé avec un camp qui mettait l'octet de poids faible dans l'adresse mémoire de la premiere case et l'octet de poids fort dans la seconde et d'autres qui faisaient l'inverse. Je vous laisse en lire plus sur tout cela sur wikipedia, c'est ce qu'on appelle le boutisme ou endianisme et vous aurez des petits-boutistes et des grands-boutistes (big-endian et little-endian pour les anglophones).

Il faut savoir que sur un Arduino avec notre compilateur, le choix qui est fait est le little-endian, les octets de poids faible sont mis en premier dans les cases mémoire d'adresse la plus faible.

Donc si vous avez l'entier sur 2 octets 1111 0000 0101 01012 à stocker en mémoire à l'adresse 543 par exemple, on sait qu'il faudra 2 octets et donc on aura 0101 01012 rangé en case 543 et 1111 00002 rangé en case 544. ça peut sembler un peu contre nature, car on a l'habitude de lire les chiffres de gauche à droite et là c'est comme si les bits étaient inversés par paquets de 8... c'est comme cela, il faut le savoir - mais la bonne nouvelle c'est que le compilateur gère tout cela pour vous et donc dans la majorité des cas on ne s'en souciera pas.

3 Likes

Bon, OK, c'est bien beau tout cela, j'ai des octets qui ont une adresse en mémoire et que je peux grouper pour représenter des valeurs plus grandes et je joue au petits indiens pour les ranger dans les bonne cases. On peut parler de pointeurs maintenant ?

Presque... on va juste couvrir un dernier point... Quand on joue sur des petits micro-contrôleurs (ou des gros ordinateurs), c'est toujours bien d'essayer de se représenter ce que fait un compilateur. Dans notre cas on va réfléchir à ce qu'il fait quand il rencontre la définition d'une variable dans votre code.

On sait qu'en C ou C++, on définit une variable par son type et on lui donne un petit nom.

type nomDeVariable;

il existe de nombreux Types fondamentaux en C ou C++ que je vous laisse regarder et vous pouvez grace aux tableaux en grouper plusieurs d'un coup ou grace à la commande typedef créer vos propres types en combinant ces types fondamentaux.

Quand vous définissez une variable, le compilateur va regarder quel est son type et calculer combien d'octets il faut réserver pour stocker une valeur dans cette variable. Ce nombre d'octets nécessaire est accessible aussi au programmeur en utilisant l'opérateur sizeof().

Par exemple si vous chargez ce programme sur un UNO ou MEGA

int x;
long y;
int z[3];

void setup() {
  Serial.begin(115200);
  Serial.print(F("Taille de x : ")); Serial.println(sizeof(x));
  Serial.print(F("Taille de y : ")); Serial.println(sizeof(y));
  Serial.print(F("Taille de z : ")); Serial.println(sizeof(z));
}

void loop() {}

et que vous ouvrez la console Série à 115200 bauds, vous verrez le résultat suivant:

Taille de x : 2
Taille de y : 4
Taille de z : 6

Le compilateur a donc 2 cases mémoire (2 octets) à trouver pour ranger x, 4 cases pour ranger y et 6 cases (3x2) pour ranger z.

Les compilateurs ont différentes techniques pour gérer les cases disponibles et là où ils vont choisir de les mettre dépend d'un certain nombre de facteurs qui serait trop long d'explorer ici, mais on peut se souvenir qu'il y a trois types de variables:

  • celles qui ont une durée de vie égale à la durée de vie du programme et qu'on connait à la compilation, ce sont celles qu'on appelle variable globales.
  • celles qui ont une durée de vie plus courte, un bloc de code ou une fonction, qu'on appelle variable locales.
  • celles qui sont créées dynamiquement lors du fonctionnement du programme, par allocation/réservation de blocs mémoires (fonctions malloc() et associées)

Toutes ces variables doivent tenir dans la SRAM de votre arduino.

Le compilateur dans sa stratégie d'allocation mémoire va réserver des cases pour toutes les variables globales utilisées puisqu'il sait qu'on va en avoir besoin. Ces cases seront à un endroit bien déterminé (une adresse connue stable durant toute l'exécution du programme). On appelle cela le segment de données connu en compilation sous le nom de .data.

Une fois tout cet espace réservé, il reste un peu de place, disons pour simplifier une zone mémoire entre 2 adresses D (début) et F (Fin). Le compilateur va prendre une stratégie d'allocation en partant des deux bouts et pour les variables dynamiques il va les mettre les unes à la suite des autres d'un côté (et gérant les trous qui peuvent être crées lorsqu'on libère de la mémoire), alors que pour les variables locales il va partir de l'autre bout. Les variables allouées dynamiquement vont dans ce qu'on appelle le TAS (heap en anglais) et les variables à portée locale iront dans la PILE (stack en anglais). La pile est généralement située dans la plus haute zone de la mémoire (ça dépend des compilateurs).

Si à l'exécution la pile rencontre le tas, c'est la catastrophe et votre programme va planter en faisant un "stack overflow" - débordement de la pile... Donc il faut toujours faire attention à la mémoire qu'on utilise, surtout qu'on en n'a pas beaucoup.

si ce sujet vous intéresse, il y a de la documentation dans wikipedia.

On a la base maintenant pour comprendre ce qu'il se passe en mémoire. On a compris que le compilateur décide où ranger une variable et combien de cases il faut réserver, donc pour la suite on va simplement raisonner en terme de variable rangée quelque part, quel que soit l'endroit choisi (segment données, tas, pile).


Bon, OK, c'est bien beau tout cela, j'ai des octets qui ont une adresse en mémoire et suivant les types le compilateur va les regrouper pour représenter des valeurs plus grandes, ranger ces valeurs en jouant aux petits indiens dans des bonne cases qu'il aura choisi à la compilation soit dans le segment des données, soit dans la pile, soit sur le tas. OK.. On peut parler de pointeurs maintenant ?

OUI :slight_smile:

Prenons l'exemple des variables globales car c'est simple à visualiser. A la compilation, les outils font une première lecture de tout votre code et trouvent toutes les variables globales. Le compilateur va calculer le nombre d'octets (de cases) nécessaires pour représenter leurs données et en phase de génération de code réserver des cases pour ces variables. Comme le compilateur sait où sont rangées les données à chaque fois que vous aurez besoin de lire le contenu de la variable, le compilateur génèrera du code qui demandera au micro-processeur d'aller chercher le contenu des cases à partir de la bonne adresse (case).

Imaginons que l'on définisse trois variables globales

int x;
long y;
byte z[3];

Comme on l'a vu précédemment avec sizeof(), le compilateur sait combien d'octet allouer et va donc se souvenir où sont rangées ces variables. Souvent les compilateurs allouent les variables dans l'ordre d'utilisation effective dans le code et non pas de déclaration (et les variables inutilisées ne sont même pas allouées).

Le "compilateur" va donc faire les choses suivantes:

calcul de l'espace nécessaire pour le segment des données globales:

2.segmentData.png
(par exemple)

et maintient une référence vers le début de la zone dispo pour réserver les variables:

réservation de x
3.x.png

réservation de y
4.y.png

réservation de z
5.z.png

Chaque variable est stockée dans un certain nombre de cases, ici par exemple on aura les 2 octets de x qui commencent à l'adresse 59, les 4 octets de y qui commencent à l'adresse 55, et les 3 octets du tableau z qui commencent à l'adresse 52.

Voilà si vous avez compris cela, vous avez fait le plus gros du chemin pour les pointeurs.

En C ou C++, un pointeur est un type de donnée dont le contenu (la valeur) sera une adresse de départ d'une case mémoire.

Comme c'est un type, on peut définir des variables de type "pointeur" et le compilateur va faire comme pour nos autres variables, il va réserver des cases pour stocker la valeur du pointeur.

Plusieurs questions se posent alors:

Combien de cases faut-il pour un pointeur ?
Bonne question. ça dépend de votre machine. Dans les ordinateurs modernes avec des OS avancés et des giga-octets de RAM, un pointeur était stocké jusqu'à peu sur 32 bits. Avec 32 bits on est capable de représenter l'adresse de 232 cases, soit 4 Giga-octets. Mais comme on a des besoins de plus en plus important et des ordinateurs avec beaucoup plus que 4 Go de RAM, depuis quelques années on est passé à 64 bits - de quoi représenter 18 446 744 073 709 551 616 cases.. (plus de 1000 fois le nombre de grain de blé dans une production mondiale annuelle).

Sur nos petits arduinos type UNO ou MEGA, on n'a pas beaucoup de mémoire et les adresses sont représentées uniquement sur 2 octets.

1 Like

comment déclarer un pointeur alors en programmation ?
Il faut dire au compilateur vers quoi on va pointer pour qu'il puisse savoir combien de cases lire quand on lui dira d'aller chercher quelque chose qui se trouve à cet endroit. Donc l'écriture retenue est sous la forme type*type est n'importe quel type connu à la compilation (type de base ou type que vous avez créé) et l'étoile * dénote que c'est un pointeur vers ce type de données que l'on déclare.

Donc si j'écris:

int* xPtr;

je déclare au compilateur que la variable qui portera le nom xPtr est de type "pointeur sur entier".

si j'écris

long* yPtr;

je déclare au compilateur que la variable qui portera le nom yPtr est de type "pointeur sur long".

Certains programmeurs préfèrent accoler l’étoile au type int* ou long* pour faciliter la lecture et montrer que cette étoile fait partie du type de la variable, mais la syntaxe autorise de la séparer et surtout si on met plusieurs pointeurs sur la même déclaration, il faut une étoile par variable.

int *ptrX, *ptrY; // penser à mettre les * pour signifier que ce sont des pointeurs

comment affecter l'adresse d'une case mémoire donnée à un pointeur ?
Il existe un autre opérateur en C ou C++ qui est l'opérateur "adresse de". Il se note avec un 'et commercial' & et s'applique à une variable dont on veut l'adresse.

Par exemple si j'écris

int* xPtr = &x;

je déclare au compilateur que la variable qui portera le nom xPtr est de type "pointeur sur entier" et que je veux mettre dans la case associée au pointeur l'adresse où est rangée la variable x.

Si on reprend notre dessin précédent, x était rangé en case 59. Donc &x c'est 59.
6.adresseX.png

et donc quand on définit xPtr, on range dans les 2 octets réservés à la variable xPtr la valeur 59.
7.xPtr.png

Si vous voulez briller lors de votre prochain cocktail mondain, il faut savoir que récupérer l’adresse d’un objet (donc l'opérateur &) s'appelle une indirection

comment accéder au contenu pointé ?
Un pointeur ne servirait à rien s'il n'y avait pas de possibilité d'aller à l'adresse mémorisée. Il existe donc un opérateur - et c'est là que le manque d'imagination (ou raccourci intellectuel) des concepteurs du langage a créé la confusion pour des générations de programmeurs - on va utiliser aussi l'étoile * juste devant le pointeur et le compilateur comprendra: "regarde l'adresse qui est dans le pointeur et va chercher à cette adresse le bon nombres d'octets en fonction du type du pointeur et ramène les moi..."

Mise en pratique:

int x = 12; // réserve 2 cases mémoire pour une variable nommée x et met 12 dedans
int* xPtr = &x; // réserver 2 cases mémoire pour une variable nommée xPtr, de type pointeur sur entier et met l'adresse de la première case mémoire de la variable x dedans
int v = *xPtr; // déclare une variable v de type entier et met dedans le contenu pointé par le pointeur xPtr

void setup() {
  Serial.begin(115200);
  Serial.print(F("v = ")); Serial.println(v);
}

void loop() {}

1/ Je dis que x est un entier sur 2 octets, le compilateur réserve 2 cases et met 12 dedans
2/ Je dis que xPtr est un pointeur vers un entier sur 2 octets et je mets dedans l'adresse de la variable x
3/ Je dis que v est un entier dans lequel je veux stocker le contenu pointé par xPtr, c'est à dire la valeur 12

et donc à l'exécution on va voir dans la console v = 12

Toujours pour épater la galerie lors de votre prochain cocktail mondain, accéder au contenu des cases à l'adresse du pointeur s'appelle le Déréférencement (donc l'opérateur *). On dira que l'on "déréférence un pointeur" quand on va chercher le contenu pointé.

cet opérateur de déréférencement peut aussi être utilisé pour dire où on veut écrire en mémoire s'il apparait à gauche d'une affectation.

Par exemple *xPtr = 22; va dire au compilateur "regarde quelle est la case pointée (référencée) par xPtr et va écrire dans cette case la valeur 22. Comme le compilateur sait que xPtr est de type pointeur sur entier sur 2 octets, il sait qu'il faut écrire la valeur 22 sur 2 octets dans les cases destination.

Mise en pratique:

int x;
int* xPtr = &x;

void setup() {
  Serial.begin(115200);
  *xPtr = 22;
  Serial.print(F("x = ")); Serial.println(x);
}

void loop() {}

Donc l’étoile quand elle est au niveau d’un type ça veut dire que déclare un pointeur, quand elle est devant un pointeur ça veut dire qu’on veut lire ou écrire la case pointée et sinon c’est une multiplication :slight_smile:

vous verrez aussi (dans un autre tuto) qu’en C++ on réutilisera le & pour le passage par référence... et ça sert aussi pour le ET binaire... bref, tout est question de syntaxe et à quoi l’opérateur s’applique, faut rester vigilant.

Peut-on faire des calculs sur les pointeurs ?
Bien sûr, la valeur d'un pointeur n'est rien d'autre que l'adresse en mémoire d'un objet typé, c'est un nombre...

Mais il faut savoir que le compilateur est intelligent et lorsque l'on ajoute 1 à un pointeur, il se dit qu'on veut l'objet suivant et donc ne va pas juste à la case mémoire suivante mais saute le nombre de cases nécessaires pour représenter le type d'objet pointé.

Mise en pratique:

int x = 22;
int* xPtr = &x;

void setup() {
  Serial.begin(115200);
  Serial.print(F("Adresse de x = ")); Serial.println((uint16_t) xPtr);
  Serial.print(F("Adresse Suivante = ")); Serial.println((uint16_t) (xPtr+1)); // l'objet suivant
}

void loop() {}

Notez que je demande de convertir l'adresse à l'impression en uint16_t, entier non signé sur 16 bits car on sait que les adresses sont sur 2 octets et c'est pour que la fonction print l'affiche correctement.

Sur une carte Arduino MEGA, quand j'exécute ce programme je vois

Adresse de x = 512
Adresse Suivante = 514

On voit bien que ajouter 1 au pointeur a en fait fait sauter 2 cases. En effet les cases 512 et 513 contiennent la valeur de ma variable x et donc la case de type entier suivant commencerait en 514.

ATTENTION: Ce que je viens de faire n'est pas bien. Il est très important de s'assurer que les pointeurs que l'on manipule contiennent bien l'adresse d'un objet valide, et pas n'importe quoi. En effet, accéder à un pointeur non initialisé ou allant n'important où revient à lire ou, plus grave à écrire, dans la mémoire à un endroit complètement aléatoire.

Dans l'exemple ci dessus, je ne sais pas ce qui peut se trouver aux adresses 514 et 515, et jouer avec ces cases mémoire n'est pas défini, le compilateur peut vous générer du code qui ne fonctionnera pas du tout.

DONC: En général, on initialise les pointeurs dès leur création, ou s'ils doivent être utilisés ultérieurement, on pense à les initialiser avec le pointeur nul connu sous le nom de NULL ou plus récemment la norme a introduit nullptr et on évite de déborder de la mémoire qui nous est allouée.

Et les tableaux alors ?
En programmation C ou C++ on peut regrouper des variables d'un même type les unes à la suite des autres dans un tableau. On donne le type des données et le nombre que l'on en veut pour que le compilateur sache combien de cases réserver.

par exemple long t[3]; va dire au compilateur je veux de l'espace pour stocker 3 entiers en format long. un long étant codé sur 4 octets, le compilateur va alors réserver 3 x 4 = 12 octets pour ce tableau t. On accède aux éléments du tableau par la notation avec crochets:

1ère variable : t[0]
2ème variable : t[1]
3ème variable : t[2]

Comment fait-on pour trouver l'adresse de départ des cases du tableau ? On sait que le premier élément du tableau est t[0], donc on peut faire comme on a vu auparavant avec l'opérateur d'indirection & et écrire &(t[0]) qui serait donc de type "pointeur sur un long". c'est tout à fait correct.

Mais c'était tellement fréquent que le compilateur - par convenance - transforme le nom du tableau en pointeur sur son premier élément et donc t tout seul est aussi de type "pointeur sur un long" et vaut l'adresse de la première case du tableau.

si vous faites tourner ce code

long t[3];

void setup() {
  Serial.begin(115200);
  Serial.print(F("adresse premier element de t = ")); Serial.println((uint16_t) &(t[0]));
  Serial.print(F("adresse du tableau = ")); Serial.println((uint16_t) t);
}

void loop() {}

vous verrez dans la console la même valeur.

1 Like

Et donc je peux parcourir un tableau avec un pointeur alors ?
Bravo - bonne question. et oui :slight_smile:
Puisque l'on sait que les éléments du tableaux sont rangés les uns derrière les autres dans les cases, et qu'en ajoutant un nombre au pointeur on saute du bon nombre de case en fonction du type, on peut faire des choses marrantes

Mise en pratique:

long t[3] = {100, 200, 300};

void setup() {
  Serial.begin(115200);
  Serial.println(F("Avec un index"));
  for (int i = 0; i < 3; i++) Serial.println(t[i]);

  Serial.println(F("\nAvec le nom du tableau"));
  for (int i = 0; i < 3; i++) Serial.println(*(t+i)); // * dit d'aller chercher le contenu, et t+i ça dit au compilateur de sauter i cases de la bonne taille (ici un long donc 4 octets)

  Serial.println(F("\nAvec un pointeur"));
  long* tPtr = t;
  for (int i = 0; i < 3; i++, tPtr++) Serial.println(*tPtr); // * dit d'aller chercher le contenu de tPtr qu'on incrémente dans la boucle for
}

void loop() {}

Sur une carte Arduino MEGA, quand j'exécute ce programme je vois

Avec un index
100
200
300

Avec le nom du tableau
100
200
300

Avec un pointeur
100
200
300

Applications utile:

voici un exemple de passage d'un tableau à une fonction pour pouvoir le modifier:

int tableau[] = {1, 2, 3, 4, 5};
const size_t taille = sizeof(tableau) / sizeof(tableau[0]); // size_t est un type pour repréventer une taille cf https://fr.cppreference.com/w/cpp/types/size_t

void plus10(int* t, int tailleDuTableau)
{
  for (int i = 0; i < tailleDuTableau; i++) // i varie entre 0 et le nombre d"élément  -1 du tableau
    *(t + i) = *(t + i) + 10; // (t+i) pointe vers la  i-ème valeur du tableau, donc *(t + i) est cette valeur à laquelle on ajoute 10. 
    // ensuite en partie gauche de l'égalité on dit qu'on veut ranger au même endroit la valeur calculée
    // c'est comme si on avait écrit t[i] = t[i]+10;
    // note, le compilateur serait OK si on l'écrivait comme cela, et ce serait plus simple :)
}

void imprimeTableau(int* t, int tailleDuTableau)
{
  for (int i = 0; i < tailleDuTableau; i++) Serial.println(*(t + i));
  Serial.println("----");
}

void setup() {
  Serial.begin(115200);
  imprimeTableau(tableau, taille);

  plus10(tableau, taille);
  imprimeTableau(tableau, taille );

  plus10(tableau, taille);
  imprimeTableau(tableau, taille);
}

void loop() {}

Quand on execute, la console dira

1
2
3
4
5
----
11
12
13
14
15
----
21
22
23
24
25
----

Voilà je vais arrêter ici, si vous vous souvenez que
& vous donne l'adresse de la case de départ de votre variable, que
* vous permet d'aller trouver ce qui est référencé par le pointeur (en lecture dans une expression ou en écriture si à gauche d'une assignation) et que vous comprenez bien que le type du pointeur va définir ce qu'il se passe (le nombre de cases à sauter) quand on fait des maths avec les pointeurs alors vous avez tout ce qu'il faut pour briller à ce prochain cocktail mondain :slight_smile:

amusez vous bien

1 Like

C'est moins que 52 pages.... mais c'est pas court non plus :grin:

Super en tout cas....

(faut que je m'y remette une fois prochaine)

oui... on peut sauter toute la première partie sur la gestion de la mémoire et commencer à la partie

Bon, OK, c'est bien beau tout cela, j'ai des octets qui ont une adresse en mémoire et suivant les types le compilateur va les regrouper pour représenter des valeurs plus grandes, ranger ces valeurs en jouant aux petits indiens dans des bonne cases qu'il aura choisi à la compilation soit dans le segment des données, soit dans la pile, soit sur le tas. OK.. On peut parler de pointeurs maintenant ?

OUI :slight_smile:

mais si on ne comprend pas bien la mémoire, on ne comprendra jamais bien les pointeurs

Merci J-M-L pour ce tuto. C'est un sujet passionnant. Est-il possible de parler de l'utilisation des pointeurs de char, et de tableau de pointeurs: comment les alimenter, les manipuler?

Vous avez un exemple en tête de ce que vous n'arrivez pas à faire ?

Bonsoir J-M-L,

J'ai un 'char buffer[30]' qui me sert pour alimenter un tableau 'char* pTab[3][2] = {NULL}'.

1ere difficulté:
A chaque valeur que je donne à buffer, je dois avoir une adresse différente.

2eme difficulté:
Je dois changer l'adresse pointé par la cellule de 'pTab' par celle de buffer.

Merci pour votre patience.

Dans l’absolu ça va se faire avec un malloc et et strcpy - je vous montrerai comment dès que j’ai un peu de temps mais en pratique sur un petit micro il vaut mieux réserver de manière statique la place nécessaire pour le cas « maximum » se manière a ne pas avoir de soucis à gérer et laisser le compilateur vous dire si tout rentre.

Ma recommandation serait donc de réserver des char pTab[3][2][30];, vos pointeurs sur les cStrings seraient les pTab[p][q]

Zarb94:
Bonsoir J-M-L,

J'ai un 'char buffer[30]' qui me sert pour alimenter un tableau 'char* pTab[3][2] = {NULL}'.

1ere difficulté:
A chaque valeur que je donne à buffer, je dois avoir une adresse différente.

2eme difficulté:
Je dois changer l'adresse pointé par la cellule de 'pTab' par celle de buffer.

Merci pour votre patience.

donc comme promis dans le post ci dessus - même si ce n'est pas l'approche recommandée car allouer dynamiquement et libérer de la mémoire peut conduire à un morcellement qui crée des soucis, voici un petit code qui utilise le code de mon tuto sur l'écoute du port Série pour capturer l'entrée de l'utilisateur sur la console et mémorise dans un tableau par le biais de pointeurs et allocation dynamique ce qui a été tapé dans le moniteur série (réglé à 115200 bauds et envoyant un NL ('\n') en fin de ligne - les '\r' seront ignorés)

j'ai pris pour parti de boucler une fois le tableau plein, donc on recommence à remplir depuis le début

const byte tailleMessageMax = 20;
char message[tailleMessageMax + 1]; // +1 car on doit avoir un caractère de fin de chaîne en C, le '\0'

const byte maxMemoire = 5;
char* tableauDePointeur[maxMemoire];
byte memoireEnCours;

char* const PROGMEM  demandeInfo = "\nEntrer une phrase : ";
char* const PROGMEM  ligneSeparation = "------------";

const char marqueurDeFin = '\n';

boolean ecouter()
{
  static byte indexMessage = 0; // static pour se souvenir de cette variable entre 2 appels consécutifs. initialisée qu'une seule fois.
  boolean messageEnCours = true;

  while (Serial.available() && messageEnCours) {
    int c = Serial.read();
    if (c != -1) {
      switch (c) {
        case '\r': // on l'ignore
          break;
        case marqueurDeFin:
          message[indexMessage] = '\0'; // on termine la c-string
          indexMessage = 0; // on se remet au début pour la prochaine fois
          messageEnCours = false;
          break;
        default:
          if (indexMessage <= tailleMessageMax - 1) message[indexMessage++] = (char) c; // on stocke le caractère et on passe à la case suivante
          break;
      }
    }
  }
  return messageEnCours;
}

//----
void imprimerTableau()
{
  Serial.println(ligneSeparation);
  for (int i = 0; i < maxMemoire; i++) {
    if (tableauDePointeur[i] != NULL) {
      Serial.print(i);
      Serial.print(F("\t"));
      Serial.println(tableauDePointeur[i]);
    }
  }
  Serial.println(ligneSeparation);
}

void setup() {
  Serial.begin(115200);
  memoireEnCours  = 0;
  for (int i = 0; i < maxMemoire; i++) tableauDePointeur[i] = NULL; // on s'assure que le tableau est initialisé proprememnt
  Serial.print(demandeInfo);
}

void loop() {
  if (! ecouter()) {
    Serial.println(message); // on affiche la phrase à mémoriser
    if (tableauDePointeur[memoireEnCours] != NULL) free(tableauDePointeur[memoireEnCours]);    // si la case était déjà utilisée, on libère l'ancienne mémoire
    char * ptr = malloc(strlen(message) + 1);    // on alloue l'espace nécessaire pour conserver la chaîne lue avec un '\0' à la fin
    if (ptr) strcpy(ptr, message);    // si l'allocation de mémoire a fonctionné on copie le message
    tableauDePointeur[memoireEnCours] = ptr;    // et on se souvient du pointeur dans le tableau
    memoireEnCours++;    // on passe à la case suivante
    if (memoireEnCours >= maxMemoire) memoireEnCours = 0; // on écrasera les premiers enregistrements
    imprimerTableau();
    Serial.print(demandeInfo);
  }
}

ça devrait être fonctionnel et c'est documenté dans le code, dites moi si vous ne comprenez pas.

Bonjour J-M-L,
Merci pour votre exemple. C'est exactement ce que je cherche à faire. Ma technique est identique à la votre sauf que je ne libère pas la mémoire. Après ajout de cette instruction, même problème, ça plante au démarrage avant même d'arrivé au tableau. Je pense qu'il y à un débordement mémoire dés le départ.
Est-il possible que le débordement survienne avant même d'arriver à la ligne critique?

J'ai remarqué qu'en masquant la ligne qui correspond à 'tableauDePointeur[memoireEnCours] = ptr;' dans mon croquis, ça ne plante pas.

Je pense que je vais suivre votre recommandation en #9 et utiliser des tableau en 3d.

Sans voir votre code c'est difficile à dire...