jeudi 12 mars 2020

ARDUINO + Ethernet ou ESP32 : comparons



ARDUINO + Ethernet ou ESP32 : comparons


Certaines personnes s'imaginent à tort qu'une carte Ethernet est facile à utiliser et que la librairie Ethernet offre les mêmes facilités que la librairie ESP32 ou ESP8266.

Il n'en est rien. Récupérer par exemple les arguments d'une requête en méthode POST n'est pas du tout à la portée de l'amateur.

Mieux vaut être prévenu avant de s'engager dans une voie sans issue.

1. Les deux approches

Cet article a pour but de comparer deux approches dans l'écriture d'un serveur HTTP.

La première solution utilise une carte ARDUINO NANO + carte Ethernet :
La seconde un module WIFI ESP32 :

2. Méthode GET ou POST

La comparaison porte principalement sur la récupération des données d'un formulaire.
Lorsque l'on valide un formulaire la requête envoyée par le navigateur est formée d'une nouvelle URL suivie du caractères '?' puis d'une suite d'arguments nom=valeur séparés par des caractères '&'.

Imaginons un formulaire permettant de saisir une entrée de répertoire. L'URL pourrait ressembler à ceci :

http://form_contacts?name=dupont&age=25&tel=0102034455&email=dupont@orange.fr

Deux méthode d'envoi existent : GET ou POST.

En méthode GET les arguments suivent l'URL et on les voit dans la barre d'adresse du navigateur lorsque l'on valide le formulaire.
Les arguments font partie de l'URL, qui elle-même fait partie de l'entête HTTP.

En méthode POST les arguments ne sont pas visibles dans la barre d'adresse du navigateur lorsque l'on valide le formulaire.
Les arguments sont envoyés après l'entête HTTP. Un double caractère '\n' signale la fin de l'entête et donc le début des arguments.

3. La version ARDUINO + Ethernet

Le sketch est disponible ici :
ethernet.ino

Le sketch permet d'envoyer le formulaire avec la méthode GET ou POST :

#define FORM                  POST
ou
#define FORM                  GET

Avec la classe EthernetServer il faut lire et décortiquer la requête soi-même pour récupérer les informations :
  • méthode : GET ou POST ou autre
  • URL
  • protocole
  • adresse IP du client
  • arguments
  • etc.
Il faut également associer soi-même URL et fonction de traitement.

Des caractères échappés peuvent apparaître dans une URL:
  • %20 : espace
  • %40 : @
  • etc
Ceux-ci doivent être décodés par nos soins. La classe EthernetServer ne prévoit rien à ce sujet.

Parlons de la méthode POST.
Classiquement il faudrait lire l'entête complète pour pouvoir récupérer les arguments, ce qui réclamerait au minimum 500 à 600 octets de mémoire RAM.
Je me suis débrouillé pour que la requête soit lue partiellement, y compris en méthode POST, en gérant les arguments à part, ceci afin de consommer le moins de mémoire RAM possible :

Les variables globales utilisent 846 octets (41%) de mémoire dynamique, ce qui laisse 1202 octets pour les variables locales. Le maximum est de 2048 octets.

Y a t-il des limitations ?
Oui, étant donné le peu de mémoire disponible il faut limiter la taille de certains buffers :

#define REQUEST_MAX           150    // longueur maximale de la requête HTTP
#define URL_MAX                    100   
// longueur maximale d'une URL
#define ARGSTR_MAX              80     // longueur maximale des arguments
#define ARG_MAX                     5       // nombre maximal d'arguments

Ces valeurs limitent fortement les possibilités de notre application.
On pourra adopter une carte ARDUINO MEGA pour plus de souplesse.

Je n'ai pas utilisé la classe String pour des raisons évidentes de fragmentation :
https://riton-duino.blogspot.com/2020/02/arduino-la-fragmentation-memoire.html

4. La version ESP32

Le sketch est disponible ici :
esp32.ino

Je suis parti de l'exemple HelloServer.

Avec la classe WebServer ESP32 tout ce qui a été fait précédemment dans la version ARDUINO + Ethernet est déjà fait.On pourrait faire la même chose, avec la même facilité, avec un ESP8266 et la classe ESP8266WebServer.

Je n'ai pas utilisé de stockage des pages HTML en SPIFFS, afin de pouvoir comparer les deux solutions sur un pied d'égalité.

5. Mode d'emploi

Après le chargement les deux sketches affichent sur le terminal série l'adresse IP sur laquelle il faut se connecter.

Voici les différentes URL que le serveur accepte :

http://xxx.xxx.xxx.xxx/parser : affiche les arguments de l'URL

Exemple : http://xxx.xxx.xxx.xxx/parser?arg1=1234&arg2=4567

Cette requête affichera les informations suivantes :
  • l'entête HTTP
  • adresse et port local
  • adresse et port distant
  • arguments : arg1=1234 et arg2=4567
http://xxx.xxx.xxx.xxx/form : affiche un formulaire :



