La mémoire EEPROM 24CSM01 : utilisation avec un Arduino UNO R3.

Posted on dim. 05 janvier 2025 in Electronics/ read-the-datasheet

Introduction

J’ai envie de stocker des données sur une mémoire EEPROM. Pour diverses raisons (coût, taille, package…), mon choix s’est porté sur la puce 24CSM01, produite par Microchip.

Si l’on regarde la première page de la datasheet, on peut voir qu’il s’agit d’une mémoire EEPROM de 1 Mbits — organisée en à peu près 131 000 octets (8 bits) — communiquant avec le protocole I2C.

Comme pour la 47L16, nous allons explorer la fiche technique pour comprendre comment utiliser cette mémoire avec le framework Arduino. Je ferai un second article plus tard pour une utilisation avec une STM32L412 et la HAL ST.

Comme il n’existe pas de bibliothèque Arduino pour cette puce, et que j’ai l’intention d’en créer une pour compenser ce manque, nous pouvons d’ores et déjà commencer à préparer un squelette de programme avec une classe pour gérer notre mémoire.

#include <Arduino.h>
#include <Wire.h>
#include <stdint.h>

class Mem24CSM01 {
  public:
    Mem24CSM01();
    void begin();

  private:

};

Brochage

J’ai pris ce circuit au format 8-pin DIP. Le brochage correspondant est visible en page 1 de la datasheet, et est reproduit ci-dessous.

Brochage de la 24CSM01 au format PDIP8

L’alimentation électrique se fait entre les pins 4 (VSS = GND) et 8 (VCC). Comme pour la 47C16, on retrouve les broches WP (qui permet de bloquer les opérations en écriture), A1 et A2 (qui permettent de personnaliser l’adresse I2C de la puce), SCL et SDA (bus I2C). Connectons tout ceci !

Connection

Nous allons maintenant voir les 3 registres qui s’offrent à nous : configuration, sécurité et mémoire.

Registre de configuration

On retrouve, comme pour la 47L16, un registre de configuration permettant de paramétrer le comportement de la puce. Ce registre permet :

  • de savoir si la dernière opération de lecture a nécessité l’utilisation d’un code de correction d’erreur ;
  • de définir si on utilise une protection en écriture logicielle (les zones protégées sont définies par le registre de configuration) ou matérielle (la protection est définie par l’état du pin WP) ;
  • de préciser quelle(s) sont les zones protégées dans le cas où on choisit une protection logicielle (le 1 Mbit est divisé en 8 zones, chacune pouvant être protégée indépendamment des autres) ;
  • de verrouiller définitivement la configuration de la puce (attention, cette opération est irréversible).

Le registre de configuration est composé de 2×8 bits (2 bytes). En reprenant la notation de la fiche technique, qui numérote les bits de 0 à 15, on a :

  • bit 15 : ECS (en lecture seule), permet de savoir si la dernière lecture a nécessité l’utilisation d’une correction d’erreur ;
  • bits 10 à 14 : inutilisés ;
  • bit 9 : EWPM : active la protection en écriture logicielle (valeur à 1) ou matérielle (valeur à 0) ;
  • bit 8 : LOCK : verrouille (définitivement) la configuration de la mémoire ;
  • bits 0 à 7 : SWPn avec n = [0;7] : si le bit 9 (EWPM) vaut 1, permet de protéger la zone n en écriture (si EWPM = 0, ces bits sont ignorés).

L’accès au registre se fait à l’adresse binaire 1011 A2 A1 X R/W. Ensuite il faut envoyer 16 bits d’adressage, sachant que seuls les bits A15, A11 et A10 sont pris en compte et doivent avoir une valeur spécifique (respectivement 1, 1 et 0). On peut donc par exemple envoyer 1000 1000 0000 0000 pour signifier que l’on souhaite accéder au registre de configuration.

Pour la lecture, il ne doit pas y avoir de STOP après l’envoi des bits d’adressage sinon celle-ci ne se fait pas (voir première Note en page 27 de la datasheet). Également, il faut terminer la séquence de lecture par un STOP pour ne pas « planter » le registre de configuration (voir deuxième Note en page 27 de la datasheet).

