7. Mémoire EEPROM


Dans un micro-contrôleur tel que l'ATmega328 qui est inclus dans un Arduino UNO, il existe deux types de mémoire: la mémoire persistante et la mémoire volatile. Comme leur nom l'indique, la première n'est pas effacée lorsque la carte est mise hors-tension, alors que la seconde l'est. L'architecture du micro-contrôleur est organisée autour de trois mémoire d'usages différents:
  • la mémoire Flash dans laquelle est sauvegardée le programme lui-même. C'est une mémoire persistante. Heureusement, sinon, il faudrait reprogammer l'arduino à chaque allumage... Par contre, elle n'est accessible qu'au moment de la programmation.
  • le mémoire RAM qui est volatile. C'est dans cette zone que les variables d'exécution du programme sont stockées. Par exemple, le i d'une boucle for().
  • la mémoire EEPROM qui est persistante et accessible lors de l'exécution du programme. Elle est particulièrement pratique lorsque vient le temps de sauvegarder des états modifiés par le programme. Une simple analogie: l'adresse d'un décodeur ou une variable de configuration (CV) dans un décodeur DCC.
Les espaces mémoires sont de tailles différentes et dépendent du micro-contrôleur utilisé. Dans notre cas, le ATmega328 contient:
  • 32ko de mémoire Flash
  • 2ko de mémoire RAM
  • 1ko de mémoire EEPROM 
Intéressons-nous au dernier type: la mémoire EEPROM. L'exemple du contrôle d'un aiguillage arrive à point nommé. Mais avant toute chose, quelques explications sur l'utilisation de cette mémoire...




7.1. Lecture et écriture EEPROM

Nous avons vu précédemment une structure de programme particulière: le tableau. Afin de vous remémorer ce concept, vous pouvez vous référer à l'exemple du phare côtier dans lequel nous avions défini un tableau de valeurs d'intensité int lum[ 32 ].

Par analogie, nous définirons implicitement la zone de mémoire EEPROM comme un tableau de valeurs byte, dont la taille de chaque élément est 1 octet. Comme cette zone mémoire est de taille 1ko = 1024 octets, nous pourrions la définir ainsi dans un programme: byte eeprom[ 1024 ]. Mais attention: il n'est pas nécessaire de la déclarer! Ceci n'est qu'une analogie afin de comprendre son utilisation. Comme signalé, il s'agit d'une définition implicite donc liée à l'architecture du micro-contôleur...

Accéder à un élément du tableau est bien simple (nous l'avons d'ailleurs déjà vu en partie dans les exemples précédents). Définissons une tableau: byte tab[ 1024 ]. La lecture de l'élément d'index 10 s'effectue en accédant à la zone mémoire par l'index de la case qui nous intéresse: l'instruction e = tab[ 10 ] permet de lire l'élément et de le stocker dans la variable e. Soit dit en passant, cette variable doit être déclarée comme une variable de type byte.

De manière similaire, l'écriture d'un élément dans le tableau s'effectue ainsi: tab[ 12 ] = 50. Nous venons de stocker la valeur 50 dans la case d'index 12.

Voyons ce que cela donne dans un petit programme...

#define SIZE 1024
byte tab[ SIZE ];
byte e;

void setup()
{
  for ( int i = 0; i < SIZE; i++ )
  {
    tab[ i ] = 0;
  }
}

void loop()
{
  e = tab[ 10 ];
  tab[ 12 ] = 50;
}



Vous êtes d'ores-et-déjà familier avec les concepts simples de programmation. Nous ne nous étendrons donc pas sur ce code, mais nous résumerons ces lignes ainsi:
  • le tableau tab est construit avec une taille SIZE soit 1024 éléments de type byte.
  • dans la fonction setup(), nous initialisons le contenu de toutes les cases du tableau avec la valeur 0 grâce à une boucle for().
  • dans la fonction principale loop(), la valeur de l'élément d'index 10 est lue e = tab[ 10 ] puis celui de valeur 50 est enregistré à l'index 12 en effectuant tab[ 12 ] = 50.
Maintenant, transposons ce code en utilisant la mémoire EEPROM...

Comme nous l'avons vu au chapitre concernant les librairies, les fonctionnalités liées à la gestion de l'EEPROM ne sont pas incluses par défaut dans un programme. Donc, la première étape est d'inclure celle-ci par la directive #include <EEPROM.h>.

