Ce tutoriel est en cours de rédaction. Merci pour votre compréhension !

Les tableaux

Utiliser des tableaux permet de stocker des informations les unes à la suite des autres en mémoire, sans créer une variable pour chacune d’entre elles, à la seule condition qu’elles soient toutes de même type. On peut donc créer des tableaux de tous les types (float, uint8_t, struct …).

Par exemple, on veut créer une guirlande lumineuse avec Arduino en y connectant 6 LED sur 6 broches différentes. Pour mémoriser les numéros des broches, on va utiliser un tableau avec une case par LED (donc six cases). Chaque case correspond à une position dans notre tableau, appelée indice.

En C/C++, les indices de tableau commencent toujours à 0. Donc un tableau de n éléments aura des indices allant de 0 (la première case) à n-1 (la dernière case).

Ainsi, lorsqu’on demandera la valeur de la case ayant l’indice 2, il s’agira en fait de la troisième case du tableau.

Déclarer et initialiser un tableau

Un tableau étant une liste d’éléments de même type rangés par adresses croissantes en mémoire, il est nécessaire pour le déclarer :

  • De préciser le type de ses éléments ;
  • De préciser sa taille entre crochets, représentant le nombre d’éléments qu’il contiendra.

Par exemple, pour déclarer un tableau nommé led comportant 6 éléments de type uint8_t :

uint8_t led[6] ;

On peut également initialiser les cases du tableau avec des valeurs déterminées.

Par exemple, pour déclarer un tableau de nom led comportant 6 éléments de type uint8_t et l’initialiser avec les valeurs de 4 à 9 :

uint8_t led[6] = {4, 5, 6, 7, 8, 9}; // On a bien six éléments dans la liste

Vous pouvez imaginer que ce tableau a la structure suivante dans la mémoire SRAM du microcontrôleur :


Structure en mémoire d'un tableau


NB : Si aucune valeur n’est explicitement assignée aux éléments d’un tableau au moment de sa déclaration, leur contenu sera fixé en général à la valeur “zéro”, par le compilateur. Nous vous conseillons cependant d’initialisez explicitement vos tableaux si c’est nécessaire au bon fonctionnement de votre algorithme !

Accéder à une case d’un tableau

Pour accéder à une case d’un tableau (lire ou écrire une donnée), il suffit de connaître l’indice de la case à laquelle on veut accéder.

  • Pour lire une valeur :

      uint8_t valeur;
      uint8_t i = 4; // On va accéder à la quatrième case
      valeur = led[i]; // Mémorise dans "valeur" le contenu de la 4ème case (soit la valeur "8")
    
  • Pour écrire une valeur :

      uint8_t valeur = 7; 
      uint8_t i = 4;  // On accéder à la quatrième case
      led[i] = valeur; // Remplace de contenu de "led[4]" par "7"
    

Exemple d’utilisation d’un tableau

Les tableaux sont utiles lorsqu’on a besoin d’appliquer un traitement répétitif au sein d’un sketch.
Par exemple, supposons que l’on souhaite déclarer en mode sortie (mot clef OUTPUT du framework Arduino) les broches 2, 4, 6 et 7 d’une carte à microcontrôleur.

  • On peut réaliser cette opération sans utiliser de tableau, comme ceci :

      uint8_t led0 = 2; // La première LED est connectée sur la broche 2
      uint8_t led1 = 4; // La deuxième LED est connectée sur la broche 4
      uint8_t led2 = 6; // Etc.
      uint8_t led3 = 7; // Etc.
    
      void setup() {
    
          pinMode(led[led0], OUTPUT); // Configuration de la broche de led0 en sortie.
          pinMode(led[led1], OUTPUT); // Configuration de la broche de led1 en sortie.
          pinMode(led[led2], OUTPUT); // Etc.
          pinMode(led[led3], OUTPUT); // Etc.
    
      }
    
      void loop() {
          ... // Code non détaillé ici
      }
    
  • On peut réaliser cette opération en utilisant un tableau, comme ceci :

      uint8_t led[4] = {2, 4, 6, 7}; // Tableau de broches
    
      void setup() {
    
          for(uint8_t i = 0; i < 4; i++) // Pour chacun des 4 élements de led ...
          {
              pinMode(led[i], OUTPUT); // ... configuration de la broche correspondante en sortie.
          }
            
      }
    
      void loop() {
          ... // Code non détaillé ici
      }
    