Si on veut modifier le contenu du registre, il faut d’abord envoyer les deux bits d’adressage, et ensuite envoyer les 16 bits de configuration d’une traite, puis un byte de confirmation. Ce byte de confirmation est très important. On peut, si on se trompe dans les bits de configuration, verrouiller complètement et définitivement la 24CSM01 (ou a minima verrouiller définitivement le registre de configuration). Pour éviter cette erreur, ce dernier byte ne peut prendre que 2 valeurs 0x99 si on veut verrouiller la configuration, 0x66 pour le laisser déverrouillé (et valider ainsi toute autre valeur).

Allez, on essaie de mettre tout ça en pratique.

Mem24CSM01.h

typedef struct {
  bool errorCorrectionState;
  bool softwareWriteProtect;
  bool configLocked;
  uint8_t zoneProtection;
} MemoryConfig;

class Mem24CSM01 {
  public:
    Mem24CSM01(uint8_t memoryRegister);
    Mem24CSM01(bool A1, bool A2);
    void begin();

    uint16_t getConfiguration();
    bool updateConfiguration(uint8_t confirmLock=0x66);
    bool enableSoftwareWriteProtect();
    bool disableSoftwareWriteProtect();
    bool setWriteProtectionZone(uint8_t zone);
    bool removeWriteProtectionZone(uint8_t zone);
    bool writeProtection(uint8_t zones);

  private:
    uint8_t m_memory_register;
    uint8_t m_configuration_register;
    uint8_t m_security_register;
    MemoryConfig m_configuration;
};

Vous noterez que j’ai créé, en plus de la classe Mem24CSM01, une structure MemoryConfig pour mémoriser les différents éléments de la configuration.

Parmi les méthodes de la classe, nous avons :

  • getConfiguration() pour récupérer la configuration directement depuis le registre ;
  • updateConfiguration() pour envoyer les modifications de configuration à la 24CSM01 (les autres fonctions en-dessous y font automatiquement appel) ;
  • enableSoftwareWriteProtect() et disableSoftwareWriteProtect() pour activer / désactiver la protection en écriture logicielle ;
  • ces deux fonctinos sont associées aux 3 dernières, setWriteProtectionZone() et removeWriteProtectionZone() (qui ciblent une zone unique spécifiée par l’utilisateur) ainsi que writeProtection() (qui permet de modifier tout le registre d’un coup).

Voyons maintenant les détails de l’implémentation.

Mem24CSM01.cpp

#include "Mem24CSM01.h"

Mem24CSM01::Mem24CSM01(uint8_t memoryRegister) {
  m_memory_register = memoryRegister;
  m_configuration_register = m_memory_register & ~(1<<4);
}

Mem24CSM01::Mem24CSM01(bool A1, bool A2) {
  m_memory_register = BASE_MEMREG_ADDR | (A2<<3) | (A1<<2);
  m_configuration_register = BASE_CFGREG_ADDR | (A2<<3) | (A1<<2); 
}

void Mem24CSM01::begin() {
  Wire.begin();
}

uint16_t Mem24CSM01::getConfiguration() {
  uint16_t result;
  uint8_t low, high;
  Wire.beginTransmission(m_configuration_register);
  Wire.write(CFGREG_WRD_ADDRH);
  Wire.write(CFGREG_WRD_ADDRL);
  Wire.endTransmission(false);
  Wire.requestFrom(m_configuration_register, 2, true);
  high = Wire.read();
  low = Wire.read();
  result = (high<<8) | low;
  m_configuration.zoneProtection = low;
  m_configuration.configLocked = high & 1;
  m_configuration.softwareWriteProtect = (high>>1) & 1;
  m_configuration.errorCorrectionState = (high>>7) & 1;
  return(result);
}

bool Mem24CSM01::updateConfiguration(uint8_t confirmLock=0x66) {
  // Preparing the config bytes
  uint8_t cfgHighByte = 0 | (m_configuration.softwareWriteProtect<<1) | m_configuration.configLocked;
  uint8_t cfgLowByte = m_configuration.zoneProtection;
  Serial.println(cfgLowByte, BIN);
  Wire.beginTransmission(m_configuration_register);
  Wire.write(CFGREG_WRD_ADDRH);
  Wire.write(CFGREG_WRD_ADDRL);
  Wire.write(cfgHighByte);
  Wire.write(cfgLowByte);
  Wire.write(confirmLock);
  Wire.endTransmission();
}

