Yahoo refuse tous les emails du site. Si vous avez une adresse chez un autre prestataire, c'est le moment de l'utiliser ;)

En cas de soucis, n'hésitez pas à aller faire un tour sur la page de contact en bas de page.

Réduire l'empreinte mémoire d'un programme Arduino avec PROGMEM

Il faut sauver le soldat RyAM

Image d'entête

par skywodd | | Licence (voir pied de page)

Catégories : Tutoriels Arduino | Mots clefs : Arduino Genuino RAM Flash Progmem

Cet article n'a pas été mis à jour depuis un certain temps, son contenu n'est peut être plus d'actualité.


Dans ce tutoriel, nous allons voir ensemble comment réduire l'empreinte mémoire d'un programme Arduino grâce à l'extension PROGMEM. En bonus, nous verrons comment faire des fonctions personnalisées qui exploitent PROGMEM.

Sommaire

Bonjour à toutes et à tous !

Taille programme Arduino

On retient son souffle jusqu'à la fin de la compilation

Lors de la compilation de vos programmes Arduino, vous avez sûrement remarqué le petit message en bas de l'écran indiquant la taille du programme.

En plus d'indiquer la taille du programme, ce message affiche aussi le pourcentage d'utilisation de la mémoire vive depuis la version 1.6 du logiciel Arduino. Et parfois, à la compilation, on a de drôles de surprises.

Dans cet article, on va s'intéresser à PROGMEM, une extension propre au compilateur AVR-GCC qui est utilisé par le logiciel Arduino pour compiler les programmes Arduino.

PS Le blabla d'introduction ci-dessous est un peu long, certes, mais important pour la bonne compréhension de l'article ;)

Avant-propos

En informatique "classique", la vie est relativement simple.

Aujourd'hui, un ordinateur, un smartphone ou une tablette dispose généralement de plusieurs gigaoctets de mémoire vive, de périphériques de stockages de grande capacité et d'un processeur multicoeur à plusieurs giga Hertz.

Par conséquent, quand on écrit un programme de nos jours, on ne s'inquiète pas trop de l'optimisation de la mémoire. Il est très classique d'écrire un programme, de le tester et de le publier, le tout sans jamais s'inquiéter de sa taille ou du risque de dépassement de la capacité mémoire. La plupart du temps, cela n'est tout simplement pas un problème.

PS Sauf bien sûr, si votre programme s'appelle DOOM, pèse plus de 56Go et que chaque mise à jour nécessite le téléchargement de presque 25Go supplémentaires. Huumm. Je m'égare.

En informatique embarquée, les mentalités sont complètement différentes. Chaque octet compte et le matériel n'est pas modifiable.

J'ai croisé à plusieurs reprises des lecteurs, informaticiens de profession ou en devenir, découvrant les spécifications matérielles d'une carte Arduino et se demandant s'il n'y avait pas une erreur quelque part.

Carte Arduino Genuino UNO

Carte Arduino Genuino UNO

32Ko de mémoire programme, 2Ko de mémoire vive, 1Ko de mémoire EEPROM, un processeur 8 bits à 16MHz. Mais qu'est-ce donc que cette vieillerie !? Comment peut-on faire quoi que ce soit avec si peu de ressources !?

Sans grande surprise, les habitudes en informatique embarquée sont bien différentes de celles de l'informatique classique. Les contraintes matérielles obligent les développeurs embarqués à faire pas mal de pirouettes. À vrai dire, on n’a pas vraiment le choix, le matériel est ce qu'il est et l'on doit faire avec.

En programmation embarquée, chaque octet à son importance, en particulier avec la mémoire vive dans laquelle sont stockées les diverses variables du programme au fil de son exécution.

Manquer de mémoire programme n'est pas un trop gros problème. Si le programme est trop gros, il ne pourra pas être transféré dans la mémoire du microcontrôleur. Pas d'exécution possible, fin de l'histoire. Dans ce cas, la seule solution est d'utiliser un microcontrôleur avec plus de mémoire ou de réduire la taille du code.

