Stocker des données en mémoire EEPROM avec une carte Arduino / Genuino

Garantie sans taxe sur la copie privée, pour le moment

Image d'entête

par skywodd | | Licence (voir pied de page)

Catégories : Tutoriels Arduino | Mots clefs : Arduino Genuino Mémoire EEPROM

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 apprendre ensemble à stocker des données binaires dans la mémoire EEPROM interne d'une carte Arduino / Genuino. Nous verrons comment écrire des données, puis lire ces données et les mettre à jour si besoin. En bonus, nous verrons comment stocker des données structurées, comme des données de calibration par exemple.

Sommaire

Bonjour à toutes et à tous !

Quand on développe une application embarquée, que ce soit un contrôleur d'imprimante 3D, un robot ou autre, on a en général besoin de stocker des informations de manière persistante.

Il y a deux grands types de données que l'on peut vouloir conserver en mémoire, même quand on débranche l'alimentation : les données utilisateurs (exemple : paramètres d'affichage) et les données de configuration d'usine (exemple : données de calibration).

Prenons l'exemple d'une imprimante 3D, celle-ci a besoin de diverses informations pour fonctionner : le nombre de pas des moteurs, la longueur, largeur et hauteur du plateau d'impression, la température minimum et maximum de l'extrudeur, etc. Toutes ces données ont besoin d'être stockées quelque part pour que l'imprimante fonctionne.

Si ces données sont connues lors de la conception de l'application et n'évoluent pas par la suite, on peut directement les déclarer dans le code source du programme. C'est le cas par exemple des broches et autres constantes que l'on trouve classiquement en début de programme.

Seulement, si ces données sont amenées à évoluer, à être modifiées par l'utilisateur ou sont liées à une machine précise (exemple : numéro de série, données de calibration de capteur, etc.), dans ce cas, il n'est pas possible de déclarer ces données directement dans le programme, il faut les stocker dans une mémoire dédiée : l'EEPROM.

Et ça tombe bien, stocker des données en mémoire EEPROM est le sujet de l'article d'aujourd'hui. Le hasard fait bien les choses ;)

Mémoire EEPROM, késako ?

Photographie EPROM UV Ti

Une mémoire EPROM de 1985, un très bon cru

Quand on parle de puces mémoires, on les classe toujours en deux catégories : les mémoires vives ("volatiles") et les mémoires mortes ("non volatiles").

Les mémoires vives sont très rapides, mais s'effacent quand l'alimentation électrique est coupée. Elles sont principalement utilisées pour stocker des résultats de calculs intermédiaires, des variables, des données temporaires, etc. "Mémoire RAM" ça vous dit quelque chose ? C'est l'appellation usuelle des mémoires vives ;)

Les mémoires mortes au contraire sont beaucoup plus lentes, mais ne s'effacent pas quand l'alimentation électrique est coupée. Elles sont donc parfaites pour stocker des données à long terme.

Il existe actuellement trois grands types de mémoire morte disponibles sur le marché :

  • les mémoires NVRAM, qui sont en réalité des mémoires vives avec une batterie intégrée. On les retrouve principalement dans les routeurs Wifi, les box Internet, les switchs Ethernet, etc.

  • les mémoires Flash, un grand classique de nos jours pour stocker de grandes quantités de données. Les clefs USB, disque dur solide (SSD) et les mémoires de microcontrôleurs sont de type Flash.

  • les mémoires EEPROM, qui permettent de stocker de petites quantités de données, très simplement. On les retrouve généralement dans les microcontrôleurs 8 bits.

Sans entrer dans les détails, les NVRAM sont chères, mais très rapides (ce sont des mémoires vives déguisées en mémoire morte). Elles ont cependant une durée de vie limitée à cause de la batterie intégrée.

Les mémoires Flash sont lentes (tout dépend de la technologie) mais elles permettent de stocker de très grandes quantités de données pour un coût très faible. Les mémoires Flash ont cependant un gros défaut : elles fonctionnent par secteur. Écrire un octet demande en réalité de lire, modifier puis écrire un secteur complet, soit entre 512 et 4096 octets d'un coup.

Pour finir, les mémoires EEPROM sont (très) lentes, permettent de stocker de toutes petites quantités de données, mais permettent l'accès aux données (en lecture et écriture) octet par octet. Elles ont aussi l'avantage d'être relativement peu couteuses. C'est pour ces raisons qu'elles sont très souvent utilisées en électronique pour stocker des données utilisateurs ou des données de calibration (qui sont en général des données de petites tailles, ne changeant pas souvent et nécessitant un accès octet par octet).