bool Mem24CSM01::enableSoftwareWriteProtect() {
  m_configuration.softwareWriteProtect = 1;
  updateConfiguration();
}
bool Mem24CSM01::disableSoftwareWriteProtect() {
  m_configuration.softwareWriteProtect = 0;
  updateConfiguration();
}

bool Mem24CSM01::setWriteProtectionZone(uint8_t zone) {
  if(zone >=0 && zone <=7) {
    m_configuration.zoneProtection = bitSet(m_configuration.zoneProtection, zone);
    updateConfiguration();
  }
}

bool Mem24CSM01::setWriteProtection(uint8_t zones) {
  m_configuration.zoneProtection = zones;
  updateConfiguration();
}

bool Mem24CSM01::removeWriteProtectionZone(uint8_t zone) {
  if(zone >= 0 && zone <= 7) {
    m_configuration.zoneProtection = bitClear(m_configuration.zoneProtection, zone);
    updateConfiguration();
  }
}

Enfin, voyons l’utilisation.

memory.ino

#include "Mem24CSM01"

void setup() {
  Serial.begin(9600);
  Mem24CSM01 memchip(false, false);
  memchip.begin();

  // Récupérer et afficher la configuration
  uint16_t config = 0;
  config = memchip.getConfiguration();
  Serial.print("Config reg = ");
  Serial.println(config, BIN);

  // Activer la protection en écriture sur la zone 3
  memchip.enableSoftwareWriteProtect();
  memchip.setWriteProtectionZone(3);
  Serial.print("Retrieving confreg value: ");
  Serial.println(memchip.getConfiguration(), BIN);
}

Registre de sécurité

Je n’ai pour le moment pas d’usage prévu pour ce registre, donc je ne vais pas m’étendre dessus. Il fournit toutefois un userspace de 256 bytes utilisables en lecture et écriture, pour y stocker les informations que l’on souhaite. Ce registre peut être verrouillé (l’opération est définitive), ce qui signifie qu’il ne sera plus possible d’écrire dans l’userspace après cette opération.

L’adresse d’accès à ce registre est la même que pour le registre de configuration (1011 A2 A1 X R/W), la différence se fait sur les Word address bytes. Pour accéder au contenu du registre de configuration, cette adresse devait être de la forme 1xxx 10xx + xxxxx xxxxx. Pour le contenu du registre de sécurité, cette adresse doit être 0xxx 10x A8 puis les 7 derniers bits d’adressage (A7 à A0). Le fonctionnement est du reste très similaire à celui de la mémoire.

Mémoire

Accès

Les adresses sont de la forme 0b1010 A2 A1 A16 R/W. On peut donc déduire l’adresse du registre de config à partir de celle du registre mémoire (voir code d’exemple ci-dessous) et vice-versa, si besoin.

int memory = 0b1011 1100;
int config = memory & ~(1<<4);

Dans l’adresse ci-dessus, A16 correspond au bit le plus sigificatif du pointeur mémoire (l’endroit où on va lire/écrire nos données). Il faut bien penser à le modifier en fonction de ses besoins d’accès en mémoire. Cela signifie également que, d’un point de vue du bus I2C, la mémoire de la 24CSM01 est partitionnée / répartie sur deux périphériques différents. La 24CSM01 « mange » donc la place de deux périphériques sur le bus (attention aux adresses des autres périphériques que vous choisissez, si jamais…).

Division en zones

On a 8 zones mémoires que l’on peut protéger avec la fonction Enhanced Software Write Protect. On peut donc les individualiser dans le code pour un accès type MEMZONE3 + 100 pour accéder au 100ème byte de la zone 3 (ce qui correspond au byte 0x0C12C).

#define MEMZONE0 0
#define MEMZONE1 0x04000
#define MEMZONE2 0x08000
#define MEMZONE3 0x0C000
#define MEMZONE4 0x10000
#define MEMZONE5 0x14000
#define MEMZONE6 0x18000
#define MEMZONE7 0x1C000