Manquer de mémoire vive est beaucoup plus embêtant. Si votre programme nécessite 4Ko de RAM, mais que le microcontrôleur ne dispose que de 2Ko, il va y avoir un souci. Sauf que ce souci ne sera visible qu'au moment de l'exécution. Une petite erreur dans le code peut donc rester cachée jusqu'au jour ou paf, surprise, plus de mémoire !

En bonus, le manque de mémoire vive est en généralement la cause de bugs totalement aléatoires, horriblement déconcertant et particulièrement pénible à débugger ou reproduire.

Comment économiser la mémoire vive

Vous l'aurez compris, économiser la mémoire vive est un sport olympique en informatique embarquée.

Je vais enfoncer des portes ouvertes, mais la solution la plus simple et la plus efficace pour économiser la mémoire vive, mais aussi réduire la taille du programme lui-même, c'est de réfléchir avant de coder.

La meilleure façon d'économiser de la mémoire, c'est de prendre un crayon, une feuille de papier, de réfléchir dans son coin, faire des diagrammes, prévoir les divers cas d'usages possibles, choisir des structures de données et des algorithmes adaptés. Bref, concevoir l'architecture logicielle du programme et prendre en compte les spécificités et contraintes du matériel avant de se lancer dans le code.

Il est inutile et illusoire de vouloir optimiser un programme mal conçu. C'est comme s'inquiéter du niveau d'essence dans sa voiture alors qu'on roule en sens inverse sur l'autoroute.

La seconde solution pour réduire l'utilisation de la mémoire vive est de ne pas l'utiliser pour tout et n'importe quoi. Si une variable est constante (cette phrase ne veut rien dire), pourquoi la garder en mémoire vive ? C'est là que PROGMEM entre en jeu.

Analyse d'un cas d'école

Étudions le code suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void setup() {
  Serial.begin(115200);
  Serial.println("Bonjour le monde !");
}

void loop() {
  int valeur = analogRead(A0);
  Serial.println(valeur);
  delay(1000);
}

Ce code fait plusieurs choses : il initialise le port série de la carte Arduino, affiche un message de bienvenue fort sympathique, puis affiche ensuite en boucle la valeur analogique lue sur la broche A0 toutes les secondes.

Maintenant, cherchez l'erreur ;)

Un indice : 115200, "Bonjour le monde !", A0 et 1000 sont des constantes. Mais où sont stockées ces constantes ? En mémoire, programme ? En mémoire vive ?