Une fois fait, il ne reste plus qu'à accéder à cette zone mémoire à la manière d'un tableau en appelant les méthodes read() et write() sur l'objet EEPROM. Ainsi, notre exemple devient:

#include <EEPROM.h>
byte e;

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

void loop()
{
  e = EEPROM.read( 10 );
  EEPROM.write( 12, 50 );
}

Mais le gros avantage désormais est que le contenu est persistant même si l'Arduino n'est plus alimenté contrairement au premier exemple... Ceci-dit, les instructions effectuées ici ne sont pas d'un grand intérêt. Passons à un exemple concret.




7.2. Exemple de persistance: le contrôle d'un aiguillage

Cet exemple va regrouper plusieurs concepts vus dans les chapitres précédents:
  • le bouton-poussoir ou l'utilisation des entrées
  • le contrôle d'un servo-moteur
  • et bien évidemment la mémoire EEPROM
L'utilité de ce montage est:
  • de positionner un aiguillage à l'aide de deux boutons poussoirs
  • de visualiser la position à l'aide de deux LEDs
  • et enfin de conserver cette information si le contrôleur (le réseau) est éteint.
Voyons de quoi il a l'air:



Et voilà le code source:

#include <Servo.h>
#include <EEPROM.h>

int boutonGauche = 8;
int boutonDroit = 9;
int ledRouge = 2;
int ledVerte = 3;
int commande = 7;

Servo moteur;

// Définir une position en mémoire EEPROM...
#define ADR_POS 0
// ...et les deux valeurs à stocker
#define POS_GAUCHE 90
#define POS_DROITE 45

void setup()
{
  // Initialisation des ports d'entrée
  pinMode( boutonGauche, INPUT );
  digitalWrite( boutonGauche, HIGH );
  pinMode( boutonDroit, INPUT );
  digitalWrite( boutonDroit, HIGH );

  // Initialisation des ports de sortie
  pinMode( ledRouge, OUTPUT );
  pinMode( ledVerte, OUTPUT );

  moteur.attach( commande );
  // Première fois, initialise la mémoire EEPROM
  if ( EEPROM.read( ADR_POS ) != POS_GAUCHE
      && EEPROM.read( ADR_POS ) != POS_DROITE )
  {
    EEPROM.write( ADR_POS, POS_GAUCHE );
  }
}

void loop()
{
  // Détection
  if ( digitalRead( boutonGauche ) == LOW )
  {
    // Debounce
    while ( digitalRead( boutonGauche ) == LOW )
    {
      delay( 20 );
    }

    // Si bouton gauche est appuyé, alors vérifier que la position courante est à droite
    // sinon il n'y a rien à faire...
    if ( EEPROM.read( ADR_POS ) == POS_DROITE )
    {
      // Déplacement du moteur
      for ( int i = POS_DROITE; i <= POS_GAUCHE; ++i )
      {
        moteur.write( i );
        delay( 20 );
      }
      // Sauvegarde en EEPROM de la nouvelle position
      EEPROM.write( ADR_POS, POS_GAUCHE );
    }
  }
  else if ( digitalRead( boutonDroit ) == LOW )
  {
    // Debounce
    while ( digitalRead( boutonDroit ) == LOW )
    {
      delay( 20 );
    }

    // Si bouton droit est appuyé, alors vérifier que la position courante est à gauche
    // sinon il n'y a rien à faire...
    if ( EEPROM.read( ADR_POS ) == POS_GAUCHE )
    {
      // Déplacement du moteur
      for ( int i = POS_GAUCHE; i >= POS_DROITE; --i )
      {
        moteur.write( i );
        delay( 20 );
      }
      // Sauvegarde en EEPROM de la nouvelle position
      EEPROM.write( ADR_POS, POS_DROITE );
    }
  }

  // Mise-à-jour des LEDs
  if ( EEPROM.read( ADR_POS ) == POS_GAUCHE )
  {
    digitalWrite( ledVerte, HIGH );
    digitalWrite( ledRouge, LOW );
  }
  else
  {
    digitalWrite( ledVerte, LOW );
    digitalWrite( ledRouge, HIGH );
  }
}

