LOCODUINO

Forum de discussion
Dépôt GIT Locoduino

dimanche 26 février 2017

39 visiteurs en ce moment

Bibliothèque MemoryUsage

Ou comment gérer les fuites.

. Par : Thierry

La mémoire reste une denrée très rare sur les petits Arduino. Elle l’est moins sur les modèles les plus évolués comme le Due ou les Teensy. Pourtant, quelle que soit la plateforme, la gestion de la mémoire doit être prise au sérieux...

Lorsque vous compilez un croquis, l’IDE Arduino, dans sa grande bonté, vous donne quelques chiffres destinés à vous aider à comprendre le résultat de la compilation...

On voit ici que 1884 octets de mémoire programme (dite aussi mémoire flash) ont été utilisés. Cette partie de la mémoire n’est jamais modifiée par le programme lui même. Ça veut dire que vous pouvez la remplir avec du code exécutable jusqu’au dernier octet !
La seconde phrase est plus inquiétante : sur les 2048 octets de mémoire dynamique (aussi appelée SRAM) qui contient les données volatiles pendant l’exécution du programme, il n’en reste que 1866. Les 182 octets sont vraisemblablement utilisés par des variables globales du code compilé, comme des liste d’entiers ou des chaînes de caractères... C’est la partie immuable de la SRAM, remplie d’office par le programme. Ces données globales ne sont pas forcément les vôtres, les bibliothèques en utilisent souvent pour pouvoir fonctionner.

Il reste plein de place alors ?

Si c’était si simple, mais non, cela ne représente que l’un des usages de la mémoire vive. Il y a en réalité trois zones dans cette mémoire :

Comme on peut le deviner, la somme de ces trois zones ne doit pas dépasser le total de mémoire SRAM disponible sur le micro-contrôleur. Le problème vient des zones de taille variable que sont les allocations et la pile d’exécution.

Bombe à fragmentation !

Le mémoire SRAM est un espace linéaire qui se remplit par défaut depuis le début de la zone (adresse 0) tant qu’il y a de la place... Les allocations faites avec des ’new’ ou des appels aux fonctions ’alloc’ du C standard réservent des adresses pour vous. La valeur de retour de ces fonctions est l’adresse du début de zone. Prenons une photo de l’espace mémoire à un moment précis de l’exécution dans une configuration simple (qui a dit simpliste ?) :

La zone des variables globales fait 182 octets de long. Certaines allocations ont déjà été faites par le setup() de votre fichier .ino et ont utilisé 318 octets (tiens, ça fait 500 au total, curieux hasard...). A l’autre bout, la pile (nous verrons plus loin son rôle) qui part toujours de la fin de la mémoire et occupe 48 octets, ce qui laisse 1500 octets disponibles pour continuer l’exécution. Allouons 800 octets :

char *text = new char[800];


Le nouvel espace réservé se place à la suite de ce qui a déjà été alloué.... Si je libère cette mémoire,

delete[] text;

je reviens à l’état précédent, la mémoire disponible retombe à 1500 octets...

Notez qu’un new[] (avec les crochets !) doit être détruit par un delete[] ! Allouons maintenant 500 octets, puis encore 500 octets :

char *text1 = new char[500];
char *text2 = new char[500];


La mémoire disponible est bien descendue à 300. Libérons par un ’delete[]’ la première allocation :

On peut voir qu’un trou s’est formé. Une allocation ne peut jamais être déplacée ! La mémoire disponible est bien de 1000 octets, mais en deux parties de 500 octets chacune. Si je dois allouer à nouveau mes 800 octets, l’allocation échouera. Une zone allouée est toujours d’un seul tenant, depuis une adresse de départ avec une longueur donnée...
La conséquence de ce comportement est que l’usage abusif d’allocations/libérations va tellement fragmenter la mémoire qu’à un moment où plus rien ne pourra être alloué, même si la somme de tous les petits bouts de mémoire libres dépasse ce que l’on veut allouer.
La morale est au maximum de ne pas abuser de cycles d’allocation / désallocation pendant le fonctionnement du programme. Un bon moyen de limiter le risque, c’est de faire toutes les allocations pendant le setup(), et de s’assurer que le loop() n’en fait plus...

La pile a t-elle une fuite ?

Une pile, dans le monde informatique, est une zone de mémoire à partir de laquelle on va empiler des données éventuellement hétérogènes.
Il y a deux types de piles : les FIFO (First In First Out / Premier entré, premier sorti) qui fonctionne comme une file d’attente chez le marchand : le plus ancien élément sort en premier, et les LIFO (Last In First Out / Dernier entré, premier sorti) qui fonctionne comme une pile d’assiette. On reprend toujours la dernière assiette posée, celle du sommet. C’est ce dernier cas qui va nous intéresser.

PNG - 8 ko
File:Lifo.png - Wikimedia Commons