Après compilation du code ci-dessus, on obtient le code machine ci-dessous (j'ai gardé seulement le plus important par souci de simplicité) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
setup:
; Serial.begin(115200);
ldi r20, 0x00   ; 
ldi r21, 0xC2   ; 115200 = 00 01 C2 00 (hexadécimal)
ldi r22, 0x01   ; 
ldi r23, 0x00   ; 
call    0x41e   ; Appel à la fonction <HardwareSerial.begin>

; Serial.println("Bonjour le monde !");
ldi r22, 0x00   ; 
ldi r23, 0x01   ; Adresse mémoire 0x0100
call    0x9a8   ; Appel à la fonction <Print.println>


loop:
; int valeur = analogRead(A0);
ldi r24, 0x0E   ; 14 = A0
call    0x2be   ; Appel à la fonction <analogRead>

; Serial.println(valeur);
ldi r24, 0xB5   ; 
ldi r25, 0x01   ; Adresse mémoire de la variable "valeur"
ldi r20, 0x0A   ; et paramètres de la fonction println
ldi r21, 0x00   ; 
call    0x96a   ; Appel à la fonction <Print.println>
  
; delay(1000);
ldi r22, 0xE8   ; 
ldi r23, 0x03   ; 1000 = 00 00 03 E8 (hexadécimal)
ldi r24, 0x00   ; 
ldi r25, 0x00   ; 
call    0x196   ; Appel à la fonction <delay>

N.B. Inutile d'essayer de comprendre le code ci-dessus. Cet article n'est pas un tutoriel de programmation bas niveau en assembleur. Ce qui est intéressant ici, c'est la façon dont sont gérées les diverses constantes de notre programme lors de la compilation.

Les valeurs 115200, A0 et 1000 sont directement intégrées dans le code machine du programme. Aucun octet de mémoire vive n'est nécessaire pour stocker ces constantes. Lors de l'exécution du code machine, le programme charge directement ces valeurs dans la mémoire de travail du processeur (les "registres") avant d'appeler le code de la fonction désirée.

Mais où est Charlie la chaine de caractères "Bonjour le monde !" ? On voit dans le code compilé qu'il y a une adresse 0x0100 chargée en mémoire avant d'appeler Serial.println(), mais aucune trace de notre sympathique message d'accueil.

1
2
3
4
Contents of section .data:
 800100 426f6e6a 6f757220 6c65206d 6f6e6465  Bonjour le monde
 800110 20210001 00000000 0303ab03 9602ca02   !..............
 800120 aa02f302                             ....

En réalité, notre message ce trouve beaucoup plus loin dans le code machine, dans la section .data de notre programme pour être précis. Cette section ne contient pas de code à proprement parler. Elle contient uniquement les valeurs d'initialisation des variables globales et statiques, ainsi que les constantes qui ne peuvent pas être incluses directement dans le reste du code machine. Si vous déclarez une variable globale int toto = 42; dans votre programme, la valeur 42 finira dans cette section .data.

Au lancement du programme, toutes les valeurs de la section .data sont automatiquement chargées en mémoire vive pour pouvoir être manipulées par la suite par le reste du code du programme. Comme cette section contient toutes les constantes non numériques (chaines de caractères, tableaux de valeurs, structures, etc.), on charge effectivement des constantes en mémoire vive sans s'en rendre forcément compte.

Il est complètement c*n ce compilateur !?

Certains lecteurs doivent se demander (à juste titre) pourquoi le compilateur charge en mémoire vive des constantes. Les développeurs d'AVR-GCC auraient-ils fumé la moquette ?

En réalité, le compilateur n'a pas vraiment d'autre choix.

Illustration architectures Von-Neumann VS Harvard

Von-Neumann VS Harvard

Il existe une différence fondamentale entre un ordinateur / smartphone / tablette et un microcontrôleur : l'architecture du processeur.

Les processeurs d'ordinateur ont une architecture de type Von-Neumann. Cela signifie que le code du programme et les données (variables) de celui-ci sont accessibles via un même bus d'adresses. Pour le processeur, accéder à une constante dans le code du programme ou à une variable en mémoire vive est strictement identique.

Les processeurs de microcontrôleurs ont (généralement) une architecture de type Harvard. Cela signifie que le code du programme et les données de celui-ci sont physiquement séparés et accessibles via deux bus d'adresses différents, un pour le code du programme et un pour les données.

Pour pouvoir utiliser une constante stockée dans le code du programme, comme une chaine de caractères par exemple, le programme doit d'abord copier la constante de la mémoire programme à la mémoire vive.

"Pourquoi ne pas charger en mémoire vive les données seulement au moment où on en a besoin plutôt qu'au lancement du programme ?"

C'est le but de PROGMEM.

"Pourquoi ce n'est pas fait automatiquement ?"

C'est (partiellement) le cas avec les versions récentes du compilateur AVR-GCC, mais ces versions ne sont pas encore disponibles avec le logiciel Arduino.

"Est-ce qu'utiliser PROGMEM augmente la taille du programme compilé ?"

Oui, mais très peu, quelques octets tout au plus. PROGMEM réduit par contre considérablement l'utilisation de la mémoire RAM. C'est le but.

La solution : PROGMEM

PROGMEM est une extension pour le compilateur AVR-GCC qui permet de dire au cas par cas "garde ces données en mémoire programme et laisse-moi gérer le chargement moi-même".

Les données marquées avec PROGMEM sont stockées directement dans le code du programme, sans être chargées au démarrage de celui-ci. C'est à vous de demander le chargement en mémoire vive des données quand vous en avez besoin. Avec cette technique on évite de consommer de la mémoire vive inutilement quand cela n'est pas nécessaire.

La fonction F

Si vous utilisez le logiciel Arduino (ce qui a de grandes chances d'être le cas si vous lisez cet article), sachez que pour vous faciliter la vie, le logiciel Arduino intègre une fonction nommée F().

Cette fonction permet de marquer une chaine de caractères avec PROGMEM, sans avoir quoi que se soit d'autre à faire. Cette fonction est utilisable uniquement avec des chaines de caractères et uniquement avec les fonctions Arduino print() et println(), comme Serial.println() par exemple.

Cette fonction a été conçue principalement pour faciliter la vie aux développeurs qui utilise Serial pour du débug ou de l'affichage. Exemple avec le code du chapitre précédent :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void setup() {
  Serial.begin(115200);
  Serial.println(F("Bonjour le monde !")); // Plus de soucis de mémoire RAM, vive F()
}

void loop() {
  int valeur = analogRead(A0);
  Serial.println(valeur);
  delay(1000);
}

Si vous affichez du texte avec les fonctions Arduino print() ou println(), ajoutez systématiquement F() autour de vos chaines de caractères. Cela ne coute rien et ça réduit considérablement l'utilisation de la mémoire vive. Cela évite aussi plein de soucis et ça empêche même la chute des cheveux d'après certains développeurs.

Le mot clef PROGMEM

La fonction F(), c'est sympa, mais c'est très limité. Pour un print() ça passe, mais pour le reste il y a le mot clef PROGMEM.

1
#include <avr/pgmspace.h>

L'extension PROGMEM est utilisable après avoir inclus le fichier <avr/pgmspace.h> en début de programme.

Ensuite, il est possible de marquer une constante avec le mot clef PROGMEM :

1
const char* PROGMEM HELLO_WORLD = "Bonjour le monde !";

N.B. Le mot clef PROGMEM doit toujours être utilisé en duo avec le mot clef const. Si vous oubliez le mot clef const, le compilateur générera une erreur de compilation.

N.B. La déclaration d'une constante PROGMEM doit se faire globalement. Si vous essayez de déclarer une constante locale avec PROGMEM, vous obtiendrez une erreur de compilation.

PS Pour éviter des erreurs de compilations suivant la version du compilateur et du logiciel Arduino, il est recommandé de placer le mot clef PROGMEM après le type de la variable comme dans l'exemple ci-dessus.

PROGMEM est particulièrement intéressant pour le stockage de tableau de valeurs, comme des données de calibration de capteurs ou des tables de correspondance par exemple.

Exemple :

1
2
3
4
const byte PROGMEM CALIBRATION_DATA[] = {
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
  // ...
};

Attention, dans le cas d'un tableau de pointeurs (tableau contenant d'autres tableaux) ou d'un tableau de chaines de caractères, il est nécessaire de déclarer une variable pour chaque sous tableau / chaines de caractères avant de déclarer le tableau "parent". C'est une limitation de PROGMEM.