Après avoir renseigné les champs (l'adresse mail doit comporter un caractère @) et cliqué sur Send la page suivante affiche les informations du formulaire.

On peut imaginer toutes sortes de traitements :
  • stockage en SD 
  • ajout à une base de données
  • etc.
Le serveur ESP32 propose une page de plus :

http://xxx.xxx.xxx.xxx/sensor-form : affiche un formulaire enrichi :
Ce formulaire comporte un ensemble de cases à cocher, de boutons radios et de champs numériques bornés.

Après avoir renseigné les champs (les champs numériques comportent des valeurs minimales / maximales) et cliqué sur Send la page suivante affiche les informations du formulaire.

Un petit exemple pour apprendre à se servir de différents champs "input".

A remarquer : la technique de formatage d'une page WEB avec sprintf().

Il existe beaucoup de documentation sur le WEB :
https://developer.mozilla.org/fr/docs/Web/HTML/Element/Input

6. Comparaison des deux solutions

La version ARDUINO + Ethernet :
  • 1 journée de travail
  • 386 lignes de code
  • complexité importante
La version ESP32 :
  • 30 minutes de travail
  • 122 lignes de code (hors formulaire enrichi)
  • complexité moyenne
Le but était simple : démontrer la richesse de la classe WebServer ESP32 et sa nette supériorité sur la classe EthernetServer.
Je ne pense pas que qui que ce soit aurait la moindre préférence pour la solution Ethernet.

A moins d'être absolument obligé d'utiliser Ethernet, l'ESP32 ou ESP8266 reste la solution idéale.

6.1. Le DHCP

La librairie Ethernet ne permet pas la modification du HostName par contre celui-ci est composé du mot WIZnet + les 3 derniers caractères de l'adresse Mac. Il y a donc moyen d'avoir un HostName différent par carte.

Côté ESP32 une méthode WiFi.setHostname(name) existe. Malheureusement elle ne fonctionne pas.
Une inspection des paquets DHCP avec tcpdump révèle que le HostName n'est pas présent dans la requête.

Il y a une astuce ou plutôt un contournement à connaître. J'ai trouvé l'information ici :  Hostname not sent via DHCP request #2537

  char hostName[12];
  uint8_t mac[6];
  WiFi.mode(WIFI_STA);
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE); 

  WiFi.macAddress(mac);
  sprintf(hostName, "Esp32%x%x%x", mac[3], mac[4], mac[5]);
  Serial.printf("setting hostname %s: %d\n", hostName, WiFi.setHostname(hostName));
 

  Serial.print("Connecting to "); Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.printf("%s (%s): connected to %s", WiFi.getHostname(), WiFi.macAddress().c_str(), ssid);
  Serial.print("IP address: "); Serial.println(WiFi.localIP());


Le nom attribué est libre. J'ai choisi ESP32 + les 3 derniers caractères de l'adresse Mac, mais c'est juste mon choix.

Accessoirement il est à noter qu'une LiveBox n'affichera ces changements qu'après redémarrage.

Avec tcpdump (sous Linux) il est facile d'inspecter les paquets DHCP :

$ sudo tcpdump -i eno1 -vvv -s 1500 '((port 67 or port 68) and (udp[38:4] = 0x3c71bf47a5b0))'
tcpdump: listening on eno1, link-type EN10MB (Ethernet), capture size 1500 bytes
13:03:41.012470 IP (tos 0x0, ttl 255, id 0, offset 0, flags [none], proto UDP (17), length 336)
    0.0.0.0.bootpc > 255.255.255.255.bootps: [udp sum ok] BOOTP/DHCP, Request from 3c:71:bf:47:a5:b0 (oui Unknown), length 308, xid 0xf7aa125b, Flags [none] (0x0000)
      Client-Ethernet-Address 3c:71:bf:47:a5:b0 (oui Unknown)
      Vendor-rfc1048 Extensions
        Magic Cookie 0x63825363
        DHCP-Message Option 53, length 1: Discover
        MSZ Option 57, length 2: 1500
        Hostname Option 12, length 11: "Esp3247a5b0"
        Parameter-Request Option 55, length 12:
          Subnet-Mask, Default-Gateway, BR, Domain-Name-Server
          Domain-Name, Netbios-Name-Server, Netbios-Node, Netbios-Scope
          Router-Discovery, Static-Route, Classless-Static-Route, Vendor-Option
        END Option 255, length 0
        PAD Option 0, length 0, occurs 33


eno1 est le nom de l'interface Ethernet de mon PC. On voit bien passer le HostName dans la requête.

7. Liens utiles

Librairie Ethernet :
https://github.com/arduino-libraries/Ethernet

Un autre article sur le sujet ARDUINO + Ethernet:
https://riton-duino.blogspot.com/2019/02/un-web-server-sur-ethernet.html
Cet article a été mis à jour dernièrement. Le serveur accepte maintenant les requêtes POST.


Cordialement
Henri

8. Mises à jour

16/03/2020 : ajout d'un formulaire enrichi (ESP32)
06/04/2020 : ajout DHCP et HostName

mardi 10 mars 2020

ARDUINO : PRO MINI 5V ou 3.3V



ARDUINO : PRO MINI 5V ou 3.3V


J'ai déjà écrit un article sur cette carte :

arduino-pro-mini-basse-consommation.html

Ce nouvel article a pour but de présenter les différents modèles de cartes PRO MINI et d'avertir sur certains dangers liés à leur utilisation.

Ce modèle de carte ARDUINO est intéressant à plus d'un titre :

  • encombrement réduit
  • possibilité de basse consommation

Il existe plusieurs modèles :

  • 5V 16MHz
  • 3.3V 8MHz 

