jeudi 27 février 2020

ARDUINO : la fragmentation mémoire




ARDUINO : la fragmentation mémoire


Lorsque l'on utilise des objets de la classe String sur ARDUINO, on voit souvent des commentaires visant à déconseiller son usage. L'utilisation de String pourrait provoquer apparemment une fragmentation de la mémoire.

On constate aussi que certaines personnes qui ont ré-écrit leur code en éliminant les objets String ont résolu leurs problèmes comme par magie :

https://forum.arduino.cc/t/softwareserial-not-reliable/879780/73

Si l'on jette un coup d’œil au début de ce sujet de discussion, le demandeur soupçonne un problème de fiabilité de SoftwareSerial. Ensuite au post #73, après avoir réécrit son code sans utiliser d'objets String, il conclut :

Avoid Strings like the Covid19. Don't let your software control your memory allocations and de-allocations.

Tout d'abord, qu'est ce que la fragmentation mémoire ?

1. La théorie

1.1. L'allocation dynamique

Il faut tout d'abord expliquer ce qu'est l'allocation dynamique de la mémoire. Voici la représentation graphique de la mémoire d'un ARDUINO :


On voit ici une zone appelée Free Memory, entre Stack (pile) et BSS (zone des variables globales).

Cette zone s'appelle Heap (le tas en français). On peut déjà remarquer une chose : si le logiciel comporte un grand nombre de variables globales la taille du tas sera réduite d'autant.

L'allocation dynamique de mémoire revient donc à réserver un bloc mémoire de la taille demandée dans cette zone appelée tas. Le demandeur est responsable de la libération de ce boc quand il sera devenu inutile.

1.2. La fragmentation

La fragmentation mémoire est un phénomène qui crée des trous dans le tas au fur et à mesure des allocations et libérations de blocs de mémoire :


On voit sur cette image que lorsque l'on alloue des objets dynamiquement, au fur et à mesure des allocations et libérations on peut très facilement aboutir à une impossibilité d'allouer de la mémoire pour un objet.

Ici l'objet LargeObj aurait pu être facilement alloué au départ, ou après avoir alloué Obj1, mais l'allocation et libération successives d'objets a créé des trous et ces trous ont une taille insuffisante pour accueillir un objet de taille moyenne : LargeObj.

1.3. La rencontre de la pile et du tas

Lors de l'exécution d'un logiciel, la pile est utilisée pour empiler les adresses de retour des fonctions ainsi que les variables locales.

La pile est gérée grâce à un registre spécial : le pointeur de pile.