Exemple (cas classique d'un tableau de messages d'erreur) :

1
2
3
4
5
6
7
8
9
const char* PROGMEM ERROR_MSG_NO_MORE_COFFEE = "Plus de café dans la cafetière";
const char* PROGMEM ERROR_MSG_PEBCAK = "Erreur d'interface chaise-clavier";
const char* PROGMEM ERROR_MSG_EAT_BY_DOG = "Le chien a mangé mon devoir";

const char* const PROGMEM ERROR_MESSAGES[] = {
  ERROR_MSG_NO_MORE_COFFEE,
  ERROR_MSG_PEBCAK,
  ERROR_MSG_EAT_BY_DOG 
};

N.B. Dans le cas d'un tableau de tableaux ou d'un tableau de chaines de caractères, le tableau parent a deux fois le mot clef const, avant et après le type, car c'est un tableau constant de constantes.

Lire des données depuis un tableau PROGMEM

La lecture des données d'un tableau en mémoire programme se fait via un lot de fonctions dont le nom commence par pgm_read_.

Dans les exemples ci-dessous, tableau un tableau de données en mémoire programme contenant des données de votre choix et index l'indice dans le tableau de la valeur que vous souhaitez charger en mémoire dans la variable valeur.

Pour lire un octet :

1
2
3
4
5
6
7
// Déclaration
const byte PROGMEM tableau[] = {
  // ...
};

