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

Les variables

Qu’est-ce qu’une variable ?

Une variable est un conteneur utilisé pour stoker une donnée utilisée par votre programme. Elle peut être vue comme une boîte contenant une valeur, correspondant à un espace dans la mémoire SRAM ou dans la mémoire flash du microcontrôleur. En langage C/C++, une variable possède un type, qui précise la nature des informations qu’elle représente. Par exemple la ligne…

uint8_t age = 16;

… précise que dans la variable nommée age sera enregistrée une valeur de type uint8_t (un entier non signé occupant un octet en mémoire). On assigne à cette variable la valeur numérique 16 (qui est effectivement un entier non signé codé sur 8 bits) grâce à l’opérateur =. La ligne se termine par un ;.

Les types de variables

Une variable est en fait un alias associé par le compilateur à un emplacement (plus précisément une adresse) dans la mémoire du microcontrôleur. Un type est associé à chaque variable, il précise comment la donnée enregistrée à l’adresse de la variable sera représentée en mémoire.

Il est conseillé d’utiliser le type le mieux adapté à la nature des informations que devra contenir votre variable.
Pour reprendre l’exemple qui précède, on sait qu’une température extérieure ne sera vraisemblablement jamais inférieure à - 100°C (froid intense hypothétique au pôle sud) ou supérieure à 100°C (chaleur intense hypothétique dans la Vallée de la Mort au Texas). Cette plage [-100 ; +100] peut être mémorisée de façon “optimale” dans un entier signé codé sur 8 bits, int8_t, qui peut contenir des nombres compris entre -128 et +127.
Nous avons choisi ici le type qui occupe juste assez d’espace dans la mémoire du microcontrôleur pour enregistrer correctement les valeurs qui nous intéressent. Une variable d’un type utilisant moins d’espace mémoire ne pourrait pas enregistrer correctement les valeurs de température les plus hautes ou les plus basses, et pourrait “planter” le programme 1. A l’opposé, une variable d’un type utilisant plus d’espace mémoire ralentirait le programme et réserverait inutilement de précieuses cellules de SRAM ou de mémoire flash.

Le tableau qui suit donne les caractéristiques des types de variables que l’on utilise le plus souvent dans les sketchs Arduino :

Type Signification Nombre d’octets utilisés (sizeof) Plage de valeurs mémorisables
uint8_t / unsigned char Entier non signé codé sur 8 bits 1 0 à 255 (soit 0 à 28-1)
int8_t / char Entier codé sur 8 bits 1 -128 à 127
uint16_ Entier non signé codé sur 16 bits 2 0 à 65535 (soit 0 à 216-1)
int16_t Entier codé sur 16 bits 2 -32 768 à 32 767
uint32_t Entier non signé codé sur 32 bits 4 0 à 4 294 967 295 (soit 0 à 232-1)
int32_t Entier codé sur 32 bits 4 -2 147 483 648 à 2 147 483 647
uint64_t Entier non signé codé sur 64 bits 8 0 à 18 446 744 073 709 551 615 (soit 0 à 264-1)
int64_t Entier codé sur 64 bits 8 -9 223 372 036 854 775 808 à 9 223 372 036 854 775 807
float Réel approximé par une représentation en virgule flottante codée sur 32 bits 4 -3.4 x 10-38 à 3.4 x 1038
void 2 type “vide” 4 (sur une architecture 32 bits) NA
struct 3 type “structure” Selon le type des membres Selon le type des membres

Dans un souci d’optimisation des performances pour des codes s’exécutant sur des microcontrôleurs, il est fortement déconseillé d’utiliser :

  • Des types entiers “plus larges” que l’architecture du microcontrôleur choisi. Par exemple, il faut utiliser autant que possible des entiers codés sur 32 bits ou moins sur les MCU STM32 (architecture 32 bits) 4.
  • Le type float, sauf sur des MCU intégrant une unité pour accélérer les opérations dans lesquelles ils sont impliqués (une FPU) 5.


NB : Ces types peuvent également être nommés autrement (par exemple unsigned short int est équivalent à uint8_t) et sont loin d’être les seuls utilisés en C/C++, sans citer certains spécifiques au framework Arduino (tels que boolean). Nous sommes donc loin d’avoir épuisé le sujet …

Comment nommer les variables ?

Lorsqu’un programme devient complexe, il comporte un très grand nombre de variables, c’est pourquoi il est important de donner à celles-ci des noms explicites pour la lisibilité. Par exemple :