Lorsque l'on appelle une fonction l'adresse de retour est placée sur la pile et le pointeur de pile est décrémenté de 2 octets (la taille d'une adresse).

Si la fonction appelée déclare une variable locale le pointeur de pile est décrémenté de la taille de la variable.

Si cette fonction appelle une autre fonction, le pointeur de pile est encore décrémenté, de la même manière.

Si la valeur du pointeur de pile descend trop bas il y a un risque d'aller corrompre les adresses hautes du tas, et cela peut avoir des conséquences imprévisibles.

Quelle est la taille du tas et de la pile ? Ces deux espaces ont environ la même taille. Ils se partagent la mémoire libre à parts égales.

Si aucune allocation dynamique de mémoire n'est utilisée, le tas n'existe pas et donc la pile dispose de toute la mémoire libre. Le risque de crash est réduit.

1.4. String

Pourquoi incriminer la classe String ?

La classe String utilise l'allocation dynamique de mémoire pour le stockage de la chaîne de caractères que l'objet contient.

Car String peut contenir des chaînes de caractères de tailles très diverses et donc, sans allocation dynamique, cela conduirait à fixer une taille maximale, et donc à consommer de la mémoire inutilement.

1.5. Mise en évidence

A la recherche d'un moyen de mettre en évidence la fragmentation de la mémoire je suis tombé sur cet article :

https://cpp4arduino.com/2018/11/06/what-is-heap-fragmentation.html

Je ne vais pas traduire les explications de l'auteur, qui sont grosso modo les mêmes que précédemment, en plus détaillé. Son article a l'avantage de comporter des images, et elles sont suffisamment parlantes.

2. Expérimentons

L'auteur propose un code que l'on peut trouver ici :

https://github.com/bblanchon/cpp4arduino/tree/master/HeapFragmentation

Un tableau de String est instancié statiquement (variable globale) :

String strings[NUMBER_OF_STRINGS];

Ensuite le tableau subit une série d'affectations en boucle infinie :

void loop()
{
  for (String &s : strings) {
    // Replace each string with a new random one
    s = generateRandomString();
  }

  Serial.print(getTotalAvailableMemory());
  Serial.print(' ');
  Serial.print(getLargestAvailableBlock());
  Serial.print(' ');
  Serial.print(getFragmentation());
  Serial.println();
}

La fonction generateRandomString() permet de créer une String de longueur aléatoire.

L'auteur affiche ensuite quelques valeurs :

  • la quantité de mémoire libre
  • la taille du plus grand bloc disponible
  • le pourcentage de fragmentation

Ce code a toutefois un problème : en cas de défaut d'allocation aucune erreur n'est signalée.

J'ai donc modifié la fonction afin d'afficher un message d'erreur :

String generateRandomString()
{
  static int counter = 0;
  String result;

  int len = random(SMALLEST_STRING, LARGEST_STRING);
  for (int i = 0 ; i < len ; i++) {
    result += '?';
    if (result.length() != i+1) {
      Serial.print("count :"); Serial.println(counter);
      Serial.print("wanted :"); Serial.println(len);
      Serial.print("got :"); Serial.println(i);
      Serial.println("TOO BAD");
      while (1);
    }

  }
  counter++;
  return result;
}

Un compteur est également affiché en cas d'erreur, permettant de savoir au bout de combien d'allocations l'erreur est survenue.

La quantité de mémoire désirée ainsi que celle réellement obtenue sont également affichées.

Voici le code complet :

// C++ for Arduino
// What is heap fragmentation?
// https://cpp4arduino.com/

// This program uses several String instances of random size.
// Over time, the Strings produces heap fragmentation.
// The program prints the value of the heap fragmentation.
// You can use the Serial Plotter to see the curve.

#include "MemoryInfo.h"

// Try to change these values and observe the evolution of the fragmentation.
// As you'll see, fragmentation disappears if SMALLEST_STRING == LARGEST_STRING,
// i.e. if the size of the strings is constant.
const size_t NUMBER_OF_STRINGS = 20;
const size_t MAX_MEM_USAGE = getTotalAvailableMemory() * 3 / 4;
const size_t LARGEST_STRING = MAX_MEM_USAGE / NUMBER_OF_STRINGS;
const size_t SMALLEST_STRING = LARGEST_STRING / 5; // varies from 1x to 5x

// The collection of string.
// They allocate and release memory from the heap. We could have called malloc()
// and free() manually but using String is simpler.
String strings[NUMBER_OF_STRINGS];

// At program startup, initialize the serial port.
void setup()
{
  Serial.begin(115200);
  Serial.print("LARGEST_STRING "); Serial.println(LARGEST_STRING);
  Serial.print("SMALLEST_STRING "); Serial.println(SMALLEST_STRING);
  randomSeed(analogRead(0));
}

// At each iteration, get new values for each string and print the value.
void loop()
{
  for (String &s : strings) {
    // Replace each string with a new random one
    s = generateRandomString();
  }

  Serial.print(getTotalAvailableMemory());
  Serial.print(' ');
  Serial.print(getLargestAvailableBlock());
  Serial.print(' ');
  Serial.print(getFragmentation());
  Serial.println();
}

// Generates a string whose length is picked randomly between SMALLEST_STRING
// and LARGEST_STRING
String generateRandomString()
{
  static int counter = 0;
  String result;

  int len = random(SMALLEST_STRING, LARGEST_STRING);
  for (int i = 0 ; i < len ; i++) {
    result += '?';
    if (result.length() != i+1) {
      Serial.print("count :"); Serial.println(counter);
      Serial.print("wanted :"); Serial.println(len);
      Serial.print("got :"); Serial.println(i);
      Serial.println("TOO BAD");
      while (1);
    }
  }
  counter++;
  return result;
}

Les conditions de test sont les suivantes :

  • le tableau contient 20 Strings
  • la mémoire est occupée au maximum à 75%

Il faudra ajouter ces quelques fichiers supplémentaires :

https://github.com/bblanchon/cpp4arduino/blob/master/HeapFragmentation/MemoryInfo.cpp

https://github.com/bblanchon/cpp4arduino/blob/master/HeapFragmentation/MemoryInfo.h

Dans un sous-répertoire Ports il faut ajouter :

3. Les tests

Essai sur ARDUINO NANO :

LARGEST_STRING 61
SMALLEST_STRING 12
834 809 3.00
666 542 18.62
612 481 21.41
588 362 38.44
count :97
wanted :52
got :50
TOO BAD

On rencontre un problème après 97 allocations mémoire.

Essai sur ARDUINO MEGA :

LARGEST_STRING 291
SMALLEST_STRING 58
4043 3963 1.98
3564 3055 14.28
3357 2313 31.10
3085 1777 42.40
2687 1487 44.66
2490 939 62.29
2391 667 72.10
2376 667 71.93
2363 667 71.77
count :195
wanted :286
got :245
TOO BAD

On rencontre un problème après 195 allocations mémoire.

Faisons un deuxième essai sur la MEGA en modifiant NUMBER_OF_STRINGS :

const size_t NUMBER_OF_STRINGS = 100;

LARGEST_STRING 54
SMALLEST_STRING 10
3737 3661 2.03
3291 2818 14.37
2869 2195 23.49
2670 1934 27.57
2424 1666 31.27
2287 1451 36.55
2212 1340 39.42
2141 1340 37.41
2086 1127 45.97
2045 911 55.45
2030 801 60.54
1995 801 59.85
1943 801 58.78
1920 801 58.28
1893 801 57.69
1873 637 65.99
1855 581 68.68
1827 526 71.21
1822 470 74.20
1816 416 77.09
1810 416 77.02
1797 416 76.85
1793 416 76.80
1790 416 76.76
1784 416 76.68
1777 416 76.59
1770 304 82.82
1768 248 85.97
1765 248 85.95
1760 248 85.91
1760 248 85.91
1759 248 85.90
1756 192 89.07
1754 192 89.05
1753 192 89.05
1753 192 89.05
1744 192 88.99
count :3705
wanted :53
got :52
TOO BAD

On rencontre un problème après 3705 allocations mémoire.

On se rend compte assez rapidement que si l'on fait varier les paramètres du test (NUMBER_OF_STRINGS, SMALLEST_STRING) on peut très bien ne rencontrer aucun problème, ou retarder son apparition.

Ce test peut sembler extrême. Allouer 20 strings ayant une taille de 12 à 61 octets sur une NANO n'arrive pas tous les jours.

Vus sous l'angle des variables globales, ce test est tout aussi extrême. A part le tableau de String il n'y en a pratiquement pas.

Or il est tout de même assez rare qu'un code ne comporte pas de variables globales. Le sketch laisse donc un maximum d'espace libre pour l'allocation, ce qui est très favorable.

Dans la vraie vie, cet espace sera forcément plus restreint et donc les problèmes d'allocation seront plus ou moins probables.

Le problème est qu'un logiciel simple a toujours tendance à évoluer et les ajouts successifs de fonctionnalités et de librairies peuvent entraîner à la longue des problèmes incompréhensibles.

4. Constat

La conclusion que l'on peut tirer de cette manipulation est que si l'espace de stockage d'un objet du type String ne peut être alloué, ou ré-alloué pour augmenter sa taille, un problème de manque de mémoire n'est pas détectable, à moins de tester la taille comme je l'ai fait dans la fonction generateRandomString().

Or les méthodes des librairies renvoyant des String ne font pas cette vérification.

Je donnerai ce simple exemple extrait de la librairie ARDUINO :

String Stream::readString()
{
  String ret;  int c = timedRead();
  while (c >= 0)
  {
    ret += (char)c;    c = timedRead();
  }
  return ret;
}

Il est extrêmement clair que si l'espace de stockage de la String ret ne peut être augmenté, le caractère c sera perdu.

Cette méthode Stream::readString() est celle qui est utilisée lorsque l'on appelle Serial.readString() pour lire les caractères sur la ligne série de l'ARDUINO.

Le deuxième constat que l'on peut faire est que si le nombre d'objets String est très réduit, et que leur taille est elle-même faible, il y a une chance sur X pour que l'on soit à l'abri d'un problème.

Mais là encore cela va dépendre fortement de l'espace mémoire disponible. Si le logiciel comporte beaucoup de variables globales, on ne sera pas du tout à l'abri.

Oui, mais combien vaut X ? Quelle est la limite à ne pas franchir ?

C'est une réponse impossible à donner avec certitude. Le logiciel peut très bien fonctionner pendant des heures et planter au bout de quelques jours.

5. Alternative

5.1. Les alternatives faciles

On peut déjà commencer par éliminer certaines utilisations inutiles et je dirais même néfastes de String. On voit beaucoup trop de codes écrits comme ceci :

  Serial.println(String("value: ") + String(10) + String(" volts"));

Cette écriture provoque des allocations mémoire totalement inutiles et elle est très inefficace en terme de temps d'exécution. Autant écrire ceci :

  Serial.print("value: "); Serial.print(10); Serial.println(" volts");

Ce n'est pas plus long à écrire. L'objet Serial possède toutes les méthodes permettant d'afficher une chaîne de caractères, un nombre entier, un nombre flottant, etc. Autant en profiter.

5.2. Reserver la mémoire

La classe String permet de réserver de la mémoire pour un objet. L'allocation des différents blocs de mémoire est donc faite une fois pour toutes au départ.

L'intérêt de cette technique est de conserver le côté pratique des méthodes de la classe String.

Ajoutons ces quelques lignes à la fonction setup() de notre code précédent :

  for (String &s : strings) {
    if (!s.reserve(LARGEST_STRING)) {
      Serial.println(F("Not enough memory for this test"));
      while (true);
    }
  }

Il est bien entendu impératif de tester la valeur retournée de la méthode reserve(). Dans le cas contraire rien n'indiquerait un problème éventuel de mémoire et le fonctionnement du logiciel pourrait poser des problèmes.

Après cette modification, l'exécution du code précédent ne pose plus de problème, mais c'est au prix d'une consommation mémoire maximale.

5.3. Les C strings

5.3.1. Le caractère

On peut difficielement parler de chaîne de caractère sans parler de caractère :

char c;

Cet unique caractère est composé de 8 bits, et il est signé, c'est à dire que sa valeur pourra être comprise entre -128 et 127.

Un caractère peut être imprimable ou non. Consultons une table ASCII :

Il s'agit ici de la tables ASCII standard, sans caractères accentués.

Remarque : Il existe d'autres tables, par exemple latin-1, comportant des codes allant de 128 à 255, dans laquelle nous trouverons des caractères accentués courament utilisés en Europe occidentale.

Dans cette table chaque caractère a un code. Jusqu'à 31 il s'agit de caractères de contrôle (non imprimables) utilisés en général dans les communications. Ensuite entre 32 (espace) et 126 (~) nous trouvons la liste des caractères imprimables.

L'erreur couramment comise par les débutants en C est de considérer qu'un caractère a besoin d'être converti si l'on désire obtenir son code ASCII.

C'est le cas dans beaucoup de langages, PYTHON par exemple.

En C ce n'est pas vrai. Le caractère 'A' est strictement égal à 65 (en décimal) ou 0x41 (en hexadécimal).

Cela implique qu'en C on peut affecter à un caractère une variable de type caractère ou une variable entière :

void setup()
{
  Serial.begin(115200);
  char c = 'A';
  Serial.print("c = "); Serial.println(c);
  c = '\x41';
  Serial.print("c = "); Serial.println(c);
  c = 0x41;
  Serial.print("c = "); Serial.println(c);
  c = 65;
  Serial.print("c = "); Serial.println(c);
  Serial.print("isprint(c) : "); Serial.println(isprint(c));
  c = 6;
  Serial.print("c = "); Serial.println(c);
  Serial.print("isprint(c) : "); Serial.println(isprint(c));
  Serial.print("(int)c = "); Serial.println((int)c);
  Serial.print("c == '\\x41' : "); Serial.println('A' == '\x41');
  Serial.print("c == 0x41 : "); Serial.println('A' == 0x41);
  Serial.print("c == 65 : "); Serial.println('A' == 65);
}

void loop()
{
}

Ce petit sketch affichera :

c = A
c = A
c = A
c = A
isprint(c) : 194
c =
isprint(c) : 0
(int)c = 6
c == '\x41' : 1
c == 0x41 : 1
c == 65 : 1

La fonction isprint() permet de savoir si un caractère est imprimable ou non. Elle retourne un nombre différent de ZERO si oui, et ZERO sinon.

La notation '\x41', couramment utilisée, désigne un caractère ayant le code hexadécimal 0x41.

  c = '\x41';
  c = 0x41;
  c = 65;

Ces trois affectations sont strictement équivalentes.

5.3.2. La chaîne de caractères

Ceci est une C string, c'est à dire une chaîne de caractère C :

  char buf[TAILLE];

Ce n'est ni plus ni moins qu'un tableau de caractères.

Une chaîne de caractères peut contenir des caractères littéraux (imprimables) ou non :

  char buf[] = "AZERTYUIOP\x06";

Cette chaîne se termine par un caractère 6. Pour pouvoir l'insérer dans une chaîne de caractères on le fait précéder d'un caractère d'échappement backslash suivi de x pour indiquer qu'il s'agit d'un code hexadécimal. Cela implique que si l'on veut insérer un backslash dans une chaîne on doit le doubler :

  char buf[] = "AZERTYUIOP\\";

La première chose à savoir est que l'index d'une chaîne de caractères C, comme de n'importe quel tableau, démarre à ZÉRO :

char s[] = "azertyuiop";

s[0] vaut 'a' et s[1] vaut 'z', etc.

La deuxième chose à savoir est qu'une chaîne de caractères C doit être terminée par un caractère nul : '\0' :

char s[] = "azertyuiop";

Dans la déclaration ci-dessus le caractère nul est automatiquement ajouté. Ce n'est pas la peine d'écrire :

char s[] = "azertyuiop\0";

Dans la déclaration suivante la chaîne de caractères comporte 10 caractères, mais elle a une occupation mémoire de 11 caractères :

char s[] = "azertyuiop";

La fonction strlen() permet de connaître sa longueur. La fonction retournera bien 10, le caractère '\0' n'étant pas comptabilisé.

Si la chaîne n'est pas terminée par ZERO la longueur retournée sera forcément fausse car strlen() s'arrêtera sur le premier caractère ZERO rencontré après le début de la chaîne. Il en rencontrera très probablement un, quelque part en mémoire.

  char buf[10];

Dans cette déclaration par contre la chaîne a une longueur de 10 caractères, y compris le terminateur '\0'. Si elle doit contenir 10 caractères effectifs il conviendra d'ajouter 1 à sa taille.

5.3.3. C string vs String

L'inconvénient d'une C string, contrairement à un objet String, est que l'on est obligé de lui donner une taille fixe, alors qu'un objet String est extensible à volonté.

Normalement si l'on conçoit un logiciel devant recevoir des commandes par une ligne série, on est censé connaître la longueur maximale de ces commandes.

5.3.3. C string et librairies

Les librairies acceptent-elles de travailler avec des C strings au lieu de String ? Pas forcément.

Dans le cas de la librairie ARDUINO, c'est majoritairement le cas.

size_t Stream::readBytes(char *buffer, size_t length);

Par exemple la méthode Stream::readBytes() permet de faire le même travail que la méthode Stream::readString().

Elle reçoit en paramètre l'adresse d'une C string et sa taille. Elle renvoie le nombre de caractères reçus.

Avec certaines librairies tierces cependant il est possible que cette alternative ne soit pas proposée. Il faudra donc implémenter nous-même une méthode.

5.3.4. Fonctions

Les fonctions permettant de manipuler les C strings sont nombreuses. La fonction strcmp() utilisée dans les 2 exemples suivants 5.2 et 5.3 permet de comparer deux chaînes.

On peut trouver la liste complète de ces fonctions dans le fichier string.h

On peut ajouter également stdlib.h

De nombreuses documentations et tutoriels existent sur le WEB.

Une petite table de correspondance est bien utile :

Méthode String Description Fonction C string
c_str Exporte une chaîne vers une C string implicite
charAt Recherche un caractère strchr
compareTo Compare deux chaînes strcmp
concat Concatène deux chaînes strcat
endsWith Teste si une chaîne se termine par le contenu d'une autrestrcmp (1)
equalsCompare deux chaînes strcmp
equalsIgnoreCase Compare deux chaînes sans tenir compte de la casse stricmp
strcmp Copie le contenu d'une chaîne dans une autre strcpy
indexOf Recherche la position d'un caractère strchr
lastIndexOf Recherche la position d'un caractère en commençant par la fin de la chaîne strrchr
length Retourne la longueur d'une chaîne strlen
remove Supprime un ou des caractères memcpy (2)
reserve Fixe la taille d'une chaîne implicite
setCharAt Modifie un caractère d'une chaîne str[i] = c;
startsWith Teste si une chaîne commence par le contenu d'une autre strncmp
substring Copie une portion de chaîne dans une autre strncpy
toCharArray Exporte une chaîne vers une C string strcpy
toDouble Exporte une chaîne vers un double float atof
toFloat Exporte une chaîne vers un float atof
toInt Exporte une chaîne vers un integer atoi
toLowerCase Transforme une chaîne en minuscules strlwr
toUpperCase Transforme une chaîne en majuscules strupr
trim Supprime les espaces en début et find de chaîne.

(1)  voici une fonction endsWith :

bool endsWith(const char *s, const char *s2)
{
  return strcmp(s + strlen(s) - strlen(s2), s2) == 0 ? true : false;
}

(2) voici une fonction remove :

void remove(const char *s, int index, int count)
{
  memcpy((void *)s+index, s+index+count, strlen(s)-index-count+1);
}

A cette longue liste on peut ajouter quelques fonctions stdlib ou stdio très utiles, sans équivalent dans String :

strtok : extrait des token (jetons) d'une chaîne de caractères.

void setup() {
  Serial.begin(115200);
  char s[] = "11 33 99";
  char *token;
  token = strtok(s, " ");
  while (token != NULL) {
    Serial.println(token);
    token = strtok(NULL, " ");
  }
}

void loop() {
}

Ce petit sketch affichera :

11
33
99

sscanf : extrait des données d'une chaîne de caractères à l'aide d'une chaîne de format.

void setup() {
  Serial.begin(115200);
  char s[] = "11 33 99";
  int a, b, c;
  sscanf(s, "%d %d %d", &a, &b, &c);
  Serial.println(a);
  Serial.println(b);
  Serial.println(c);
}

void loop() {
}

Ce petit sketch affichera :

11
33
99

5.3.5. Liens utiles

Rien de tel que quelques bons tutoriels pour se familiariser :

openclassrooms

mon-club-elec

electroniqueamateur

5.4. Les dangers

5.4.1. Le danger des variables locales

L'utilisation des C strings n'est pas dépourvue de danger, mais ce danger se situe au niveau de la déclaration des variables locales.

Prenons un exemple :

void loop(void)
{
  char buffer[1000];
// ...

C'est une variable locale à une fonction. Elle va donc être allouée sur la pile.

Imaginons que le logiciel complet, après compilation, consomme 1400 octets de mémoire RAM. L'espace libre restant est de 2048 - 1400 = 648 octets.

Déclarer une chaîne de caractères de 1000 octets sur la pile alors que la mémoire libre a une taille inférieure (648 octets) est donc potentiellement source de problème.

La zone des variables initialisées (DATA), si aucune variable de l'application n'est initialisée à la déclaration, occupe peu de place : 22 octets.

La zone des variables globales BSS démarrerait donc aux alentours de 0x116 et se terminerait en 0x116+1400 = 0x68E.


En effet si au départ le pointeur de pile est initialisé à la valeur RAMEND (0x8FF), après avoir déclaré ce buffer local le pointeur de pile sera décrémenté de 1000 octets. Il vaudra donc 0x8FF - 1000 = 0x517.

Or cette adresse 0x517 se trouve dans la zone des variables globales BSS (0x100 à 0x678).

La moindre utilisation de ce buffer provoquera un ravage des variables globales, et potentiellement un crash.

5.4.2. Le danger de débordement

Lorsque l'on écrit dans une chaîne de caractères, le débordement n'est pas contrôlé. Si l'on écrit en dehors de la chaîne, il y a de fortes chances que le logiciel aille écraser une autre variable, et modifier le comportement du logiciel de manière imprévisible, y compris en provoquant un crash.

#define SIZE 10
char s1[] = "azertyuiopqsdfghjklm";
char s2[SIZE];

Si l'on copie s1 dans s2 à l'aide de strcpy(), comme la taille de s2 est de dix caractères, la partie "qsdfghjklm" sera copié après la chaîne s2.

void setup() {
  Serial.begin(115200);
  strcpy(s2, s1);
  Serial.println(s2);
}

Le sketch n'affiche rien. Cela prouve bien qu'il plante. Si la taille de s2 est de 21 caractères, tout se passe bien.

Il est préférable d'utiliser strncpy().

Mais que dit le manuel ?

"Si la chaîne source a une taille supérieure à celle spécifiée en paramètre, alors la chaîne produite ne sera pas terminée par un code ASCII nul (caractère '\0'). Il sera alors de votre responsabilité de l'ajouter si vous souhaitez exploiter correctement la chaîne produite. En effet, n'oubliez pas qu'en C on manipule des chaînes AZT (A Zéro Terminal)."

Ce qu'il faut faire :

void setup() {
  Serial.begin(115200);
  strncpy(s2, s1, SIZE-1);
  s2[SIZE-1] = '\0';
  Serial.println(s2);
}

Les 9 premier caractères sont copiés, et un ZERO est ajouté dans la dizième case du tableau : s2[SIZE-1], donc s2[9].

Le sketch affichera "azertyuio", c'est à dire 9 caractères.

De la même manière on utilisera de préférence strncat() au lieu de strcat().

5.5. Les pointeurs

Les pointeurs sont la bête noire du débutant. C'est plutôt déroutant. Noua allons en parler.

Un pointeur est une variable contenant l'adresse d'une autre variable.

Dans le cas des chaînes de caractères on peut déclarer un pointeur comme ceci :

char *p;

On peut lui affecter une valeur à la déclaration :

char *p = "qsdfghjklm";

On peut également lui affecter l'adresse d'une chaîne de caractères :

char s[] = "azertyuiop";
char *p = s;

On peut également lui affecter l'adresse d'un caratère dans une chaîne de caractères :

char s[] = "azertyuiop";
char *p = s+5;

p contiendra donc l'adresse du sixième caractère de s (car on part de ZÉRO).

Cette écriture, plus compréhensible pour le débutant, est possible :

char s[] = "azertyuiop"; 
char *p = &s[5];

L'accès aux caractère d'une chaîne se fait comme s'il s'agissait d'une chaîne :

char s[] = "azertyuiop";
char *p = s;

p[0] sera égal à s[0]

Exercice :

void setup() {
  Serial.begin(115200);
  char s[] = "azertyuiop";
  char *p = "qsdfghjklm";
  Serial.print("s="); Serial.println(s);
  Serial.print("p="); Serial.println(p);
  Serial.println("#affectation p=s");
  p = s;
  Serial.print("strcmp(s, p)="); Serial.println(strcmp(s, p));
  Serial.print("p="); Serial.println(p);
  Serial.print("s[0]="); Serial.println(s[0]);
  Serial.print("p[0]="); Serial.println(p[0]);
  Serial.println("#affectation p=s+5");
  p = s+5;
  Serial.print("p="); Serial.println(p);
  Serial.println("#affectation p=&s[5]");
  p = &s[5];
  Serial.print("p="); Serial.println(p);
}

void loop() {
}

Ce petit sketch affichera :

s=azertyuiop
p=qsdfghjklm
#affectation p=s
strcmp(s, p)=0
p=azertyuiop
s[0]=a
p[0]=a
#affectation p=s+5
p=yuiop
#affectation p=&s[5]
p=yuiop

Quels sont les types des paramètre qu'attend une fonction de maniplation de C string de la librairie ?

Quand on voit ceci :

int strcmp(const char *, const char *);
char  *strcpy(char *, const char *);

On pourrait être tenté de croire qu'elles attendent des pointeurs.

Il n'en est rien. On peut leur passer en paramètres une chaîne de caractères ou un pointeur :

char s[] = "azertyuiop";
char *p = s;
p = s;
Serial.print("strcmp(s, p)="); Serial.println(strcmp(s, p));

Ce petit sketch affichera :

strcmp(s, p)=0

0 voulant dire : ZERO différences.

Dernier point concernant une erreur très courante :

char s1[] = "azertyuiop";
char s2[] = "azertyuiop";
Serial.print("s==s2 ? "); Serial.println(s == s2);


char s[] = "azertyuiop";
char s2[] = "qsdfghjklm";
Serial.print("s==s2 ? "); Serial.println(s == s2);

Ces deux codes afficheront :

s==s2 ? 0 

0 voulant dire : non égalité.

En effet l'opérateur == compare simplement les deux adresses, qui sont forcément différentes.

On a l'habitude de comparer deux objets de la classe String avec l'opérateur ==.

Mais l'opérateur  == de la classe String n'est pas l'opérateur == du C. Il a été surchargé (remplacé), et il compare les contenus.

Donc : ATTENTION.

5.6. Economiser la mémoire RAM

Certaines chaînes de caractères constantes ,des messages principalement, peuvent être déclarées en mémoire FLASH, ce qui libèrera de l'espace en mémoire RAM.

Exemple :

  Serial.print("AZERTYUIOPQSDFGHJKLM");

On utilise la macro F() :

  Serial.print(F("AZERTYUIOPQSDFGHJKLM"));

Quelle différence cela fait-il ?

Dans le premier cas :

Les variables globales utilisent 206 octets (2%) de mémoire dynamique, ce qui laisse 7986 octets pour les variables locales. Le maximum est de 8192 octets.

Dans le deuxième cas :

Les variables globales utilisent 184 octets (2%) de mémoire dynamique, ce qui laisse 8008 octets pour les variables locales. Le maximum est de 8192 octets.

Cela fait toute la différence.

6. Un exemple concret

Nous allons prendre comme exemple les méthodes Stream::readString() et Stream::readStringUntil(), souvent utilisées pour lire une chaîne de caractères sur la ligne série de l'ARDUINO.

Les situations dans lesquelles on utilise ces deux méthodes sont nombreuses :

  • communication avec un PC
  • communication avec un autre ARDUINO ou un ESP8266 ou un ESP32 
  • communication avec un module BlueTooth
  • etc.

Les classes HardwareSerial et SoftwareSerial héritent de ces méthodes, ainsi que toutes les classes susceptibles d'hériter de la classe Stream.

Dans la suite de cet article nous allons parler de ces deux méthodes Serial.readString() ou Serial.readStringUntil() car c'est souvent lors de l'utilisation de ces deux méthodes que les problèmes surviennent.

Mais il faut avoir à l'esprit que toutes les méthodes retournant une String sont concernées, quelque soit la librairie.

6.1. L'exemple

L'exemple que nous allons essayer de transformer est le suivant :

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

void loop()
{
  String buf;

  if (Serial.available()) {
    buf = Serial.readStringUntil('\r');
    // le caratère '\n' est éliminé
    buf.trim();
    if (buf.length() > 0) {
      Serial.println(buf);
      if (buf == "bonjour") {
        Serial.println("bienvenue");
      }
      else if (buf == "au revoir") {
        Serial.println("à bientôt");
      }
    }
  }
}

L'exemple répond "bienvenue" s'il reconnait la phrase "bonjour" et "à bientôt" s'il reconnait la phrase "au revoir".

Dans cet exemple un chaîne terminée par '\r' est attendue.

'\r' est le caractère retour charriot (CR) ayant pour valeur 13 ou 0x0D.

Pour nos essais il faudra donc paramétrer le terminal avec "CR" ou "NL et CR" comme caractères de fin de ligne.

'\r' est le caractère terminateur traditionnel d'un terminal, c'est pour cette raison qu'on l'utilise assez souvent, et cela offre l'avantage de pouvoir facilement réaliser des tests avec un terminal.

On voit également que le caractère '\n' (NL ou NewLine) est éliminé grâce à la méthode trim(). En effet si le terminal ARDUINO est paramétré avec "NL et CR" comme caractères de fin de ligne, le caractère NL va nous déranger et produire des lignes vides.

6.2. read()

La première méthode est d'utiliser une lecture caractère par caractère et de les stocker dans un buffer. Il faudra gérer un index :

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

char buf[256];
int i;  // index

void loop()
{
  if (Serial.available()) {
    int c = Serial.read();
    if (c == '\r') {
      Serial.println(buf);
      if (!strcmp(buf, "bonjour")) {
        Serial.println("bienvenue");
      }
      else if (!strcmp(buf, "au revoir")) {
        Serial.println("à bientôt");
      }
      i = 0;
    }

    // ne pas dépasser 255 pour laisser de la place pour le caractère NULL
    else if (i < 254) {
      // le caratère '\n' est ignoré
      if (c != '\n') {
        buf[i++] = c;

        // caratère nul de terminaison
        buf[i] = 0;
      }
    }
  }
}

La lecture se fait caractère par caractère. Chaque caractère reçu (sauf '\n') est ajouté au buffer et l'index i est incrémenté. Un caractère nul est ensuite ajouté.

Si le caractère '\r' est reçu la phrase est comparée à "bonjour" et "au revoir".

6.3. readBytesUntil()

Cette méthode bien pratique permet la lecture d'une chaîne de caractères ayant un caractère terminateur :

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

void loop()
{
  char buf[256];

  if (Serial.available()) {

    // ne pas dépasser 255 pour laisser de la place pour le caractère NULL
    int n = Serial.readBytesUntil('\r', buf, 255);
    // caratère NULL de terminaison
    buf[n] = '\0';
    // le caratère '\n' est ignoré
    if (n == 1 && buf[0] == '\n') {
      return;
    }
    Serial.println(buf);
    if (!strcmp(buf, "bonjour")) {
      Serial.println("bienvenue");
    }
    else if (!strcmp(buf, "au revoir")) {
      Serial.println("à bientôt");
    }
  }
}

readBytesUntil() accepte trois arguments :

  • le caractère terminateur
  • l'adresse du buffer
  • sa taille

L'implémentation de readBytesUntil() garantit que la chaîne devant recevoir les caractères ne contiendra pas plus de caractère que le maximum fixé par le dernier paramètre.

7. Un exemple de librairie tierce

Nous allons maintenant examiner le cas d'une librairie n'offrant pas de méthode alternative.

Imaginons une classe :

class Screen {
 public:
  Screen();
  int read(void);
  String listen(void);
};

La méthode listen n'a pas d'alternative utilisant une C string. Elle est implémentée comme suit :

String Screen:: listen(void)
{
  String buf;
  int c = read();
  while (c >= 0) {
    buf += (char)c;
    c = read();
  }
  return buf;
}

Il nous faut donc implémenter une méthode nous même et l'ajouter à la classe.

Il faut donc la dériver, car il est hors de question de modifier les fichiers de la librairie :

class MyScreen : public Screen
{
 public:
  MyScreen();
  size_t listen(char *buf, int size);
};

Et nous allons implémenter une autre méthode listen :

MyScreen::MyScreen()
{

  // constructeur
}
 

size_t MyScreen:: listen(char *buf, int size)
{
  int i = 0;

  int c = read();
  while (c >= 0) {
    buf[i++] = (char)c;
    buf[i] = '\0';
    c = read();
  }
  return i;
}

Notre classe dérivée est prête à être utilisée.

8. Conclusion

Le but de cet article était de prouver que la fragmentation de la mémoire n'est pas une illusion. Elle existe bel et bien et peut avoir des effets néfastes sur le comportement d'un logiciel.

Il est important bien sûr de se familiariser avec les C strings et un tutorial n'est pas inutile.

On pourra lire :

https://www.arduino.cc/reference/en/language/variables/data-types/string/

https://www.tutorialspoint.com/arduino/arduino_strings.htm

https://www.tutorialspoint.com/c_standard_library/string_h.htm


Cordialement

Henri


9. Mises à jour

28/02/2020 : 5.1. Les C strings
                     5.2. Les pointeurs
                     5.3. Economiser la mémoire RAM
29/02/2020 : 1.3. La rencontre de la pile et du tas
01/03/2020 : 5.1. Réserver la mémoire
                     5.3. Le danger des variables locales

Aucun commentaire:

Enregistrer un commentaire