Opérations d’écriture

La section 6 de la datasheet nous apprend que la 24CSM01 supporte l’écriture byte par byte, ainsi que page par page. Une page est une succession arbitraire de bytes, dont le nombre doit toutefois être inférieur ou égal à 256.

La différence entre les deux, d’un point de vue opérationnel, vient du fait que l’hôte du bus I2C (notre Arduino) n’envoie pas de STOP après le premier byte, mais continue d’envoyer des données. La 24CSM01 continue à les « ingérer » jusqu’à ce que l’hôte envoie un STOP, après quoi elle réalisera l’opération d’écriture (sous réserve de respecter quelques conditions, que nous verrons dans la partie dédiée).

Écriture d’un byte

Pour écrire un byte, il faut respecter la séquence suivante (explicitée par la figure 6-1 page 14) :

  • l’hôte envoie un START, puis l’adresse de la mémoire ;
  • l’hôte transmet ensuite les 2 bytes finissant l’encodage de l’adresse à laquelle on veut écrire ;
  • l’hôte envoie le byte à écrire, suivi d’un STOP.

Écriture multi-bytes

Possibilité d’écrire jusqu’à 256 bytes à la suite, sous réserve que ces bytes appartiennent à la même « page physique », c’est à dire que les bytes A16 à A8 doivent rester constants (page 15 de la datasheet). Si on dépasse cette page physique, la 24CSM01 revient au début de la page (si on est au byte 1111 1111, une écriture supplémentaire fera revenir à 0000 0000) et écrit les données par-dessus les anciennes.

Mise en pratique

Mem24CSM01.h

// Snip

#define MEMZONE0 0
#define MEMZONE1 0x04000
#define MEMZONE2 0x08000
#define MEMZONE3 0x0C000
#define MEMZONE4 0x10000
#define MEMZONE5 0x14000
#define MEMZONE6 0x18000
#define MEMZONE7 0x1C000

#define MAX_MEMORY_POINTER 0x1FFFF

typedef enum {
  OK,
  ADDR_TOO_LARGE,
  BUFFER_TOO_LARGE,
  NEGATIVE_ADDR,
  NOT_ON_SINGLE_PAGE,
  ADDRESS_ERROR,
  DATA_ERROR,
  TIMEOUT,
  GENERIC_ERROR,
} MEMORYRESULT;

typedef struct {
  uint8_t deviceMemoryAddress;
  uint8_t memoryMSB;
  uint8_t memoryLSB;
} MemoryPointer;

class Mem24CSM01 {
  public:
    // Snip
    MEMORYRESULT write(uint32_t address, uint8_t singleByte);
    MEMORYRESULT write(uint32_t address, uint8_t* dataArray, size_t arraySize);

  private:
    // Snip
    MemoryPointer setMemoryAddress(uint32_t address);
    MEMORYRESULT processTransmissionResult(int transmissionResult);
    uint8_t addressMemoryPointer(uint32_t address);
};

Mem24CSM01.cpp

MemoryPointer Mem24CSM01::setMemoryAddress(uint32_t address) {
  MemoryPointer memoryAddress;
  uint8_t highAddrBit = (address>>17) & 1;
  memoryAddress.deviceMemoryAddress = m_memory_register | (highAddrBit<<1);
  memoryAddress.memoryMSB = (address>>8) & 0xFF;
  memoryAddress.memoryLSB = address & 0xFF;
  return(memoryAddress);
}

MEMORYRESULT Mem24CSM01::processTransmissionResult(int transmissionResult) {
  switch (transmissionResult) {
  case 0:
    return(MEMORYRESULT::OK);
    break;
  case 2:
    return(MEMORYRESULT::ADDRESS_ERROR);
    break;
  case 3:
    return(MEMORYRESULT::DATA_ERROR);
    break;
  case 5:
    return(MEMORYRESULT::TIMEOUT);
    break;
  default:
    return(MEMORYRESULT::GENERIC_ERROR);
    break;
  }
}