À chaque carte Arduino sa mémoire EEPROM

Toutes les tailles sont données en octets :

  • Arduino UNO, Leonardo, 101 : 1024 octets (1Ko),

  • Arduino Mega et Mega2560 : 4096 octets (4Ko),

  • Arduino Zero : 16384 octets (16Ko),

  • Arduino Due : pas d'EEPROM.

N.B. Sur les cartes Arduino 101 et Zero, l'EEPROM n'est pas une vraie mémoire EEPROM. C'est une simple émulation utilisant la mémoire Flash normalement utilisée pour stocker le programme de la carte. Par conséquent, la durée de vie de cette mémoire est très limitée (25 000 cycles d'écritures maximum). Partez du principe qu'il n'y a pas de mémoire EEPROM sur les cartes 101 et Zero, c'est beaucoup simple ;)

La bibliothèque EEPROM.h

Pour manipuler des données en mémoire EEPROM, il est nécessaire d'utiliser la bibliothèque EEPROM disponible de base avec le logiciel de développement Arduino.

Pour utiliser la bibliothèque EEPROM dans un programme Arduino, il suffit d'ajouter cette ligne en début de programme :

1
#include <EEPROM.h>

Écrire dans la mémoire EEPROM

Écrire un octet en mémoire ce fait au moyen de la fonction EEPROM.write().

1
EEPROM.write(int adresse, byte valeur)

La fonction EEPROM.write() accepte deux paramètres obligatoires : l'adresse mémoire de l'octet à écrire (débutant à 0) et la valeur de l'octet en question (entre 0 et 255). La fonction ne retourne aucune valeur.

N.B. L'écriture en mémoire EEPROM est très lente, environ 3.3 millisecondes pour écrire un octet. Cela correspond à une vitesse d'écriture d'un peu plus de 300 octets par seconde. De plus, chaque cellule d'une mémoire EEPROM a une endurance de 100 000 cycles d'écriture. Il est donc fortement déconseillé d'écrire en boucle dans une mémoire EEPROM, au risque de la détruire prématurément.

Par exemple, voici comment écrire une suite de nombres de 0 à 255 dans les 256 premiers octets de mémoire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <EEPROM.h>

void setup() {
  for (int i = 0; i < 256; i++) {
    EEPROM.write(i, i);
  }
}

void loop() {
} 

Lire depuis la mémoire EEPROM

Lire un octet en mémoire ce fait au moyen de la fonction EEPROM.read().

1
byte EEPROM.read (int adresse)

La fonction EEPROM.read() accepte un paramètre obligatoire : l'adresse mémoire de l'octet à lire (débutant à 0). La fonction retourne la valeur de l'octet à l'adresse en question (entre 0 et 255).

PS : Vous pouvez lire une mémoire EEPROM autant de fois que vous le souhaitez, seule l'écriture réduit sa durée de vie.

Par exemple, voici comment lire le contenu des 256 premiers octets de mémoire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <EEPROM.h>

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

  for (int i = 0; i < 256; i++) {
    byte valeur = EEPROM.read(i);
    Serial.print(i);
    Serial.print(" = ");
    Serial.println(valeur);
  }
}

void loop() {
} 

Mettre à jour les données efficacement

Imaginons que la mémoire EEPROM de votre carte Arduino contient la suite d'octets suivante : 1 2 3 4 5 6.

Le temps passe et vous voulez maintenant changer ces valeurs par 1 2 3 7 8 9, comme vous pouvez le remarquer, certaines valeurs sont identiques, il serait donc idiot de perdre un cycle d'écriture pour écrire une valeur identique à la précédente.

C'est pourquoi il existe la fonction EEPROM.update(). Celle-ci est identique à la fonction EEPROM.write() mais vérifie d'abord la valeur en mémoire avant de faire une écriture. Si la valeur est différente, l'écriture est réalisée, sinon, la fonction ne fait rien.

PS : Idéalement, cette fonction devrait toujours être utilisée, en remplacement de EEPROM.write(), car celle-ci fonctionne de manière identique, mais évite les écritures inutiles.

1
EEPROM.update (int adresse, byte valeur)

La fonction EEPROM.update() accepte deux paramètres obligatoires : l'adresse mémoire de l'octet à écrire (débutant à 0) et la valeur de l'octet en question (entre 0 et 255). La fonction ne retourne aucune valeur.