A quoi sert la pile ?

Le principal besoin de mémoire du processeur lui même pendant l’exécution d’un programme se passe dans l’enchaînement des fonctions.

  • Pour le processeur, l’instruction en cours d’exécution est connue par le pointeur d’exécution. Ce pointeur est unique pour un processus, une exécution. Si le processeur veut exécuter une autre commande, il doit déplacer ce pointeur, donc changer sa valeur. Cela signifie que si une fonction est appelée, la valeur courante du pointeur d’exécution doit être stockée quelque part pour que le processeur puisse y revenir lorsque la fonction sera terminée... Et c’est évidemment la pile qui sert de conteneur pour ces mémorisations.

Première conséquence : on voit bien aussi que multiplier les toutes petites fonctions qui s’appellent mutuellement en cascade va nécessiter beaucoup de place de stockage des pointeurs de retour...

  • Dans une fonction, vous avez souvent des variables locales. Ces variables occupent forcément de la mémoire puisqu’elles ont un contenu... Gagné, c’est aussi sur la pile !

Deuxième conséquence : limiter le volume et la taille des variables locales va forcément diminuer le besoin de la pile...

  • Ce n’est pas tout. Lorsqu’une fonction est appelée, des arguments sont souvent présents. Tous les processeurs disposent d’emplacements mémoire spécialisés appelés registres qui sont d’un accès très rapide puisque câblés directement dans le silicium du processeur et utilisés prioritairement. Sur les Atmel/Avr, ces registres reçoivent les arguments passés à la fonction, mais s’ils sont trop nombreux ou de types indéfinis à la compilation, comme pour des listes d’arguments variables (varargs) , c’est encore la pile qui est utilisée.

Troisième conséquence : diminuer la taille des arguments de fonction diminuera aussi la taille de la pile...

Par nature, la taille de la pile dépend du moment où on la mesure. Si vous êtes dans le Setup, hormis les variables internes à cette fonction, la pile sera quasiment vide. Mais si vous vous trouvez au fin fond d’une sous-sous-sous-sous fonction, elle sera bien plus importante, pour redevenir quasi vide dès que vous en serez sorti ! Sa taille n’est écrite nulle part, et le processeur prendra la place dont il a besoin sans vérifier que cela écrase quelque chose...

Pour éviter de collisionner avec les autres utilisateurs de la SRAM, la pile commence de la fin de la SRAM et descend vers le début ! Evidemment, si sa taille devient trop importante, elle risque de chevaucher la zone des allocations, sa gestion va en écraser la fin, et inversement le remplissage de cette zone d’allocation va écraser le contenu de la pile...

Que ce soit le processeur pendant l’exécution, ou le compilateur sur l’ordinateur, personne ne signalera ce problème. Simplement vous aurez des plantages intempestifs si un pointeur d’exécution est écrasé, des fonctionnements erratiques si des variables locales changent de valeur ou le contenu d’une mémoire allouée change brutalement et sans raison ou qu’une allocation échoue faute de place... Aucun outil de contrôle ne vous signalera le problème !

C’est là que SuperBibliothèque MemoryUsage apparaît !

La bibliothèque propose de vous montrer l’état de votre mémoire au moment de la demande. Quelques macros pour afficher les différents pointeurs :

- MEMORY_PRINT_START : le départ de la mémoire dynamique SRAM. Elle peut ne pas commencer à zéro !
- MEMORY_PRINT_HEAPSTART le départ de la zone disponible pour les allocations, immédiatement après les variables globales signalées par le compilateur (les 182 octets cités plus haut...).
- MEMORY_PRINT_HEAPEND la fin actuelle de la zone des allocations.
- MEMORY_PRINT_STACKSTART le début de la pile.
- MEMORY_PRINT_END la fin de la SRAM, qui est aussi la fin de la pile.

- MEMORY_PRINT_HEAPSIZE La taille de la mémoire allouée par des new ou des alloc.
- MEMORY_PRINT_STACKSIZE La taille de la pile à ce moment
- MEMORY_PRINT_FREERAM Le volume de mémoire SRAM encore disponible.
- MEMORY_PRINT_TOTALSIZE La taille mémoire disponible totale

La fonction SRamDisplay() va directement afficher le graphique chiffré de la situation de la mémoire sur la console, mais sans représenter la fragmentation éventuelle de la zone d’allocation.

mu_FreeRam() va donner la taille mémoire disponible au moment de la demande, c’est à dire le delta entre heap_end, la fin de la zone d’allocation et stack_start, le bas de la pile.

La vie de la pile...

Une pile, c’est vivant. Elle change sans cesse, diminue, augmente, puis diminue encore au rythme des appels de fonction. Et un peu comme dans la physique quantique, l’observer change l’observation... Malgré tout comment faire pour tenter de deviner sa taille maximum ? Dans MemoryUsage, deux approches complémentaires, issues de mes recherches sur le Web ont été utilisées.