Je ne rentrerai pas dans les détails puisque toutes les parties importantes de ce code ont été vues dans les chapitres précédents. Bien évidemment, il n'y a pas qu'une seule façon de faire... J'ai développé ici un code simple et clair qui permettra au lecteur débutant de comprendre les grandes étapes de ce programme. Quelques mots rapidement:
  • on définit l'adresse mémoire EEPROM à laquelle on désire sauvegarder la position du moteur #define ADR_POS 0. Ici, le premier des 1024 bytes de la zone mémoire
  • dans la fonction setup(), on initialise la valeur à mettre en mémoire EEPROM car la première fois, il y a n'importe quelle valeur... Le test est simple à comprendre: si la valeur lue à cette adresse en EEPROM ne correspond ni à la position gauche ni à la position droite (en fait, il faut lire: "si la valeur actuelle est différente de la position gauche ET la valeur actuelle est différente de la position droite", alors écrit "une valeur par défaut" dans la case mémoire EEPROM d'adresse 0. Ici nous avons fait le choix de la position gauche, mais cela aurait pu être l'autre...
  • dans la fonction loop() principale, il y a deux grandes étapes: d'une part la détection d'un évènement (bouton pressé): si le "bouton pressé ne correspond pas à la position courante du moteur alors actionner le moteur", ce qui basculera les rails dans la bonne position. D'autre part, les LEDs sont mises à jour en fonction de la position courante du moteur.
Voilà, il n'y a rien de bien sorcier dans ce petit programme... Mais si vous avez des questions, n'hésitez pas à me contacter.

Remarque importante: en écrivant ce code, j'ai trouvé un bug dans la librairie Servo.h. En effet, lors de la mise en fonction du moteur en appelant Servo.attach(), la librairie envoie une valeur quelconque au moteur, le faisant se déplacer inopinément. Je n'ai pas pris le temps d'examiner le problème dans le code source de la librairie. Peut-être prochainement...



7.3. Un peu plus de mémoire

La plupart du temps, il y a suffisamment de mémoire EEPROM dans l'Arduino pour y stocker quelques données persistantes. Mais il peut être nécessaire d'ajouter un peu d'espace... Par exemple dans le cas d'un automate, si on veut enregistrer un script pour chaque locomotive, la logique du réseau et quelques informations de signalisation, 1ko devient vite un facteur limitant.

D'où l'intérêt de ce composant: le 24LC256. Il s'agit d'une EEPROM avec laquelle l'Arduino communique grâce au bus I2C. Il existe plusieurs tailles de stockage. Ici, cette version donne accès à 256kbit ce qui est correspond à 32ko.

Le branchement est simple:

www.datasheetdir.com
L'alimentation:
Pin 8: Vcc vers +5V
Pin 4: Vss vers GND

Le bus I2C:
Pin 5: SDA vers A4 (Arduino Uno)
Pin 6: SCL vers A5 (Arduino Uno)

La protection en écriture:
Pin 7: WP vers GND pour autoriser l'écriture ou +5V pour protéger le contenu.

Enfin, l'identificateur:
Pin 1, 2 et 3: A0, A1 et A2. Le triplet de bits permet de former une valeur de 0 à 7 (b000, b001, b010, ... b111). Cette valeur est à ajouter à l'adresse de base 0x50, identifiant une mémoire EEPROM de ce type. Ainsi, dans le programme, on accédera au device I2C avec l'adresse 0x50 + 0 ou 0x50 + 1 ou 0x50 + 2, etc... Cela permet d'ajouter plusieurs puces identiques sur le bus I2C et les différencier par leur adresse respective. Dans le cas présent, on définie ce device par l'identificateur 0, donc les 3 pins A0, A1 et A2 sont connectés au GND.

Maintenant, le code... Tout d'abord, il faut communiquer avec ce device par le bus I2C. Nous allons donc inclure la librairie Wire. Ensuite, nous définissons l'adresse du device 0x50.

  #include <Wire.h>
  #define ADDR 0x50

Deux fonctions bien utiles. Tout d'abord, EEwrite() qui permet d'écrire un octet dans la mémoire EEPROM. Puis la fonction EEread() qui, bien évidemment, permet de lire une valeur... Ainsi:

  void EEwrite( unsigned int address, byte value )
  {
    Wire.beginTransmission( ADDR );
    Wire.write( (unsigned int)( ( address & 0xFF00 ) >> 8 ) );
    Wire.write( (unsigned int)( address & 0x00FF ) );
    Wire.write( (uint8_t)value );
    Wire.endTransmission();
    delay( 3 );
  }

  byte EEread( unsigned int address )
  {
    byte data = 0xFF;
    Wire.beginTransmission( ADDR );
    Wire.write( (unsigned int)( ( address & 0xFF00 ) >> 8 ) );
    Wire.write( (unsigned int)( address & 0x00FF ) );
    Wire.endTransmission();
    Wire.requestFrom( ADDR, 1 );
    data = Wire.read();
    return data;
  }