int8_t temperature_exterieure = -2;

Le nom d’une variable ne peut pas être écrit n’importe comment, il faut respecter des règles strictes :

  • Seuls les chiffres, les lettres alphabétiques minuscules ou majuscules et le caractère _ sont autorisés.
  • Le nom doit commencer par une lettre.
  • Le nom ne peut pas comporter plus que 31 caractères.
  • Le nom ne peut pas être l’un des mots réservés du langage C/C++ (comme « for » ou « if » par exemple).

Tout compilateur C/C++ décent signalera une erreur si vous ne respectez pas ces contraintes. On remarquera en particulier que les caractères accentués sont interdits dans les noms de variables.

Attention, le compilateur C/C++ fait la différence entre les majuscules et les minuscules, on dit qu’il est sensible à la casse (en anglais “case sensitive”). Ainsi les variables Temperature et temperature seront considérées comme différentes.

Déclaration, initialisation et utilisation des variables

Les variables doivent toujours être déclarées avant d’être utilisées.

Une variable déclarée sans qu’aucune valeur ne lui soit affectée est initialisée avec la valeur zéro. La valeur d’une variable peut être modifiée au cours de l’exécution d’un programme, par exemple pour réaliser un calcul et en mémoriser le résultat :

int8_t temperature_1 = 25;
int8_t temperature_2 = 22;
int8_t moyenne_temperature; 

moyenne_temperature = (temperature_1 + temperature_2)/2;

Nous avons pris soin d’être cohérents sur le typage des variables.
Nous calculons la moyenne de deux entiers signés codés sur 8 bits et nous mémorisons le résultat dans un entier signé codé sur 8 bits. Il revient au programmeur de s’assurer que cette cohérence de types est respectée et/ou d’anticiper les limites de son algorithme, ce qui n’est pas toujours intuitif. Par exemple, lorsqu’on effectue une addition ou une multiplication, le résultat peut éventuellement nécessiter un type “plus grand” que celui de ses opérandes (on parle alors de “débordement”) …

Le cast

Au fil d’un programme, il peut arriver que vous réalisiez un calcul utilisant des variables de différents types. Considérons par exemple le sketch Arduino suivant :

01 void setup() {
02
03   Serial.begin(9600);
04
05   int value = 125;
06   const int max = 150;
07   const int min = 100;
08
09   float rescaled = (value - min) / (max - min);
10
11   Serial.print("rescaled = ");
12   Serial.println(rescaled);
13 }
14
15 void loop() {
16 }

Naïvement, on s’attend à ce que rescaled = (125 - 100) / (150 - 100) = 0.5.
Mais, si on compile et exécute ce sketch, la console du moniteur série de l’IDE Arduino affiche :

rescaled = 0.0

Ce résultat surprenant est la conséquence des conversions de type implicites effectuées par le compilateur C/C++ et de la priorité des opérations dans le calcul de la ligne 9. Le microprocesseur enchaîne les opérations dans cet ordre :

  1. Il calcule value - min qui sont toutes deux des variables déclarées de type entiers signés codés sur 32 bits (int) et trouve donc 25, un résultat intermédiaire qu’il mémorise également au format int.
  2. Il calcule max - min et obtient de la même façon 50, au format int.
  3. Il calcule la division de ces deux int, soit 25/50 et il trouve tout naturellement zéro puisqu’il d’agit d’une division entière.
  4. Il mémorise le nombre entier 0 dans la variable rescaled de type float et convertit implicitement 0 en 0.0.

Cette dernière étape est ce que l’on appelle une opération de “cast” ou “transtypage” implicite : le compilateur a décidé tout seul de transformer notre résultat int en float car cela paraît cohérent dans le contexte.

Pour corriger notre code nous allons aussi devoir réaliser des opérations de cast, mais cette fois-ci explicites :

01 void setup() {
02
03   Serial.begin(9600);
04
05   int value = 125;
06   const int max = 150;
07   const int min = 100;
08
09   float rescaled = (float)(value - min) / (float)(max - min);
10
11   Serial.print("rescaled = ");
12   Serial.println(rescaled);
13 }
14
15 void loop() {
16 }