La deuxième approche est évidemment plus compacte et s’imposera si le nombre de broches à configurer devient important, une boucle for étant nettement plus lisible et facile à écrire sans se tromper qu’une longue série d’appels à la fonction pinMode(...) 1 quasiment (mais pas exactement !) tous identiques.

Tableaux et chaînes de caractères

Une chaîne de caractères est un série de caractères “alignés” les à la suite les uns des autres dans la mémoire, qui représente généralement un texte.
Une chaîne de caractères littérale constante s’écrit entre des ", par exemple "Hello World".

En C, la notion de chaîne de caractères est implémentée sous la forme de tableaux de caractères qui doivent se terminer par la constante caractère nul '\0'. Il ne faudra surtout pas oublier ce '\0' si on construit ou on manipule (redimensionnement, par exemple) la chaîne en accédant aux éléments du tableau qui la contient !

Quelques exemples simples de déclarations et manipulations de chaînes de caractères

  • Déclaration / initialisation d’une chaîne vide :

      char chaine[] = "" ;
    

    Sa structure en mémoire sera :


    Structure en mémoire d'un tableau de caractères


  • Déclaration / initialisation d’une chaîne contenant un saut de ligne :

      char chaine[] = "\n" ;
    

    Sa structure en mémoire sera :


    Structure en mémoire d'un tableau de caractères


  • Déclaration / initialisation de la chaîne "Hello" :

      char chaine[] = "Hello" ;
    

    Sa structure en mémoire sera :


    Structure en mémoire d'un tableau de caractères


  • Déclaration d’un tableau de 6 caractères puis modifications ultérieures de son contenu :

      char chaine[6] ; // 6 caractères réservés en mémoire, le dernier devra être '\0'
        
      ... // Code non détaillé
    
      // Ecriture du contenu de la chaîne à un moment donné de votre algorithme
    
      chaine[0] = 'H' ;
      chaine[1] = 'e' ;
      chaine[2] = 'l' ;
      chaine[3] = 'l' ;
      chaine[4] = 'o' ;
      chaine[5] = '\0' ;
    
      ... // Code non détaillé
    
      // Modification de deux caractères un peu plus loin
    
      chaine[0] = 'a' ;
      chaine[3] = 'p' ;
    
      // La chaîne est désormais "aelpo"
    
      ... // Code non détaillé
    

    Sa structure en mémoire sera :


    Structure en mémoire d'un tableau de caractères


Convertir une variable en sa représentation sous forme de chaîne de caractères

Il ne faut pas confondre le contenu d’une variable, encodé avec un certain nombre d’octets selon son type (voir cette page), avec la représentation affichable de cette variable dans un format spécifié, nécessairement sous la forme d’un tableau de caractères.

Le croquis suivant illustre comment construire une chaîne de caractères incluant un nombre entier (type int) et un nombre à virgule flottante (type float), puis comment l’afficher dans le terminal série de l’IDE Arduino :

01 // Appel à la lib stdio.h pour les fonctions manipulant des chaînes de char.
02 // Inutile en pratique dans le croquis Arduino car déjà appelée par ailleurs
03 // En revance un "vrai" programme en C en aura absolument besoin.
04 #include <stdio.h>
05
06 // Constante Pi approximée
07 #define PI 3.14159
08
09 // Numéro de l'itération
10 uint32_t indx;
11
12 // On réserve un tableau de 5 char
13 char buffer[5];
14
15 void setup() {
16
17  // On initialise le port série du ST-Link à 9600 bauds
18  Serial.begin(9600);
19  
20  // On écrit dans buffer la chaîne représentant Pi avec 2 décimales (%2f.)
21  sprintf(buffer, "%.2f.", PI);
22
23 }
24
25 void loop() {
26  
27  // On incrémente l'itération
28  indx ++;
29  
30  // On affiche sur le terminal série de l'IDE Arduino une chaîne
31  // - Incluant indx, dont la valeur est formatée en entier non signé (%u)
32  // - Incluant le contenu de buffer au format chaîne de caractères (%s)
33  Serial.printf("%u - La valeur de pi est %s\n", indx , buffer);
34
35  // Temporisation de 1000 millisecondes
36  delay(1000);
37
38 }

Les subtilités du formatage avec sprintf() et printf() (lignes 21 et 33) nécessiteraient tout un chapitre d’explications. Pour mieux comprendre cette problématique, nous vous renvoyons à l’une des très nombreuses références en ligne, par exemple cet article.