// Lecture
byte valeur = pgm_read_byte(tableau + index);

Pour lire un entier sur 16 bits :

1
2
3
4
5
6
7
// Déclaration
const int PROGMEM tableau[] = {
  // ...
};

// Lecture
int valeur = pgm_read_word(tableau + index);

Pour lire un entier sur 32 bits :

1
2
3
4
5
6
7
// Déclaration
const long PROGMEM tableau[] = {
  // ...
};

// Lecture
long valeur = pgm_read_dword(tableau + index);

Pour lire un nombre à virgule :

1
2
3
4
5
6
7
// Déclaration
const float PROGMEM tableau[] = {
  // ...
};

// Lecture
float valeur = pgm_read_float(tableau + index);

Pour lire une chaine de caractères :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Déclaration
const char* PROGMEM message = "Mon message";
const char* const PROGMEM tableau[] = {
  message,
  // ...
};

// Lecture
char valeur[32]; // Attention : La variable doit être suffisamment grande pour stocker le message
strcpy_P(valeur, (char*) pgm_read_word(&(tableau[index])));

N.B. Le chargement en mémoire vive d'une chaine de caractère est forcément plus complexe que le chargement d'un type natif, car une chaine de caractères à une longueur variable.

N.B. Dans le cas d'une chaine de caractères, la taille de la variable valeur doit impérativement être suffisamment grande pour accueillir la chaine de caractères.

Manipulation de chaines de caractères PROGMEM

Pour les lecteurs qui souhaiteraient utiliser PROGMEM pour des messages d'erreurs, des titres de menus, etc. Sachez qu'il existe une version compatible PROGMEM de quasiment chaque fonction standard de manipulation de chaines de caractères comme strlen(), strcpy() ou strcmp(), etc.

Par convention, les versions compatibles PROGMEM portent le même nom que la version standard, mais avec _P à la fin du nom. Exemple : au lieu de strlen(), on utilise strlen_P().

Je vous laisse regarder la documentation pour avoir un aperçu des diverses fonctions disponibles.

Bonus : Écrire une fonction acceptant une F-chaine de caractères en paramètre

Si vous souhaitez écrire une fonction acceptant une chaine de caractères encapsulée avec la fonction F(), il convient d'utiliser le type spécial __FlashStringHelper, sans oublier le mot clef const.

Exemple :

1
2
3
4
void printError(const __FlashStringHelper* s) {
  Serial.print(F("Erreur : "));
  Serial.println(s);
}

N.B. Toutes les fonctions pgm_read_XXX() et strXXX_P() sont utilisables avec les variables de type __FlashStringHelper. En réalité, __FlashStringHelper n'est qu'un raccourci compatible PROGMEM pour les chaines de caractères.

Bonus : Écrire une fonction acceptant un tableau PROGMEM en paramètre

Écrire une fonction acceptant un tableau PROGMEM en paramètre est un peu bizarre.

En fait, il faut écrire la fonction comme si on n'utilisait pas PROGMEM, tout en utilisant les fonctions pgm_read_XXX et associés pour manipuler les paramètres de la fonction.

Exemple, une fonction qui prend un tableau PROGMEM en paramètre et affiche sur le port série les 4 premières valeurs du tableau en question :

1
2
3
4
5
6
void maSuperFonction_P(const int tableau[]) {
  for (int i = 0; i < 4; i++) {
    int valeur = pgm_read_word(tableau + i); // Normalement on aurait fait tableau[i], mais pas ici vu que le tableau est marqué avec PROGMEM
    Serial.println(valeur);
  }
}

N.B. Comme il est impossible de faire la différence entre une fonction "normale" et une version utilisant PROGMEM simplement en regardant les paramètres de la fonction, par convention, on ajoute _P à la fin du nom de la version compatible PROGMEM.

Conclusion

Ce tutoriel est désormais terminé.

Si ce tutoriel vous a plu, n'hésitez pas à le commenter sur le forum, à le partager sur les réseaux sociaux et à soutenir le site si cela vous fait plaisir.