Le code n'est pas très compliqué. Dans les deux cas, on ouvre un canal de transmission en direction de l'adresse choisie, par un appel à la méthode Wire.beginTransmission( ADDR );

Ensuite, on transmet l'information. Tout d'abord l'adresse de la case mémoire puis la valeur à stocker dans cette case. Cette adresse étant sur 16 bits, il faut envoyer cette valeur en deux octets distincts: d'une part l'octet de poids fort Wire.write( (unsigned int)( ( address & 0xFF00 ) >> 8 ) ); d'autre part l'octet de poids faible Wire.write( (unsigned int)( address & 0x00FF ) );

Quelques mots sur ces deux instructions. 0xFF00 et 0x00FF sont appelés des masques. Ils permettent de masquer (ah ben!) des bits d'une valeur. Ici, nous avons une adresse sur 16 bits. Nous voulons donc envoyer d'abord les 8 bits de gauche ou de poids fort. Le masque correspond à: b1111111100000000 en binaire, ce qui s'écrit aussi 0xFF00 en héxadécimal. De la même façon pour les 8 bits de droite ou de poids faible: b0000000011111111 en binaire, équivalent à 0x00FF en héxadécimal. Enfin, pour appliquer ces masques sur le paramètre address, on lui adjoint l'opérateur binaire ET ou aussi &. Je ne rentrerai pas plus dans les détails, car il nous faudrait aborder beaucoup de concepts informatiques ce qui n'est pas le but de ce blog... Si néanmoins vous avez quelques difficultés à vous représenter ce qui se passe, n'hésitez pas à me contacter...

La dernière instruction de transmission est Wire.write( (uint8_t)value ); qui envoie la valeur à stocker à proprement parler. Ici, pas de masque puisque la valeur est déjà sur 8 bits soit un octet.

Les données effectives ont été transmises, il ne reste plus qu'à clore la transmission: Wire.endTransmission();

Un point important concernant la fonction EEwrite(): l'instruction delay(). Il est nécessaire de faire une pause afin de s'assurer que les données ont bien été écrites dans la mémoire EEPROM... Sinon, au moment de l'envoi d'autres commandes sur le bus I2C, les dernières commandes pourraient être perdues du fait du délais d'écriture dans l'EEPROM. La durée n'est pas précise, et doit être ajustée par l'expérience. Il est facile d'effectuer plusieurs tests d'écriture puis de lecture et vérifier quelle durée est appropriée. Dans le cas du UNO que j'utilise, 3ms semble suffisant.

Passons maintenant à la seconde fonction: EEread(). Elle est très semblable à la première: ouverture de la transmission, envoi de l'adresse de la case mémoire à lire (sur 16 bits ou 2 octets) et clôture de la transmission. Le seul changement se situe au niveau des deux dernières instructions. Afin de lire le résultat, il faut demander à la classe Wire de nous retourner la valeur qui a été lue... Pour cela, on demande à recevoir 1 octet du device identifié par ADDR sur le bus I2C: Wire.requestFrom( ADDR, 1 );. Une fois que cet octet est transmis sur le bus par le device, on le lit en appelant la méthode Wire.read();. Il ne reste plus qu'à retourner cette valeur return data; et le tour est joué.

Voilà, rien de bien sorcier... Pour que tout cela puisse fonctionner, il ne faut pas oublier d'initialiser la classe Wire dans la fonction setup() en appelant: Wire.begin();.

Il ne vous reste plus qu'à écrire vos données vers l'EEPROM et les relire au besoin. Dernier point, cette mémoire est non volatile, donc tant que vous ne les effacez pas, elles seront toujours là au prochain allumage de votre circuit!


1 commentaire:

  1. Bonjour,

    Peux ton rajouter d'autres aiguillages sur le meme programme ?
    Si oui combien et comment ?
    D'avance merci

    RépondreEffacer