Important
Le formatage et l’affichage de nombres à virgule flottantes (floats) sont des opérations exigeantes en ressources matérielles. C’est pourquoi, dans les systèmes embarqués à base de microprocesseurs ARM, on utilise par défaut des versions “allégées” des bibliothèques pour sprintf() et printf() qui ne gèrent pas les floats. Pour changer ce comportement avec l’IDE Arduino, il faut activer “Newlib Nano + Float Printf” comme indiqué dans la copie d’écran ci-dessous :


Newlib Nano + Float Printf


Les microcontrôleurs STM32 disposent d’assez de ressources pour gérer cette option sans difficultés. Il n’en demeure pas moins que l’utilisation de floats dans le firmware d’un système embarqué reste une mauvaise pratique que nous déconseillons.

Tableaux de chaînes de caractères

Pour diverses raisons, vous pouvez être amené à créer une liste, ou un tableau de chaînes de caractères. Cet exemple, tiré de la documentation Arduino sur les chaînes de caractères, montre comment procéder :

// Tableau de pointeurs sur chaînes de caractères
char *myStrings[] = {"Chaine 1", "Chaine 2", "Chaine 3", "Chaine 4"};

void setup() {
    Serial.begin(9600);
}

void loop() {
    
     for (int i = 0; i < 3; i++) {
        Serial.println(myStrings[i]);
        delay(500);
     }
}

La classe String de Arduino

Le framework Arduino simplifie grandement la manipulation de chaînes de caractères via la classe2 String (avec un S majuscule !). Cette classe offre de nombreuses méthodes3 pour réaliser facilement des opérations complexes sur des chaînes de caractères, voir par exemple cette référence.

Cette simplification n’est cependant pas “gratuite” car l’usage de String nécessite plus de mémoire et est moins performant que les fonctions “natives” du langage C pour manipuler des tableaux de caractères. A vous de voir ce qui sera le plus judicieux pour votre croquis …

Manipuler des tableaux avec des fonctions

Cette section est un complément à celle qui traite des fonctions.
Manipuler des tableaux en langage C/C++ à l’aide de fonctions nécessite l’utilisation de pointeurs. Un pointeur est une variable qui contient l’adresse (i.e. l’emplacement) d’un autre objet situé dans la mémoire du microcontrôleur.

Comment retourner un tableau avec une fonction ?

Une fonction en C / C++ ne peut retourner directement qu’une valeur d’un type donné ; le retour d’une liste de valeurs (un tableau) est impossible. Mais une fonction peut retourner l’adresse où est rangée la première case d’un tableau dans la mémoire 4 autrement dit un pointeur sur le tableau qui sera, dans le code source, équivalent à une référence directe au tableau.

Par exemple, ce code utilise une fonction qui renvoie un pointeur sur le premier élément d’un tableau de caractères créé en mémoire (on dit “alloué”) à l’aide de l’instruction calloc pendant l’exécution programme, puis effacé à l’aide de l’instruction free lorsqu’on n’en a plus besoin :

// Nombre maximum d'élements du futur tableau
// Constante définie à l'aide de la directive de préprocesseur C #define
#define NB_ELEMENTS 6

void setup() {

  // Initialisation du port série communiquant avec l'IDE Arduino
  Serial.begin(9600);

  // La variable "chaine" contiendra l'adresse d'un emplacement
  // en mémoire dans lequel sera rangée une variable de type char
  // On précise que cette adresse est indéfinie, de type "NULL", au départ
  // (elle ne désigne aucun emplacement en mémoire).
  char *chaine = NULL;

  // Allocation en mémoire d'un tableau contenant "NB_ELEMENTS" de type char.
  // La variable "chaine" contient à présent l'adresse du premier élement d'un
  // tableau de "NB_ELEMENTS" de type char.
  // C'est effectivement un tableau de "NB_ELEMENTS" caractères pour C !
  chaine = AllocationMemoire(NB_ELEMENTS);

  // On affiche le contenu du tableau suivi d'un saut de ligne sur le moniteur 
  // série de l'IDE Arduino
  Serial.println(chaine);

  // TOUJOUR libérer la mémoire après utilisation de calloc() !
  free(chaine);
}

void loop() {
  // Pas de code ici !
}