Le Renoir de la pile ?

La première méthode disponible est subtile. Dès que vous incluez la bibliothèque MemoryUsage, une fonction d’initialisation est utilisée par le processeur avant même le lancement du Setup(), c’est mu_StackPaint(). Cette fonction, que vous ne devez pas appeler, va ’peindre’ (ou plutôt remplir) toute la mémoire disponible à ce moment là avec un caractère particulier. Il suffit ensuite à n’importe quel moment d’appeler la fonction mu_StackCount() pour compter les occurrences de ce caractère pour savoir jusqu’où a été utilisée la pile... C’est un peu comme si on avait peint toute la mémoire en jaune canari, et qu’après usage, on regarde jusqu’où vont les traces de pas des utilisateurs !

Échantillonner...

Le second moyen pour traquer la taille maxi de la pile consiste à la mesurer le plus souvent possible et à en retenir la plus grande valeur. C’est assez laid parce que cela oblige à faire de nombreuses modifications dans son code :

- STACK_DECLARE comme son nom l’indique va créer la variable qui va contenir la plus grande valeur de pile atteinte. Il doit être placé au début du fichier .ino, juste après les include et surtout en dehors de toute fonction :

  1. #include <MemoryUsage.h>
  2.  
  3. STACK_DECLARE
  4.  
  5. void setup()
  6. ....

- STACK_COMPUTE doit être appelé à l’entrée de chaque fonction pour mettre à jour la variable globale au fur et à mesure...

  1. void subFonction()
  2. {
  3. double v[SIZE];
  4. STACK_COMPUTE;
  5.  
  6. .... // fait des choses...
  7. }
  8.  
  9. void loop()
  10. {
  11. subFonction();
  12. }

Lorsque l’on estime que le programme a assez travaillé pour avoir une taille de pile réaliste, par exemple à la fin du setup(), et à chaque pas de boucle de loop(), on peut alors l’afficher sur la console avec STACK_PRINT avec un texte standard suivi de la valeur, ou STACK_PRINT_TEXT si on veut personnaliser ce texte.

Pour finir ce tour d’horizon des méthodes de traque des octets, n’oublions pas comme je l’ai dit plus haut que l’appel à des fonctions de calcul de la pile ou d’affichage va forcément faire grossir la pile et la mémoire programme, ralentir un petit peu l’exécution des fonctions et polluer la console série !

Bibliothèque disponible ici : Forge Locoduino

Contrôler les tailles mémoire reste un travail ingrat, fait d’essais divers, de tentatives plus ou moins fructueuses d’apporter des modifications pour améliorer les choses, et d’affichage de hiéroglyphes et de textes abscons sur la console... Mais quel plaisir de se rendre compte que l’on a gagné deux octets de cette précieuse SRAM !
Dans un article ultérieur, nous parleront des moyens d’améliorer le bilan mémoire de votre programme au vu des statistiques données par MemoryUsage...

Réagissez à « Bibliothèque MemoryUsage »

Qui êtes-vous ?
Votre message

Pour créer des paragraphes, laissez simplement des lignes vides.

Lien hypertexte

(Si votre message se réfère à un article publié sur le Web, ou à une page fournissant plus d’informations, vous pouvez indiquer ci-après le titre de la page et son adresse.)

Rubrique « Bibliothèques »

Bibliothèque Servo

Bibliothèque SoftWare Serial

Bibliothèque Serial

Bibliothèque EEPROM

Bibliothèque Wire : I2C

Bibliothèque LCD

Bibliothèque ScheduleTable

Bibliothèque MemoryUsage

Bibliothèque EEPROMextent

Bibliothèque Commanders

Un décodeur d’accessoires universel (1)

Un décodeur d’accessoires universel (2)

Un décodeur d’accessoires universel (3)

Bibliothèque Accessories (1)

Bibliothèque Accessories (2)

Les derniers articles

Bibliothèque Accessories (2)


Thierry

Bibliothèque Accessories (1)


Thierry

Bibliothèque Commanders


Thierry

Bibliothèque MemoryUsage


Thierry

Bibliothèque Wire : I2C


Dominique, Guillaume

Bibliothèque Serial


Dominique, Guillaume, Jean-Luc

Bibliothèque SoftWare Serial


Dominique, Guillaume

Bibliothèque LCD


Dominique, Guillaume, Jean-Luc

Bibliothèque EEPROMextent


Thierry

Bibliothèque EEPROM


Dominique, Guillaume

Les articles les plus lus

Bibliothèque Wire : I2C

Un décodeur d’accessoires universel (1)

Bibliothèque Servo

Bibliothèque EEPROM

Bibliothèque Serial

Bibliothèque LCD

Bibliothèque Commanders

Bibliothèque ScheduleTable

Bibliothèque Accessories (1)

Bibliothèque Accessories (2)