Bonus : Lire et écrire des données typées en mémoire EEPROM

Lire et écrire des octets, c'est bien, mais il est souvent bien plus intéressant de lire ou écrire des données typées, comme des nombres entiers, des nombres à virgules, du texte, etc.

Pour cela il existe deux fonctions : EEPROM.get() et EEPROM.put().

N.B. Ces fonctions étaient à l'origine disponible dans une bibliothèque séparée, conçue par la communauté et nommée "EEPROMWriteAnything". Pour les utilisateurs d'anciennes versions du logiciel Arduino (inférieur à Arduino 1.0.x), le code de cette bibliothèque est disponible ici : http://playground.arduino.cc/Code/EEPROMWriteAnything.

La fonction EEPROM.get() permet de lire une variable d'un type quelconque depuis la mémoire EEPROM.

1
EEPROM.get(int adresse, variable)

La fonction EEPROM.get() accepte deux paramètres obligatoires : l'adresse de la variable à lire et la variable à lire. La fonction retourne une référence vers la variable lue.

À l'inverse, la fonction EEPROM.put() permet d'écrire la valeur d'une variable d'un type quelconque dans la mémoire EEPROM.

1
EEPROM.put(int adresse, variable)

La fonction EEPROM.put() accepte deux paramètres obligatoires : l'adresse de la variable à écrire et la variable à écrire. La fonction retourne une référence vers la variable écrite.

N.B. La fonction EEPROM.put() utilise EEPROM.update() pour écrire les données uniquement quand cela est nécessaire.

Voici un exemple de code faisant une écriture puis une lecture de deux variables :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <EEPROM.h>

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

  int valeur_1 = 42; 
  EEPROM.put(0, valeur_1);

  float valeur_2 = 13.37;
  EEPROM.put(2, valeur_2); // Un int fait deux octets, l'adresse est donc de 0 + 2 = 2

  int valeur_lue_1;
  EEPROM.get(0, valeur_lue_1);
  Serial.print("Valeur 1 = ");
  Serial.println(valeur_lue_1);

  float valeur_lue_2;
  EEPROM.get(2, valeur_lue_2);
  Serial.print("Valeur 2 = ");
  Serial.println(valeur_lue_2);
}

void loop() {
} 

PS : Pour faciliter le calcul de l'adresse, sachez que la fonction sizeof(variable) permet de connaitre la taille en octet d'une variable. Si vous souhaitez stocker plusieurs variables en mémoire avec cette méthode, je vous conseille de déclarer une variable pour l'adresse, initialisée à zéro, puis d'ajouter sizeof(variable) après chaque EEPROM.put() pour avoir l'adresse suivante. Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <EEPROM.h>

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

  int adresse = 0;
  
  int valeur_1 = 42; 
  EEPROM.put(adresse, valeur_1);
  adresse += sizeof(valeur_1);

  float valeur_2 = 13.37;
  EEPROM.put(adresse, valeur_2); 
  adresse += sizeof(valeur_2);

  // ...
}

void loop() {
} 

Attention aux pointeurs

Si l'envie vous prend de stocker un tableau d'entiers ou une chaine de caractères, soyez vigilant.

Un tableau de taille fixe peut être stocké en mémoire par EEPROM.put(), ce n'est pas le cas d'un pointeur.

Si vous essayez de stocker en EEPROM un pointeur vers une chaine de caractères par exemple (type char*), vous n'allez pas stocker la chaine de caractères elle-même, mais seulement l'adresse du pointeur. Or, plus tard, lors de la lecture, cette adresse ne vous saura d'aucune utilité.

Ce petit morceau de code C (pour PC) illustre parfaitement la subtile différence entre un tableau et un pointeur sur tableau (ici de caractères) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main(void) {

    // Pointeur sur tableau de char
    char * ptr = "Foobar";
    printf("sizeof(ptr) = %d\n", sizeof(ptr));
    
    // Tableau de char
    char array[] = "Foobar";
    printf("sizeof(array) = %d\n", sizeof(array));
    return 0;
}

// Résultats :
// sizeof(ptr) = 4 (-> adresse 32 bits = 4 octets)
// sizeof(array) = 7 ("Foobar" = 6 caractéres + zéro de fin de chaine de caractères)

Bonus : Lire et écrire des données structurées en mémoire EEPROM

On a vu dans le chapitre précédent comment lire et écrire des données typées, mais cela n'est pas très pratique quand on a plusieurs variables à sauvegarder en mémoire.