J'utilise de plus en plus ces cartes, en lieu et place de cartes NANO ou UNO, car elles offrent les mêmes possibilités dans un encombrement moindre. Le choix de la tension d'alimentation est fait en premier lieu en fonction des modules présents :

  • NRF24L01, RFM69 : 3.3V
  • DS18B20, MCP9808, SHT31D : 3.3V ou 5V
  • HDC2080, BME280 : 3.3V 
  • L293D : 5V
  • etc.

Je sais bien qu'il existe des circuits d'adaptation de niveau permettant d'utiliser un microcontrôleur 5V avec des périphériques 3.3V, mais je préfère m'en passer.

De même, un microcontrôleur 5V peut parfaitement fonctionner avec des périphériques tolérants au 5V sur leur entrées, alimentés sous 3.3V, mais le fonctionnement est fortement dépendant de la valeur exacte de tension d'alimentation 5V du microcontrôleur ou de 3.3V du périphérique.

Pour ces raisons je préfère toujours adopter la même tension d'alimentation pour le microcontrôleur et les périphériques.

Il y a plusieurs méthodes d'alimentation de ces cartes :

  • par la broche RAW
  • par la broche VCC

Un régulateur (MIC5205 en général) est implanté sur la carte et permet d'alimenter celle-ci par la broche RAW sous une tension maximale de 16V.

Ce régulateur est soit un modèle 5V soit un modèle 3.3V.

La broche VCC permet l'alimentation directe en 3.3V ou 5V.

Première remarque : un ATMEGA328P équipé d'un oscillateur 16MHz est incapable de fonctionner sous 3.3V.

Un ATMEGA328P équipé d'un oscillateur 8MHz est capable de fonctionner sous 3.3V, car cette fréquence lui permet d'accepter une tension inférieure.

Dans les deux cas il s'agit exactement du même microcontrôleur.

Deuxième remarque : l'ATMEGA 328P, même s'il est équipé d'un oscillateur 8MHz, peut supporter 5V ou même 6V. Mais le régulateur présent sur une carte 8MHz 3.3V n'appréciera pas je pense, de subir 5V sur sa sortie.

Cela reste faisable néanmoins si l'on supprime le régulateur.

Troisième remarque : la PRO MINI 8MHz et la PRO MINI 16MHz ont une consommation en mode veille assez semblable :

  • PRO MINI 8MHz : 1.5µA
  • PRO MINI 16MHz : 3µA

Cette consommation est obtenue en retirant la LED POWER et le régulateur 3.3V ou 5V de la carte :

Quand on pense basse consommation on pense forcément PRO MINI 3.3V 8MHz, mais la PRO MINI 16MHz n'est pas à exclure. 3µA n'est pas une consommation excessive. Le courant d'auto-décharge d'une batterie est souvent supérieur !

1. Différents modèles

En dehors de cette différence de fréquence et de tension de fonctionnement il en existe d'autres. La carte PRO MINI est assez peu standardisée.

Certaines cartes mettent à disposition ou non les broches A4, A5, A6, et A7 :

PRO MINI 3.3V (A4 + A5) + (A6 + A7) en bout de carte.

PRO MINI 3.3V (A4 + A5 + A6 +A7)

PRO MINI 3.3V (A4 + A5 + A6 +A7) avec strap

La présence ou l'absence de A6 et A7 et l'emplacement de ces broches ne sont pas les seules différences.

Comme on peut le voir sur ces trois images les broches de connexion du convertisseur USB / série en bout de carte sont inversées :

  • DTR TX RX VCC GND GND pour la première
  • BLK GND VCC RX TX GRN pour la seconde et la troisième

Si l'on utilise un connecteur DUPONT 6 points pour le chargement du logiciel il suffira d'inverser le connecteur ou non. GRN est équivalent à DTR.

On constate également sur la troisième carte la présence d'un strap (en haut à droite, à côté de la broche GND) permettant de mettre hors service la partie régulateur 3.3V et LED POWER, afin de gagner quelques précieux mA.

Bien entendu, si l'on désire utiliser A4 et A5 pour une liaison I2C par exemple, il faudra souder un connecteur mâle supplémentaire à deux broches sous la carte.

Par contre cette solution ne sera pas utilisable sur breadboard. Dans ce cas, il faudra placer le connecteur sur le dessus de la carte.

2. Différenciation

Il arrive quelquefois que les vendeurs chinois commettent des erreurs et envoient des PRO MINI 16MHz en lieu et place de PRO MINI 8MHz.

Il m'est même arrivé de recevoir un lot de 10 cartes panachées (4 cartes 16MHz et 6 cartes 8MHz), absolument semblables en apparence.

Sous la carte il y a généralement des cases à cocher :

On constate généralement qu'aucun marquage n'est présent, d'où une confusion possible, et un danger potentiel.

Si la carte est alimentée par sa broche RAW et que des modules sont alimentés par la broche VCC, en fonction du modèle de carte PRO MINI, la tension sur la broche VCC sera de 5V ou 3.3V.

Si les modules alimentés par la broche VCC ne supportent que 3.3V ou 3.6V il y a donc danger si la carte est une PRO MINI 5V.

Il serait dommage de s'apercevoir du problème trop tard et il me semble absolument indispensable de tester ces cartes avant de les utiliser.

Comment les différencier ?