Nous avons rajouté à la ligne 9 deux opérateurs de cast 6, identiques, l’un au numérateur l’autre au dénominateur : (float). Un opérateur de cast porte le nom du type de destination mis entre parenthèses. Voici comment est modifié le calcul de la ligne 9 :

  1. Evaluation de value - min qui donne 25au format int. L’application de l’opérateur (float) convertit ensuite 25au format float : 25.0.
  2. Evaluation de max - min qui donne 50au format int. L’application de l’opérateur (float) convertit ensuite 50au format float : 50.0.
  3. Evaluation de 25.0/50.0 qui est à présent une division entre deux floats, dont le résultat est tout naturellement le float 0.5.
  4. Mémorisation du float 0.5 dans la variable rescaled de type float (qui ne change rien).

Si on compile et exécute ce sketch, la console du moniteur série de l’IDE Arduino affiche le résultat attendu :

rescaled = 0.5

Les opérations de cast “explicites” s’imposent donc pour forcer le type du résultat d’un calcul dans des situations ambigües où le cast “implicite” réalisé par le compilateur est pris en défaut.

Il existe des opérateurs de cast pour tous les types : (int), (uint8_t), (unsigned char), (double), (float), etc. Pour plus d’informations à ce sujet vous pouvez consulter cet article.

Portée des variables

Une variable peut être :

  • Visible et accessible / modifiable partout dans votre programme, on dit alors qu’elle est GLOBALE.
    Une variable globale doit être déclarée au début de votre sketch, à l’extérieur de toute structure, comme ceci :

      // Déclaration et initialisation de la variable globale
      uint8_t compteur_global = 0;
    
      void setup() {
    
        // Initialisation du port série "Serial" à 9600 bauds
        Serial.begin(9600);
    
        // On incrémente le compteur
        compteur_global++;
      }
    
      void loop() {
        
        // On incrémente le compteur
        compteur_global++;
    
        // On affiche la valeur du compteur sur le terminal série
        Serial.print("Valeur du compteur : ");
        Serial.println(compteur_global);
    
        // On attends un dixième de secondes (100 millisecondes)
        delay(100);
      }
    

    Observez les valeurs affichées sur le terminal série de l’IDE Arduino pendant une minute. Que se passe-t-il ? Pourquoi ?

  • Visible et accessible / modifiable seulement dans l’une des structures de votre programme, on dit alors qu’elle est LOCALE.
    Une variable locale doit être déclarée dans la structure (délimitée par des accolades {} ) où elle sera utilisée, comme ceci :

      void setup() {
    
        // Déclaration et initialisation de la variable locale 1
        uint8_t compteur_local_1 = 0;
    
        // Initialisation du port série "Serial" à 9600 bauds
        Serial.begin(9600);
    
        // On change la valeur du compteur
        compteur_local_1 = 1;
    
        // On affiche la valeur du compteur
        Serial.print("Valeur du compteur 1 : ");
        Serial.println(compteur_local_1);
        
      }
    
      void loop() {
        
        // Déclaration et initialisation de la variable locale 2
        uint8_t compteur_local_2;
    
        // On incrémente le compteur
        compteur_local_2;
    
        // On affiche la valeur du compteur sur le terminal série
        Serial.print("Valeur du compteur 2 : ");
        Serial.println(compteur_local_2);
    
        // On attends une seconde (1000 millisecondes)
        delay(1000);
    
      }
    

    Modifiez le sketch pour changer la valeur de compteur_local_1 dans loop() et lancez une compilation depuis l’IDE Arduino. Que se passe-t-il ? Pourquoi ?

On pourrait être tenté de ne travailler qu’avec des variables globales afin d’éviter les passages par paramètres aux fonctions, mais cette pratique serait nuisible à l’optimisation de votre firmware et potentiellement génératrice d’erreurs difficiles à identifier (si vous pensez par exemple que le contenu d’une variable gloable est “0” alors que ce n’est pas le cas du fait d’une assignation ailleurs dans le code). Il faut donc ne déclarer comme globales que les variables partagées par plusieurs fonctions et tenter d’en minimiser le nombre.

Les mots clefs const, static et volatile