Pour ce genre de chose, il existe une méthode très simple et pratique : les structures. Les structures sont des types de données complexes qui peuvent accueillir n'importe quels autres types de données. En gros, c'est un type de variable qui peut contenir d'autres variables à l'intérieur de celle-ci.

Voici un exemple de code utilisant une structure pour écrire puis lire un certain nombre de variables en mémoire EEPROM en une seule fois :

 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
33
34
35
/**
 * Exemple d'utilisation d'une structure avec EEPROM.put().
 */

#include <EEPROM.h>

/** La structure permettant de stocker les données */
struct MaStructure {
   int valeur_1;
   float valeur_2;
}; // Ne pas oublier le point virgule !

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

  // Ecrit la structure en mémoire
  MaStructure ms;
  ms.valeur_1 = 42; 
  ms.valeur_2 = 13.37;
  EEPROM.put(0, ms);


  // Lit la structure en mémoire
  MaStructure ms_lue;
  EEPROM.get(0, ms_lue);

  Serial.print("Valeur 1 = ");
  Serial.println(ms_lue.valeur_1);

  Serial.print("Valeur 2 = ");
  Serial.println(ms_lue.valeur_2);
}

void loop() {
} 

L'extrait de code ci-dessus est disponible en téléchargement sur cette page (le lien de téléchargement en .zip contient le projet Arduino prêt à l'emploi).

Attention aux tableaux et aux pointeurs (le retour)

Les structures peuvent contenir des tableaux de valeurs, exemple :

1
2
3
4
struct MaStructure {
   int valeurs[10];
   char adresse_ip[16];
};

Cependant, vous remarquerez que les tableaux sont de tailles fixes ! Vous ne pouvez pas utiliser de pointeurs dans une structure ayant pour but d'être sauvegardée en mémoire EEPROM. Je l'ai déjà dit dans le chapitre précédent, mais c'est une erreur très facile à faire, donc autant le redire ;)

Si vous faites ceci par exemple :

1
2
3
4
5
6
7
struct MaStructure {
   char* adresse_ip;
};

// ...
MaStructure ms;
ms.adresse_ip = "192.168.1.1";

La valeur 192.168.1.1 ne sera pas sauvegardée, c'est l'adresse du pointeur vers cette valeur qui le sera. Cette erreur est extrêmement commune. Quand vous écrivez votre structure, n'oubliez pas de donner une taille fixe à chaque tableau de valeurs.

Et si vous avez vraiment besoin de stocker des données de tailles variables, comme une chaine de caractères, il faudra revenir à la méthode manuelle avec des adresses calculées pour chaque élément.

Astuce de développeur embarqué

Si vous utilisez une structure pour stocker en mémoire EEPROM des données utilisateurs ou de calibration, pensez à ajouter en tout début de structure ces deux variables :

1
2
unsigned long magic;
byte struct_version;

Au moment d'écrire les données en mémoire, assignez une valeur constante à magic (par exemple 1234567890) et un numéro de version à struct_version.

La variable magic permet de gérer le cas où aucune donnée ne se trouve en mémoire (première utilisation du programme). Si la constante n'est pas bonne, c'est que le reste des données n'est pas utilisable et ces données doivent être remplacées par des valeurs par défaut.

La variable struct_version permet de gérer le cas où vous avez besoin d'ajouter plus tard une variable à la structure. En fonction du numéro lue, il est possible de savoir quelles variables sont utilisables et quelles variables n'existaient pas à l'époque et doivent être remplacées par une valeur par défaut.

Exemple complet avec commentaires :

 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
 * Exemple d'utilisation de structure avec EEPROM.put().
 * Variante avec versionnage et détection de première utilisation.
 */

#include <EEPROM.h>

/** Le nombre magique et le numéro de version actuelle */
static const unsigned long STRUCT_MAGIC = 123456789;
static const byte STRUCT_VERSION = 2;

/** La structure qui contient les données */
struct MaStructure {
  unsigned long magic;
  byte struct_version;

  int valeur_1; // Depuis la struct_version = 0
  float valeur_2; // Depuis la struct_version = 1
  char valeur_3[16]; // Depuis la struct_version = 2
};

/** L'instance de la structure, globale, car utilisé dans plusieurs endroits du programme */
MaStructure ms;

void setup() {
  Serial.begin(9600);
  
  // Charge le contenu de la mémoire
  chargeEEPROM();

  // Affiche les données dans la structure
  Serial.print("Valeur 1 = ");
  Serial.println(ms.valeur_1);

  Serial.print("Valeur 2 = ");
  Serial.println(ms.valeur_2);

  Serial.print("Valeur 3 = ");
  Serial.println(ms.valeur_3);
}