2.1. Le régulateur

On peut tout d'abord les alimenter (en 9V ou 12V par exemple) par leur broche RAW et mesurer la tension sur la broche VCC.

Si elle est de 5V, c'est une carte 16MHz. Si elle est de 3.3V, c'est une carte 8MHz

2.2. L'oscillateur

Certains oscillateurs (le petit composant métallique) possèdent un marquage.

Si ce n'est pas le cas, on peut tester la fréquence d'horloge de la carte.

Il faudra bien entendu d'abord souder le connecteur de chargement 6 points et le relier à un convertisseur USB / série :

arduino-pro-mini-basse-consommation.html : paragraphe 5. Chargement

#define LED 13

void setup() {
  pinMode(LED, OUTPUT);
  Serial.begin(115200);
}

void loop() {
  digitalWrite(LED, HIGH);
  delay(1000);
  digitalWrite(LED, LOW);
  delay(1000);
  Serial.println("OK");
}

Ce petit sketch permet de vérifier qu'une carte est un modèle 8MHz ou 16MHz.

Après avoir choisi "Arduino Pro or Pro Mini" dans le menu Outils, deux choix sont proposés dans le menu Outils / Processeur :

  • ATmega 328P (5V 16MHz)
  • ATmega 328P (3.3V 8MHz)

Après chargement du sketch la LED doit clignoter avec une période de 1s / 1s.

En ouvrant le moniteur série les textes "OK" doivent apparaître.

Si ce n'est pas le cas, recompiler après avoir choisi l'autre option dans le menu Outils / Processeur.

Ensuite il est prudent de marquer ces carte dans les cases appropriées avec un marqueur indélébile, ou de coller une petite étiquette sous la carte.

3. Lien utiles

Apparemment je ne suis pas le seul à avoir écrit un article sur le sujet :

https://bentek.fr/comment-reconnaitre-la-version-dun-arduino-pro-mini/

 

Cordialement

Henri

 

samedi 7 mars 2020

Février 2020 : actualité des Blogs du Mois


Actualité des Blogs du Mois


Sur le blog d'Yves Pelletier :
Sur Arduiblog
Sur  Framboise 314:
Sur  MCHobby:
D'autres blog (merci à Yves Pelletier) :

Cordialement
Henri

dimanche 1 mars 2020

ARDUINO : au fond de la pile


ARDUINO : au fond de la pile


Nous allons parler dans cet article de la gestion de la pile (stack en anglais) d'un ATMEGA328p ou d'un ATMEGA2560.
Ces explications sont parfaitement applicables à d'autres microcontrôleurs. Les informaticiens ne sont pas de grands génies. Il leur arrive rarement de réinventer la poudre ou l'eau tiède.
Certaines différences peuvent exister, par exemple pour un ESP8266, un ESP32, ou STM32, la taille d'une adresse mémoire est simplement différente : 32 bits au lieu de 16.

Le but de cete article est également de fournir des outils de diagnostic de la pile. Il est en effet possible de mesurer à un instant T la place disponible sur la pile avec des outils relativement simples.

1. La pile

Lors de l'exécution d'un logiciel, la pile est utilisée pour empiler les adresses de retour des fonctions (lors du retour d'une fonction il vaut mieux en effet savoir d'où l'appel a été fait), ainsi que les variables locales (appelées aussi variables automatiques).
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 ATMEGA).
Si la fonction appelée déclare une variable locale le pointeur de pile est décrémenté de la taille de la variable. Dans certains cas, le compilateur utilisera un registre, plus rapide.
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 haute de la zone des variables globales. Si le logiciel utilise des objets String ou l'allocation dynamique, les adresses hautes du tas seront corrompues.

2. Les espaces mémoire

Les différents espaces mémoire d'un ATMEGA328p peuvent être représentés ainsi :

Le contenu de ces différentes zones est le suivant :
  • STARTUP : code de démarrage
  • CODE : code de l'application, y compris celui des librairies
  • LOAD_DATA : les valeurs des variables globales et statiques initialisées
  • REGISTERS : registres du microcontrôleur (GPIOs, TIMERS, etc.)
  • DATA : variables globales et statiques initialisées
  • BSS : variables globales et statiques non initialisées
  • STACK : la pile
Les notions de variables globales, statiques, initialisées ou non, sont décrites dans le paragraphe suivant.