uint8_t Mem24CSM01::addressMemoryPointer(uint32_t address) {
  MemoryPointer memoryAddress = setMemoryAddress(address);
  Wire.beginTransmission(memoryAddress.deviceMemoryAddress);
  Wire.write(memoryAddress.memoryMSB);
  Wire.write(memoryAddress.memoryLSB);
  return(memoryAddress.deviceMemoryAddress);
}

MEMORYRESULT Mem24CSM01::write(uint32_t address, uint8_t* dataArray, size_t arraySize) {
  if(address > MAX_MEMORY_POINTER) { return(MEMORYRESULT::ADDR_TOO_LARGE); }
  if(address < 0) { return(MEMORYRESULT::NEGATIVE_ADDR); }
  if(arraySize>256) {return(MEMORYRESULT::BUFFER_TOO_LARGE);}
  if((address + arraySize - 1) > 0xFF) {return(MEMORYRESULT::NOT_ON_SINGLE_PAGE);}
  MemoryPointer memoryAddress = setMemoryAddress(address);
  Wire.beginTransmission(memoryAddress.deviceMemoryAddress);
  Wire.write(memoryAddress.memoryMSB);
  Wire.write(memoryAddress.memoryLSB);
  for(int i=0; i<arraySize; ++i) {
    Wire.write(dataArray[i]);
  }
  int transmissionResult = Wire.endTransmission();
  return(processTransmissionResult(transmissionResult));
}

MEMORYRESULT Mem24CSM01::write(uint32_t address, uint8_t singleByte) {
  return(write(address, &singleByte, 1));
}

Comme la gestion de l’adresse mémoire est un peu complexe (le bit le plus élevé est intégré à l’adresse de la puce sur le bus I2C), je préfère le gérer avec une structure et une fonction dédiée. Il y a également une fonction pour adapter le MEMORYRESULT au résultat renvoyé par la fonction endTransmission(), ainsi qu’une dernière (addressMemoryPointer) qui initie la transmission sur le bus I2C (la séquence est toujours la même que l’on souhaite lire ou écrire des données). Comme ces fonctions n’ont pas lieu d’être appelées par l’utilisateur, je les met dans l’espace private.

Vous noterez que la fonction pour écrire un byte unique fait appel à la fonction d’écriture multiple selon le principe du « qui peut le plus peut le moins ». De cette façon, on évite des répétitions dans le code, et en cas de besoin, on a juste à modifier une fonction pour que les effets en soient répercutés sur l’autre.

Pour l’utilisation, c’est assez simple.

memory.ino

#include "Mem24CSM01.h"

void setup() {
  Serial.begin(9600);
  Mem24CSM01 memchip(false, false);
  memchip.begin();

  // Writing single byte
  memchip.write(18, 45);

  // Writing 4 bytes, at positions 19, 20, 21, 22
  uint8_t bytesToWrite[4] = {5, 10, 15, 20};
  memchip.write(19, bytesToWrite, sizeof(bytesToWrite));
}

En théorie, ces opérations ont du fonctionner. Toutefois, pour le vérifier, nous devrions peut-être voir comment lire les données !

Opérations de lecture

Le principe des opérations de lecture est globalement identique à ceux de la 47C16, à savoir qu’on dispose de possibilités de lire 1 byte à la fois, une page (quantité arbitraire de bytes pouvant aller jusqu’à 256), ainsi que de lire le byte au pointeur mémoire enregistré dans la puce. Ce pointeur mémoire est mis à jour automatiquement, en interne, nous n’avons pas à nous soucier de sa valeur.

Lecture de la dernière position

La 24CSM01 tient à jour un pointeur interne dirigeant vers le byte juste après le dernier accédé (page 19) : si on a écrit au byte 14, ce pointeur dirigera sur le byte 15. Si l’on envoie une instruction de lecture simple sans fournir d’adresse, la 24CSM01 nous renverra le contenu de ce fameux byte.

Lecture « aléatoire »