Vous rencontrerez souvent l’un (ou plusieurs) des trois mots clefs const, static ou volatile dans les déclarations de variables. Voici leur signification :

  • Le mot-clé (attribut) const indique que la variable est en lecture seule et sera désormais une constante.Une constante devra donc être initialisée lors de sa déclaration, il ne sera pas possible de lui assigner une valeur plus tard 6.
    Exemple d’utilisation pour la déclaration d’une approximation de la constante mathématique π (pi) : const float Pi = 3.14159265359;. Une section de ce tutoriel est réservé aux constantes, ici.

  • Le mot-clé static spécifie en fait une classe d’allocation c’est à dire l’endroit où est stockée la donnée et sa durée de vie.
    Il peut avoir deux significations différentes 7 selon le contexte :
    • Pour une variable locale, il permet de définir une variable maintenue pendant toute la durée d’exécution du programme (comme les variables globales). Puisqu’elle a été déclarée à l’intérieur d’une fonction, sa visibilité sera limitée au corps de cette fonction. Un exemple d’utilisation de variable locale statique est disponible ici (variable time_step_cnt dans le sketch “square_generator.ino”).
    • Pour une variable globale, le modificateur static permet de réduire la visibilité de la variable au fichier où elle est déclarée.
  • Le mot clé (attribut) volatile signale au compilateur que la variable est susceptible d’être modifiée par le programme, mais aussi par des facteurs “extérieurs” qu’il ne contrôle pas.
    La déclaration d’un objet/d’une variable en tant que volatile avertit le compilateur de ne pas faire d’hypothèses sur la valeur de l’objet/variable pendant l’évaluation des expressions dans lesquelles il/elle apparaît, car la valeur peut changer à tout moment.
    Ce mot clé est extrêmement important en programmation embarquée où il doit être utilisé pour toutes les déclarations de variables globales modifiées par des routines de service d’interruptions 8. Vous trouverez un exemple ici avec la variable invert_LED.

NB : D’autres mots clefs existent, en particulier pour définir d’autres classes d’allocation que static (précisément auto, extern, register). Nous n’en parlerons pas ici.

Regroupement de variables dans des struct

Imaginons que votre sketch corresponde au firmware d’un module météo d’intérieur qui mesure la température et la pression atmosphérique par deux capteurs différents. Il serait bien sûr tout à fait correct de déclarer deux variables globales 9 puis de les “modifier” de façon indépendante comme ceci 10 :

/* Déclaration des variables globales pour les mesures */
float _temperature; // Variable globale qui contiendra la température, exprimée en degrés celsius
uint16_t _pression_atm; // Variable globale qui contiendra la pression atmosphérique, exprimée en hPa

/* Initialisations */
void setup() {
        
    Serial.begin(9600); // Initialisation du port série "Serial" à 9600 bauds

    uint8_t ret; // Valeur de retour des fonctions d'initialisation des capteurs

    ret = init_capteur_temperature(); // Appel de la fonction d'initialisation du capteur de température
    if(ret == 0) { // Si la fonction d'initialisation du capteur de température a échoué ...
        Serial.println("Erreur initialisation du capteur de température"); // Signale le via le port série de l'IDE Arduino
        while(1); // Bloque l'exécution du programme à ce point
    }

    // Lecture d'une valeur initiale de température, enregistrement de celle-ci dans _temperature
    _temperature = lecture_capteur_temperature(); // Appel de la fonction de lecture du capteur

    ret = init_capteur_pression(); // Appel de la fonction d'initialisation du capteur de pression atmosphérique
    if(ret == 0) { 
        Serial.println("Erreur initialisation du capteur de pression atmosphérique"); 
        while(1);
    }
    
    // Lecture d'une valeur initiale de pression atmosphérique, enregistrement de celle-ci dans _pression_atm
    _pression_atm = lecture_capteur_pression(); // Appel de la fonction de lecture du capteur

}

/* Boucle infinie */
void loop() {
  ... // Code non détaillé ici
}

/* Code source des fonctions d'initialisation et de lecture des capteurs */
 ... // Code non détaillé ici

Une alternative possible consiste à regrouper les mesures des capteurs dans une structure, ce qui donne :

// Déclaration d'une structure pour les données mesurées
struct mesure {
  float temperature;
  uint16_t pression_atm;
};

// Déclaration de la variable globale mes de type mesure
struct mesure mes; 

/* Initialisations */
void setup() {
        
    Serial.begin(9600); 

    uint8_t ret;

    ret = init_capteur_temperature(); 
    if(ret == 0) {
        Serial.println("Erreur initialisation du capteur de température"); 
        while(1); 
    }

    // Lecture d'une valeur initiale de température, enregistrement de celle-ci dans le membre temperature de mes
    mes.temperature = lecture_capteur_temperature(); 

    ret = init_capteur_pression();
    if(ret == 0) { 
        Serial.println("Erreur initialisation du capteur de pression atmosphérique"); 
        while(1);
    }
    
    // Lecture d'une valeur initiale de pression atmosphérique, enregistrement de celle-ci dans le membre pression_atm de mes
    mes.pression_atm = lecture_capteur_pression(); 

}