Le tas (zone mémoire réservé à l'allocation mémoire dynamique) n'est pas représenté. Il a déjà été décrit dans l'article précédent :
https://riton-duino.blogspot.com/2020/02/arduino-la-fragmentation-memoire.html

Ici nous allons parler de la pile. Le tas, existant ou pas, ne modifie en rien ces explications.

2.1. Les variables

Nous allons commencer par faire la distinction entre les différents types de variables :

int globalData;
int globalInitialized = 1000;

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

void loop()
{
  int localData;
  int localInitialized = 1000;
  static int staticData;
  static int staticInitialized = 1000;

  Serial.print("globalData :"); Serial.println(globalData);
  Serial.print("globalInitialized :"); Serial.println(globalInitialized);
  Serial.print("localData :"); Serial.println(localData);
  Serial.print("localInitialized :"); Serial.println(localInitialized);
  Serial.print("staticData :"); Serial.println(staticData);
  Serial.print("staticInitialised :"); Serial.println(staticInitialized);
  delay(1000);
}


globalData est une variable globale, accessible depuis n'importe quelle fonction. Toutes les variables globales sont initialisées à ZÉRO au démarrage.
Elle aura donc la valeur ZÉRO dès le départ, y compris avant l'entrée dans la fonction setup().

globalInitialized est une autre variable globale, initialisée à la déclaration.
Elle aura la valeur 1000 dès le départ, y compris avant l'entrée dans la fonction setup().

localData est une variable locale, créée sur la pile, accessible seulement depuis la fonction dans laquelle elle est déclarée. Elle a une valeur indéterminée en entrant dans la fonction. Ce n'est pas parce que par chance elle vaut ZÉRO la première fois qu'elle va conserver cette valeur dans le temps.
Une variable locale est détruite en sortant de la fonction.

globalInitialized est une autre variable locale. Elle est initialisée à la valeur 1000 en entrant dans la fonction, et ceci à chaque fois que cette fonction est appelée.

staticData est une variable statique, Elle a les mêmes propriétés qu'une variable globale, sauf qu'elle est accessible uniquement depuis la fonction dans laquelle elle est déclarée.

staticInitialized est une autre variable statique, initialisée à la déclaration. Elle a les mêmes propriétés qu'une variable globale initialisée à la déclaration, sauf qu'elle est accessible uniquement depuis la fonction dans laquelle elle est déclarée.

2.2. Le startup

Ce morceau de code est celui qui est exécuté avant l'entrée dans l'application. Ses différents rôles sont :
  • initialiser la pile
  • initialiser à ZÉRO la zone BSS (variables globales)
  • initialiser la zone DATA (variables globales initialisées)
  • appeler les constructeurs d'objets statiques
  • appeler main()
La zone BSS est initialisée à ZÉRO par une fonction __clear_bss du startup. Comme cette opération est faite automatiquement, il est inutile de le faire manuellement :

int  data = 0;   // initialisation à ZERO inutile

La zone DATA est initialisée par une fonction __do_copy_data du startup.
Cette opération est réalisée par recopie de la zone LOAD_DATA en mémoire FLASH vers la zone DATA en RAM.
La zone DATA et la zone LOAD_DATA ont donc la même taille.

2.3. L'application

Le point d'entrée de l'application s'appelle main(). Voici son contenu dans la librairie standard ARDUINO :

int main(void)
{
    init();
    initVariant();
#if defined(USBCON)
    USBDevice.attach();
#endif
    setup();
    for (;;) {
        loop();
        if (serialEventRun) serialEventRun();
    }
    return 0;
}

On retouve les points d'entrée d'un sketch : setup(), appelé une seule fois, et loop(), appelé en boucle.

2.4. Les variables locales

Comme on l'a vu plus haut les variables locales sont déclarées sur la pile. Lorsque l'on utilise trop de variables locales à travers des appels de fonctions imbriqués (une fonction appelle une autre fonction qui en appelle une autre) il y risque de débordement de pile.

Comment mesurer tout ça et évaluer le risque ?

2.5. La mesure de la pile

Le code suivant comporte quelques fonctions utiles :

#define GLOBAL_SIZE        1211
char global[GLOBAL_SIZE + 1];
int  data = 1000;

extern char *__bss_end;
extern char *__bss_start;
extern char *__data_end;
extern char *__data_start;
extern char *__data_load_start;
extern char *__data_load_end;

uint16_t data_start;
uint16_t data_end;
uint16_t data_load_start;
uint16_t data_load_end;
uint16_t bss_start;
uint16_t bss_end;
uint16_t stack_addr;
int stack_size;

#define fillStack(addr) for (char *p = (char *)&__bss_end+1; p < addr ; p++) *p = 'U';

#define LOCAL_SIZE        100

void __attribute__ ((noinline)) func2(void)
{
  char local[LOCAL_SIZE + 1];
  memset(local, 'C', LOCAL_SIZE);
  local[LOCAL_SIZE] = 0;
  Serial.print(F("func2 STACK       = 0X0")); Serial.println((uint16_t)local, HEX);
}

void __attribute__ ((noinline)) func1(void)
{
  char local[LOCAL_SIZE + 1];
  memset(local, 'B', LOCAL_SIZE);
  local[LOCAL_SIZE] = 0;
  Serial.print(F("func1 STACK       = 0X0")); Serial.println((uint16_t)local, HEX);
  func2();
}

int getFreeStack(void)
{
  for (char *p = (char *)bss_end+1 ; p < RAMEND ; p++) {
    if (*p != 'U') {
      return p - bss_end+1;
    }
  }
  return 0;
}

unsigned char memByteRam(const void* x) {return *(char*)x;}
unsigned char memBytePgm(const void* x) {return pgm_read_byte(x);}

void dump(Print& out, void const*at, int sz, unsigned char (*memByte)(const void*)) {
  while(sz>0) {
    out.print("0x");
    out.print((unsigned long)at < 0x10 ? "000" : (unsigned long)at<0x100 ? "00" : (unsigned long)at<0x1000 ? "0" : "");
    out.print((unsigned long)at,HEX);
    out.print(": ");
    for(int c=0;c<16;c++) {
      if (c==8) out.write(' ');
      if (sz-c>0) {
        // Because ISO C forbids `void*` arithmetic, we have to do some funky casting
        void *memAddress = (void *)((int)at + c);
        unsigned char v = memByte(memAddress);

        out.write(v>=32/*&&v<='z'*/?v:'.');
      } else out.write(' ');
    }
    out.write(' ');
    for (int c=0; c<16 && sz; c++, sz--) {
      // Because ISO C forbids `void*` arithmetic, we have to do some funky casting
      unsigned char v=memByte(at);
      at = (void *)((int)at + 1);

      if (c==8) out.write(' ');
      out.print(v<16?"0":"");
      out.print(v,HEX);
      // out.write(v==0x97?'=':' ');
      out.write(' ');
    }
    out.println();
  }
}

void dumpRam(Print& out, void const*at,int sz) {return dump(out,at,sz,memByteRam);}
void dumpPgm(Print& out, void const*at,int sz) {return dump(out,at,sz,memBytePgm);}

void setup()
{
  char foo = 0xff;
  data_load_start = (uint16_t)&__data_load_start;
  data_load_end = (uint16_t)&__data_load_end;
  data_start = (uint16_t)&__data_start;
  data_end = (uint16_t)&__data_end;
  bss_start = (uint16_t)&__bss_start;
  bss_end = (uint16_t)&__bss_end;
  stack_addr = &foo;
  stack_size = stack_addr - bss_end+1;
  fillStack(stack_addr);
  Serial.begin(115200);
  memset(global, 'A', GLOBAL_SIZE);
  Serial.print(F("global ")); Serial.println(global);
  Serial.print(F("data ")); Serial.println(data);
  Serial.print(F("free_stack        = ")); Serial.println(getFreeStack());
  dumpRam(Serial, bss_end+1, stack_size);
  Serial.print(("fill stack        : 0X0")); Serial.print((bss_end)+1, HEX); Serial.print(F(" - 0X0"));   Serial.println(stack_addr, HEX);
  Serial.print(F("free_stack        = ")); Serial.println(getFreeStack());
  Serial.print(F("RAMEND            = 0X0")); Serial.println(RAMEND, HEX);
  Serial.print(F("TOP OF STACK      = 0X0")); Serial.println(stack_addr, HEX);
  Serial.print(F("STACK POINTER     = 0X0")); Serial.println(SP, HEX);
  Serial.print(F("STACK SIZE        = ")); Serial.println(stack_size);
  Serial.print(F("__data_start      = 0X0")); Serial.println(data_start, HEX);
  Serial.print(F("__data_end        = 0X0")); Serial.println(data_end, HEX);
  Serial.print(F("__data_load_start = 0X0")); Serial.println(data_load_start, HEX);
  Serial.print(F("__data_load_end   = 0X0")); Serial.println(data_load_end, HEX);
  Serial.print(F("__bss_start       = 0X0")); Serial.println(bss_start, HEX);
  Serial.print(F("__bss_end         = 0X0")); Serial.println(bss_end, HEX);
  Serial.print(F("__bss_size        = ")); Serial.println(bss_end - bss_start);
  func1();
  Serial.print(F("free_stack        = ")); Serial.println(getFreeStack());
  dumpRam(Serial, bss_end+1, stack_size);
}

void loop()
{
}


Tout d'abord il y a un certain nombre de variables externes :

extern char *__bss_end;
extern char *__bss_start;
extern char *__data_end;
extern char *__data_start;
extern char *__data_load_start;
extern char *__data_load_end;


Celles-ci sont créées par l'éditeur de liens (le linker en anglais) lors de la fabrication de l'exécutable. Elles représentent les adresses de début et de fin des trois zones BSS, DATA et DATA_LOAD.

uint16_t data_start;
uint16_t data_end;
uint16_t data_load_start;
uint16_t data_load_end;
uint16_t bss_start;
uint16_t bss_end;
uint16_t stack_addr;
int stack_size;


Ces quelques autres variables sont une recopie des précédentes, car il est plus facile de faire des calculs à partir de variables entières.

La macro fillStack() permet de remplir la pile de caractères 'U' (caractère '\x55'). Au fur et à mesure de l'utilisation de la pile d'autres caractères vont venir remplacer ceux-ci. Lorsque l'on désirera mesurer jusqu'à quelle adresse la pile a été utilisée, il suffira de compter le nombre d'octets 'U'.

getFreeStack() retourne le nombre d'octets non utilisés sur la pile. Plus ce nombre sera important plus la pile sera considérée comme étant en bon état de santé.

dump() permet d'afficher le contenu d'une zone mémoire :

0x06AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 

Sur chaque ligne 16 octets sont affichés. L'adresse est affichée, puis 16 caractères en mode texte puis les 16 mêmes caractères en hexadécimal.

Pourquoi les fonctions func1 et func2 ont-elles été déclarées noinline ?
Parce que sinon, le compilateur, comme elles sont appelées une seule fois,  inclurait leur code directement dans la fonction appelante, ce qui n'est pas voulu dans cet exercice.

A l'exécution ce sketch affiche beaucoup d'informations :

global AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
data 1000
### STACK DUMP
free_stack        = 506 
0x06AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06BB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06CB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06DB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06EB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06FB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x070B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x071B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x072B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x073B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x074B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x075B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x076B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x077B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x078B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x079B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07BB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07CB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07DB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07EB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07FB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x080B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x081B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x082B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x083B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x084B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x085B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x086B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x087B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x088B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x089B: UU.⸮.⸮.. >?.B.... 55 55 04 89 01 BC 06 0E  29 2E 08 38 00 01 00 00 
0x08AB: #⸮..⸮... ⸮...@.⸮. 23 A3 00 01 DA 00 00 08  E2 00 01 01 40 08 C1 08 
0x08BB: ⸮...⸮.⸮. ..⸮.⸮.⸮. DF 00 02 00 3E 02 A9 00  00 01 FA 02 A9 04 89 01 
0x08CB: ⸮..'(. . ⸮.⸮..57. BC 06 0E 00 04 01 30 04  89 00 C1 06 0E 21 25 08 
0x08DB: .....#⸮. .⸮.⸮.".. 20 00 01 00 00 23 A3 00  01 DA 08 E6 E6 16 00 0F 
0x08EB: .`.⸮... .......⸮ 03 A3 08 FA 00 00 7F 00  00 00 03 05 11 01 05 B7 
0x08FB: ⸮.                FF 00 
fill stack        : 0X06AB - 0X08FB
free_stack        = 500
RAMEND            = 0X08FF
TOP OF STACK      = 0X08FB
STACK POINTER     = 0X08FA
STACK SIZE        = 594
__data_start      = 0X0100
__data_end        = 0X0138
__data_load_start = 0X0DBE
__data_load_end   = 0X0DF6
__bss_start       = 0X0138
__bss_end         = 0X06AA
__bss_size        = 1394
func1 STACK       = 0X0892
func2 STACK       = 0X0829

### STACK DUMP
free_stack        = 300
0x06AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06BB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06CB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06DB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06EB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x06FB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x070B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x071B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x072B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x073B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x074B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x075B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x076B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x077B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x078B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x079B: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07BB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
0x07CB: UUUUUUUU UU.⸮.⸮.. 55 55 55 55 55 55 55 55  55 55 04 89 01 BC 06 0E 
0x07DB: ...9.... #⸮..⸮... 0B 0C 08 39 00 03 00 00  23 A3 00 01 D9 00 02 08 
0x07EB: ....@.⸮. .....⸮U 1A 00 03 01 40 07 F9 08  17 7F 00 00 00 02 A9 55 
0x07FB: UUUUU.⸮. ⸮....... 55 55 55 55 55 04 89 01  BC 06 0E 0D 0E 01 0A 00 
0x080B: ...#⸮..⸮ .......@ 02 00 00 23 A3 00 01 DB  00 01 01 14 00 02 01 40 
0x081B: ....... .⸮.(.⸮CC 00 03 00 00 7F 00 00 00  02 CD 08 28 03 F2 43 43 
0x082B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x083B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x084B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x085B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x086B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x087B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x088B: CC..⸮..B BBBBBBBB 43 43 00 08 91 04 1E 42  42 42 42 42 42 42 42 42 
0x089B: BB.⸮.⸮.. ?..B.... 42 42 04 89 01 BC 06 0E  2A 2F 08 38 00 01 00 00 
0x08AB: #⸮..⸮... ⸮...@.⸮. 23 A3 00 01 DA 00 00 08  E2 00 01 01 40 08 C1 08 
0x08BB: ⸮...⸮.⸮. ..,.⸮.⸮. DF 00 02 00 3E 02 A9 00  00 01 2C 02 A9 04 89 01 
0x08CB: ⸮..(). . ⸮.⸮..68. BC 06 0E 01 05 01 30 04  89 00 C1 06 0E 22 26 08 
0x08DB: .....#⸮. .⸮.⸮.".. 20 00 01 00 00 23 A3 00  01 D7 08 E6 E6 16 00 0F 
0x08EB: .`.⸮... .......y 03 A3 08 FA 00 00 7F 00  00 00 03 05 11 01 06 79 
0x08FB: ⸮.                FF 00 


Nous allons expliquer tout cela.

Nous allons commencer à la ligne suivante :

fill stack        : 0X06AB - 0X08FB

La pile est emplie de caractères 'U' de 0X06AB à 0X08FB.

RAMEND            = 0X08FF

Il s'agit de l'adresse haute de la mémoire RAM. L'adresse de début (voir plus bas __data_start vaut 0x0100. La quantité de mémoire est donc de :

0x900 - 0x100 : 0x800 = 2048 octets, pile la taille de mémoire RAM de l'ATMEGA328p.

TOP OF STACK      = 0X08FB

Il s'agit de l'adresse de la première variable locale du sketch : foo
Il est normal que l'adresse soit proche de RAMEND, étant donné que le pointeur de pile est presque à son maximum au démarrage.

STACK POINTER     = 0X08FA

Il s'agit de l'adresse stockée dans le pointeur de pile.
Il est normal que sa valeur soit inférieure d'un octet à celle de l'adresse de la variable foo puisque la variable foo a une taille d'un octet. Après le déclaration de foo le pointeur de pile a été décrémenté de 1 octet.

STACK SIZE        = 594

Cette valeur est obtenue en soustrayant l'adresse RAMEND l'adresse de la variable foo. On obtient donc la taille de la pile puisque foo a été déclarée sur la pile.
Attention : le compilateur ordonne les variables sur la pile comme bon lui semble. S'il y avait plusieurs variables locales, leurs emplacements en mémoire seraient forcément voisins mais dans un ordre indéterminé.

__data_start      = 0X0100

Cette adresse est le début de la zone DATA.

__data_end        = 0X0138

Cette adresse est la fin de la zone DATA.

__data_load_start = 0X0DBE

Cette adresse est le début de la zone LOAD_DATA en mémoire FLASH.

__data_load_end   = 0X0DF6

Cette adresse est la fin de la zone LOAD_DATA.

Au démarrage LOAD_DATA sera recopiée dans DATA. Elles doivent donc avoir la même taille.

__data_end - __data_start = __data_load_end - __data_load_start

Est-ce vrai ? Oui, 56 octets dans les deux cas.

A quoi correspondent ces 56 octets ?
Premièrement à notre variable data :

int  data = 1000;

Le reste est occupé par d'autres variables globales ou statiques initialisées à la déclaration, dans la librairie ARDUINO.

__bss_start       = 0X0138

Cette adresse est le début de la zone BSS, les variables globales.

__bss_end         = 0X06AA

Cette adresse est la fin de la zone BSS.

__bss_size        = 1394

Cette valeur représente la quantité de variables globales. En sommes-nous responsables ?

Non, puisque nous avons déclaré :
  • un tableau global de 1211 octets
  • 8 entiers de 2 octets chacun
  • donc un total de 1227 octets
Il y a donc 1394 - 1227 = 167 octets déclarés quelque part dans la librairie ARDUINO.

func1 STACK       = 0X0892

Il s'agit de l'adresse d'une variable locale de 100 octets déclarée dans func1.

func2 STACK       = 0X0829

Il s'agit de l'adresse d'une variable locale de 100 octets déclarée dans func2.

Pourquoi est-elle beaucoup plus basse que la précédente ?
parce que func1 appelle func2 donc les variables locales s'empilent.

Il y a 105 octets de différence, ce qui correpond à la somme de :
  • la taille de la variable locale
  • l'adresse de retour de la fonction
  • quelques registres que le compilateur a décidé d'empiler par comodité
Il nous reste deux blocs marqués ### STACK DUMP à examiner.

Le premier est affiché juste après avoir affiché notre variable data.

### STACK DUMP
free_stack        = 506
0x06AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55  
...
0x089B: UU.⸮.⸮.. >?.B.... 55 55 04 89 01 BC 06 0E  29 2E 08 38 00 01 00 00  

Une quantité de pile de 506 octets n'a pas été utilisée.
On voit plus bas dans le dump qu'à l'adresse 0x089D le 'U' a disparu.

Tout le reste a déjà été exploité par les différents appels de fonctions, en particulier Serial.begin(), Serial.print() et Serial.println() qui ont déclaré et utilisé des variables locales entre cet emplacement et la fin de la mémoire RAM.

On peut donc dire que 0x08FB - 0x089D = 94 octets ont été utilisés depuis le début de l'exécution, ce qui est assez conséquent. Serial.print() est gourmand.

Le deuxième dump est affiché juste après avoir appelé la fonction func1 (qui elle-même appelle func2). On devrait observer les traces des deux fonctions.

### STACK DUMP
free_stack        = 300
0x06AB: UUUUUUUU UUUUUUUU 55 55 55 55 55 55 55 55  55 55 55 55 55 55 55 55 
...
0x07CB: UUUUUUUU UU.⸮.⸮.. 55 55 55 55 55 55 55 55  55 55 04 89 01 BC 06 0E  

Une quantité de pile de 300 octets n'a pas été utilisée.
On voit plus bas dans le dump qu'à l'adresse 0x07D5 le 'U' a disparu.

On voit également les traces des variables locales des fonctions func1 et func2.
func1 a rempli un buffer de caractères 'B'.
func2 a rempli un buffer de caractères 'C'

0x088B: CC..⸮..B BBBBBBBB 43 43 00 08 91 04 1E 42  42 42 42 42 42 42 42 42 
0x089B: BB.⸮.⸮.. ?..B.... 42 42 04 89 01 BC 06 0E  2A 2F 08 38 00 01 00 00 
0x08AB: #⸮..⸮... ⸮...@.⸮. 23 A3 00 01 DA 00 00 08  E2 00 01 01 40 08 C1 08 


0x081B: ....... .⸮.(.⸮CC 00 03 00 00 7F 00 00 00  02 CD 08 28 03 F2 43 43 
0x082B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 
0x083B: CCCCCCCC CCCCCCCC 43 43 43 43 43 43 43 43  43 43 43 43 43 43 43 43 


Et les traces sont encore visibles aux adresses affichées précédemment :

func1 STACK       = 0X0892
func2 STACK       = 0X0829

Pourquoi les traces du travail de func1 ont-elles en partie disparu ?
Parce que depuis la sortie de func1, d'autres fonctions, en particulier Serial.print() et Serial.println() ont déclaré des variables au même endroit.

Après avoir examiné toutes ces données on peut conclure :

STACK SIZE        = 594
free_stack        = 300 au minimum

La pile a été utilisée à environ 50%, ce qui est un signe de parfaite santé pour notre application.

3. Conclusion

Comme on vient de le voir, le compilateur et l'éditeur de liens mettent à notre disposition toutes les variables nécessaire au diagnostic mémoire d'une application.
Il n'y a qu'à les utiliser.


Cordialement
Henri

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