Par lecture aléatoire, il faut entendre qu’on peut accéder à n’importe quelle portion de la mémoire sans passer par les bytes précédents. Ce type de lecture peut se faire soit byte par byte, soit par séquences de bytes. On envoie dans un premier temps l’adresse mémoire de la puce, puis les 2 Word Address Bytes, et on peut alors lire les données. L’hôte renvoie un NACK quand il n’a plus besoin que le périphérique lui envoie les données. Si on renvoie un NACK après le premier byte reçu, on aura réalisé une lecture d’un seul byte. Sinon, le périphérique continue d’envoyer des données.

Mem24CSM01.h

// Snip

class Mem24CSM01 {
  public:
    // Snip
    uint8_t read(); // Read at current adress pointer
    uint8_t read(uint32_t address); // Random read
    MEMORYRESULT read(uint32_t address, uint8_t* buffer, size_t size);


  private:
    // Snip

};

Mem24CSM01.cpp

uint8_t Mem24CSM01::read() {  // Read at current adress pointer
  Wire.beginTransmission(m_memory_register);
  Wire.endTransmission();
  Wire.requestFrom(m_memory_register, 1);
  return(Wire.read());
}

uint8_t Mem24CSM01::read(uint32_t address) {// Random read {
  uint8_t result = 0;
  read(address, &result, 1);
  return(result);
} 

MEMORYRESULT Mem24CSM01::read(uint32_t address, uint8_t* buffer, size_t bufferSize) {
  if(address > MAX_MEMORY_POINTER) { return(MEMORYRESULT::ADDR_TOO_LARGE); }
  if(address < 0) { return(MEMORYRESULT::NEGATIVE_ADDR); }
  if(bufferSize>256) {return(MEMORYRESULT::BUFFER_TOO_LARGE);}
  uint8_t deviceAddress = addressMemoryPointer(address);
  Wire.endTransmission();
  Wire.requestFrom(deviceAddress, bufferSize);
  size_t i=0;
  while(Wire.available()) {
    buffer[i] = Wire.read();
    Serial.println(buffer[i]);
    ++i;
  }
  return(MEMORYRESULT::OK);
}

Comme pour l’écriture, on délègue la gestion de l’ensemble adresse périphérique + adresse mémoire, ainsi que le démarrage de la transmission, à la fonction addressMemoryPointer. Puis on utilise Wire.requestFrom pour récupérer les données. Également, et comme pour les opérations d’écriture, on part sur le principe du « qui peut le plus peut le moins » : on définit d’abord la fonction pour lire une succession de bytes, et on la réutilise pour une lecture d’un seul byte.

Pour le moment, la fonction de lecture à l’adresse actuelle est redéfinie « de zéro ». J’essaierai de trouver plus tard une manière de la définir à partir de la fonction de lecture multi-bytes.

memory.ino

#include "Mem24CSM01.h"

void setup() {
  Serial.begin(9600);
  Mem24CSM01 memchip(false, false);
  memchip.begin();

  // Writing single byte
  memchip.write(18, 45);

  // Writing 4 bytes, at positions 19, 20, 21, 22
  uint8_t bytesToWrite[4] = {5, 10, 15, 20};
  memchip.write(19, bytesToWrite, sizeof(bytesToWrite));

  uint8_t byteRead = 0;
  byteRead = memchip.read(18);
  uint8_t multibyteRead[4] = 0;
  memchip.read(19, multibyteRead, sizeof(multibyteRead));

  Serial.print("Single byte read = ");
  Serial.println(byteRead);

  Serial.println("Multiple bytes read:");
  for(int i=0; i<4; ++i) {
    Serial.print("Byte ");
    Serial.print(i);
    Serial.print(" = ");
    Serial.println(multibyteRead[i]);
  }
}

Conclusion

Nous avons vu au travers de ce post – et grâce à la lecture de la datasheet – comment utiliser la puce mémoire EEPROM 24CSM01 de Microchip.

Pour simplifier l’utilisation de ce composant avec Arduino, le code que j’ai détaillé dans ce post est intégré à la bibliothèque Mem24CSM01, qui a été intégrée au registre des bibliothèques pour l’IDE Arduino ce vendredi 10/01/2025. Elle devrait donc apparaître dans le gestionnaire de bibliothèque de l’IDE sous quelques jours !

Bibliothèque Arduino 24CSM01 acceptée