void loop() {
} 

/** Sauvegarde en mémoire EEPROM le contenu actuel de la structure */
void sauvegardeEEPROM() {
  // Met à jour le nombre magic et le numéro de version avant l'écriture
  ms.magic = STRUCT_MAGIC;
  ms.struct_version =  STRUCT_VERSION;
  EEPROM.put(0, ms);
}

/** Charge le contenu de la mémoire EEPROM dans la structure */
void chargeEEPROM() {

  // Lit la mémoire EEPROM
  EEPROM.get(0, ms);
  
  // Détection d'une mémoire non initialisée
  byte erreur = ms.magic != STRUCT_MAGIC;

  // Valeurs par défaut struct_version == 0
  if (erreur) {

    // Valeurs par défaut pour les variables de la version 0
    ms.valeur_1 = 42;
  }
  
  // Valeurs par défaut struct_version == 1
  if (ms.struct_version < 1 || erreur) {

    // Valeurs par défaut pour les variables de la version 1
    ms.valeur_2 = 13.37;
  }

  // Valeurs par défaut pour struct_version == 2
  if (ms.struct_version < 2 || erreur) {

    // Valeurs par défaut pour les variables de la version 2
    strcpy(ms.valeur_3, "Hello World!");
  }

  // Sauvegarde les nouvelles données
  sauvegardeEEPROM();
}

L'extrait de code ci-dessus est disponible en téléchargement sur cette page (le lien de téléchargement en .zip contient le projet Arduino prêt à l'emploi).

N.B. Le code ci-dessus ne détecte pas les erreurs d'écriture ou de lecture. Une version améliorée sera disponible sous peu dans un prochain article ;)

Bonus : Utiliser la mémoire EEPROM comme un tableau d'octets

Si vous souhaitez utiliser la mémoire EEPROM comme s'il s'agissait d'un simple tableau d'octets, sachez qu'il existe un outil pour cela ;)

L'objet EEPROM n'est pas une simple classe avec quelques fonctions, elle peut aussi se comporter comme un tableau d'octets. Pour cela, il suffit d'utiliser la syntaxe "crochets" :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <EEPROM.h>

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

  // Ecriture
  for (int i = 0; i < 256; i++) {
    EEPROM[i] = i;
  }

  // Lecture
  for (int i = 0; i < 256; i++) {
    Serial.print(i);
    Serial.print(" = ");
    Serial.println(EEPROM[i]);
  }
}

void loop() {
} 

Cela fonctionne aussi bien en écriture qu'en lecture. Toutes les syntaxes du langage C/C++ sont utilisables (EEPROM[i] += 10 par exemple), comme s'il s'agissait d'un vrai tableau d'octets en mémoire vive.

Bonus : Connaitre la taille de la mémoire EEPROM depuis le programme

Cela n'est pas documenté officiellement, mais depuis Arduino 1.0.x, il est possible de connaitre la taille de la mémoire EEPROM via la fonction : EEPROM.length().

La fonction EEPROM.length() retourne un nombre entier qui correspond à la taille de la mémoire EEPROM.

Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <EEPROM.h>

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

  Serial.print("Taille EEPROM : ");
  Serial.println(EEPROM.length());
}

void loop() {
} 

Bonus : Boucles C++11 et EEPROM

La norme C++11 a introduit une nouvelle syntaxe de boucle, proche de celle des langages Java ou Python :

1
2
3
for (type nomvariable: objetsource) {
  // Boucle sur chaque élément de l'objet source
}

Cette syntaxe est supportée par la bibliothèque, même si cela n'est pas officiellement le cas :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <EEPROM.h>

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

  // Lecture
  for (byte valeur: EEPROM) {
    Serial.println(valeur);
  }
}

void loop() {
} 

Je doute sérieusement de l'utilité de cette fonctionnalité, mais bon, elle existe donc autant vous le faire savoir ;)

N.B. Cette fonctionnalité nécessite une version récente de l'environnement de développement Arduino (1.6.x ou supérieur).

PS : Au moment où j'écris ces lignes, la fonctionnalité ci-dessus est buggée. La boucle ne s'arrête jamais, car l'adresse de fin de mémoire est invalide. Cela explique surement pourquoi elle n'est pas encore documentée.

Conclusion

Ce tutoriel est désormais terminé.

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

Articles suivants