/* Boucle infinie */
void loop() {
  ... // Code non détaillé ici
}

/* Code source des fonctions d'initialisation et de lecture des capteurs */
 ... // Code non détaillé ici

Procéder de la sorte présente l’avantage de contraindre le programmeur à organiser (structurer !) les données par thématique (mesures de capteurs, ajustements de calibrage, paramètres de connexion, etc.) ce qui donnera un sketch mieux écrit et finalement un firmware plus performant.
Cet exemple montre que struct est considéré comme un type à part entière, défini par le programmeur.
Il y aurait bien plus à dire concernant les struct, voir par exemple ici, ou encore ici.

L’opérateur sizeof

L’opérateur sizeof permet de calculer la taille, en nombre d’octets, occupée par un type ou par une variable. Cet opérateur est surtout utile pour initialiser des tableaux sans se tromper. Pour reprendre l’exemple donné par la documentation Arduino :

char myStr[] = "Ceci est un test";

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

void loop() {
  for (uint8_t i = 0; i < sizeof(myStr) - 1; i++) {
    Serial.print(i, DEC);
    Serial.print(" = ");
    Serial.write(myStr[i]);
    Serial.println();
  }
  delay(5000);
}

Si vous changez la liste de caractères dans myStr, le programme continuera de fonctionner sans autre modification.

Attention
Gardez à l’esprit que sizeof retourne l’occupation mémoire en octets. Ainsi pour des variables de type occupant plus d’un octet en mémoire, le code source qui précède doit être adapté comme ceci :

... // Code non détaillé ici

int myValues[] = {123, 456, 789};

// Pour pacourir le tableau élément par élément
for (uint8_t i = 0; i < (sizeof(myValues) / sizeof(myValues[0])); i++) {
  ... // Code non détaillé ici
}

Liens et ressources

Notes au fil du texte

  1. Une erreur de “typage” de variable est à l’origine du crash du vol 501 de la fusée Ariane 5 … 

  2. Le type void est utilisé avec les fonctions pour indiquer qu’elles ne retournent aucune valeur. Il est également utilisé par les pointeurs (nous n’en parlerons pas plus ici) pour indiquer une absence d’information sur le type d’une donnée. 

  3. Le type struct est défini par le programmeur. Vous pouvez le voir comme une “enveloppe” contenant plusieurs variables de types éventuellement différents. Nous en parlons plus loins dans cette section. 

  4. En pratique, la vitesse de calcul sera maximale avec des types entiers non signés qui ont exactement la “taille” de l’architecture du MCU, mais cela génèrera un gaspillage de mémoire dans la plupart des situations et le programmeur devra faire des compromis … 

  5. En programmation embarquée, on peut généralement trouver une alternative plus économe en ressources au type float, qui est plutôt réservé aux calculs scientifiques sur de “gros” ordinateurs. 

  6. En pratique une seule opération de cast (au numérateur ou au dénominateur) au rait été suffisante pour que le compilateur réalise correctement l’autre de façon implicite. Mais notre approche donne un code plus rigoureux.  2

  7. Étant donné que les arguments d’une fonction se comportent (hormis quelques subtilités) comme de simples variables locales initialisées avec les valeurs passées lors d’un appel, le mot-clé const peut également s’utiliser avec les arguments d’une fonction. 

  8. En fait, il peut avoir trois significations car on peut aussi l’associer à une fonction. Il réduit alors la visibilité de celle-ci au fichier source où elle est déclarée. Une telle fonction ne pourra donc pas être utilisée depuis un autre fichier. 

  9. Une interruption est un mécanisme d’exécution intégré à un microcontrôleur qui lui permet de réagir presque instantanément à des signaux provenant de périphériques (des capteurs par exemple) en exécutant de façon prioritaire des programmes répondant à ces signaux. Plus précisément, le programme exécuté par le microcontrôleur juste avant l’interruption est mis en pause, puis le microcontrôleur exécute un programme spécialement dédié à celle-ci, appelé “routine de service de l’interruption” (ou ISR pour “Interrupt Service Routine”, en anglais). Une fois que l’ISR a terminé, l’exécution du programme principal reprend son cours. 

  10. Nous avons choisi les types en cohérence avec les plages de valeurs possibles de chaque variable, compte tenu des unités de mesures (°C, hPa).