// Fonction qui retourne un pointeur sur l'adresse d'un char.
// Elle prend comme paramètre un entier non signé codé sur 32 bits, "nombre_elements".
char *AllocationMemoire(uint32_t nombre_elements) {

  // Variable de type pointeur sur caractère
  char *tab = NULL;

  // Réservation de "nombre_elements" cases mémoires consécutives, chacune assez grande
  // pour contenir un char à l'aide de la fonction calloc. Notez la nécessaire
  // opération de casting "(char *)" pour retourner l'adresse du premier char.
  char * tab = (char *)calloc(nombre_elements, sizeof(char));

  // Si calloc a échoué, elle renvoie un pointeur NULL.
  // Dans ce cas, on bloque la poursuite de l'exécution du programme.
  if( tab == NULL ){
    Serial.println("Echec de l'allocation dynamique de mémoire");
    while(1);
  }

  // On renseigne les 6 premiers éléments du tableau, s'il est assez grand !
  if (nombre_elements > 5) { // Si "nombre_elements" est strictement supérieur à 5
    tab[0] = 'H';
    tab[1] = 'e';
    tab[2] = 'l';
    tab[3] = 'l';
    tab[4] = 'o';
    tab[5] = '\0';
  }

  return tab;  // retour de l'adresse du premier char de tab
}

Vous trouverez un exemple plus détaillé de l’utilisation de la fonction calloc sur le site KOOR.fr.

Remarque : Cet exemple met en œuvre l’allocation de mémoire dynamique. Dans le contexte de la programmation embaquée sur microcontrôleur l’allocation dynamique n’est pas une bonne pratique car elle génère de la fragmentation qui ralentit le firmware et gaspille de la mémoire, une ressource très limitée. Qui plus est, la programmation d’allocation dynamique de mémoire, lorsqu’on n’est pas expert, ouvre la porte à des bugs et à des failles de sécurité subtils. Pour toutes ces raisons, nous vous conseillons d’utiliser plutôt des variables globales réservées par le compilateur dès le démarrage du firmware plutôt qu’avoir recours à l’allocation dynamique.

Comment passer un tableau comme paramètre à une fonction ?

On peut également manipuler le contenu d’un tableau à l’aide d’une fonction en lui passant comme paramètre un pointeur sur celui-ci, par exemple, toujours pour un tableau de caractères :

// Tableau de 5 caractères
// Attention, ce n'est pas une chaîne car il manque '\0' à la fin !
#define NBCHAR 5
char myString[NBCHAR] = { 'H', 'e', 'l', 'l', 'o' };

void setup() {

  Serial.begin(9600);

  for (int i = 0; i < NBCHAR-1; i++) {
    Serial.print(myString[i]);
  }
  Serial.println(myString[NBCHAR-1]);

  delay(500);

  for (int i = 0; i < NBCHAR-1; i++) {
    // On appelle la fonction change_content qui modifie le tableau
    change_content(myString, i);
    Serial.print(myString[i]);
  }
  change_content(myString, NBCHAR-1);
  Serial.println(myString[NBCHAR-1]);
}

void loop() {
  // Pas de code ici !
}

// Fonction qui reçoit en argument un tableau de char et qui
// modifie ses éléments
void change_content(char* data, uint8_t indx) {
  // Conversion de l'entier non signé indx en sa représentation 
  // sous forme de caractères
  const uint8_t nbchar = 2;
  char onebyte[nbchar];
  snprintf(onebyte, nbchar, "%d", indx);
  data[indx] = onebyte[0]; // et onebyte[1] = '\0'
}

Vous trouverez un exemple plus abouti de manipulation d’un tableau de caractères passé comme paramètre à une fonction dans notre projet de station météo connectée, dans la section Gérer la date, l’heure et le fuseau horaire, la fonction concernée étant Make_TimeStamp.

Liens et ressources

Notes au fil du texte

  1. En anticipant sur les fonctions en général et sur les fonctions spécifiques du framework Arduino en particulier, telles que pinMode(...), voir cette fiche

  2. La notion de classe en informatique est quelque peu abstraite. Pour notre besoin, vous pouvez imaginer une classe comme une bibliothèque qui simplifie l’écriture des programmes en introduisant des types de variables plus élaborés que ceux évoqués ici

  3. Une méthode est sur le principe très semblable à une fonction qui s’applique dans le contexte d’utilisation d’une classe. 

  4. Une adresse de la mémoire est bien une valeur, plus précisément un uint32_t (donc compris entre 0 et 232 - 1) pour les microcontrôleurs d’architecture 32 bits.