Gestion dynamique de la mémoire embarquée

Lorsque vous avez un système embarqué avec une quantité de mémoire limitée, vous êtes obligé de réfléchir à deux fois à la façon dont vous allez dépenser votre budget. La solution la plus évidente est d'éviter l'allocation dynamique de la mémoire et de s'occuper entièrement du pool de mémoire statique que vous gérez vous-même.

Mais parfois, vous voulez utiliser un code existant qui utilise le tas (soit via malloc ou new). Dans ce cas, vous devez être conscient des pièges à éviter.

Cet article tente d'expliquer et de donner quelques conseils pour optimiser et minimiser votre impact sur la mémoire. Il est écrit pour les systèmes à mémoire très limitée (ceux qui ont moins ou quelques mégaoctets de mémoire, pas des gigaoctets). J'utilise ESP32 comme plateforme d'exemple, mais les conseils ci-dessous s'appliquent à de nombreuses plateformes différentes.

Heap vs Pile vs stockage fixe

Sur l'architecture actuelle des CPU, il existe trois types de stockage mémoire que vous pouvez utiliser :

  1. le stockage fixe mis en place à la compilation
  2. l'espace de la pile
  3. l'espace de tas

Stockage fixe

Lorsque vous écrivez un logiciel pour un système embarqué, vous devez décrire la disposition de la mémoire du système à l'éditeur de liens afin qu'il place le code exécutable à une adresse spécifique. Mais il doit aussi placer vos variables statiques dans un espace spécifique (beaucoup de développeurs les appellent section .data, mais cela dépend de l'architecture). Habituellement, cette étape est faite pour vous par l'IDE que vous utilisez. Il y a 3 types de données que vous pouvez utiliser de cette façon :

  1. les données en lecture seule (typiquement des constantes, déclarées avec le mot-clé const en C/C++). Cela inclut des chaînes de caractères comme "blue" et beaucoup d'autres tables écrites automatiquement (comme les tables virtuelles en C++, les tables de commutation/de saut de cas et ainsi de suite). Ces données se retrouvent généralement dans des sections de données en lecture seule. Il peut y avoir plusieurs sections que l'éditeur de liens concaténera pour la construction finale du firmware.
  2. les données en lecture-écriture avec initialisation (typiquement, comme char lut[8] = {0, 1, 2, 3, 4, 5, 6, 7}; en C/C++ ou simplement un global int a = 34;). Ces données nécessitent 2 étapes pour être utilisables depuis votre programme. Un espace de stockage doit leur être alloué et un code d'initialisation doit être appelé pour les remplir. Ceci est fait pour vous par l'éditeur de lien qui crée 2 entrées dans la section .data et la section d'initialisation.
  3. des données en lecture-écriture sans initialisation (comme char buffer[1024]; en C/C++). Dans ce cas, l'éditeur de lien réservera de l'espace mémoire dans le layout mémoire pour ces variables, mais n'émettra aucune entrée dans la section data. Au lieu de cela, il créera quelques entrées dans la section .bss pour dire "hey, il y a 1024 octets requis pour cette variable, initialisez-la au démarrage". Il ne consomme pas d'espace binaire, seulement de l'espace RAM et nécessite la coopération du bootloader pour créer/remettre à zéro l'espace avant que le contrôle ne soit donné à votre programme.

En C++, vous trouverez la structure std::array comme le meilleur outil pour gérer de tels tampons. Le plus gros inconvénient de ces tampons est qu'ils ne peuvent pas être redimensionnés (ni plus petits ni plus grands). Ils consomment également de la mémoire même s'ils ne sont pas utilisés. Un tampon dans une fonction obscure qui n'est appelée que lors d'un cas d'erreur très rare mangera votre mémoire et vous ne pourrez pas la récupérer.

Sur ESP32, vous avez de nombreux outils pour les lister, l'un d'eux est idf.py size-components.

Sur un système POSIX, vous pouvez aussi utiliser BloatyMcFace (ou objdump -tT) pour analyser un logiciel pour chaque symbole grignotant de cet espace

Espace de la pile

La pile est une mémoire qui est mise en place avant que le contrôle ne soit donné à votre fonction. Elle est de taille fixe et est utilisée par les fonctions pour stocker leurs variables locales et leur adresse de retour. Vous pouvez allouer dynamiquement de la mémoire avec des fonctions comme alloca (en C) ou StackHeapBuffer (en C++) voir ici, mais vous risquez de faire déborder la zone mémoire (et vous ne savez pas combien de mémoire il reste quand le contrôle arrive à votre code). Une telle utilisation est donc très dangereuse, à moins que vous n'allouiez beaucoup d'espace à la pile.

L'allocation dans la pile est presque gratuite, puisqu'elle implique d'ajouter la taille de votre allocation au pointeur de la pile. La désallocation est effectuée automatiquement par le compilateur lorsque le contrôle quitte la fonction en cours d'exécution (par soustraction du pointeur de pile). Cela signifie donc qu'il n'y a pas de stockage à long terme de vos données ici.

Afin d'estimer l'espace de pile nécessaire, vous pouvez utiliser l'option -fstack-usage du compilateur de GCC pour que le compilateur enregistre l'utilisation de la pile pour chaque fonction. Cependant, c'est pénible à gérer.

Espace de tas

Bien qu'il ne soit pas toujours présent, le tas est un pool de mémoire qui est souvent créé à partir de la mémoire restante après que chaque autre consommateur ait été servi sur la disposition de la mémoire selon ses besoins. Il est géré par un algorithme avec une interface commune :

  1. Allocation de mémoire malloc. Demande un tampon de la taille donnée. Si ce n'est pas possible, il retourne null.
  2. free désallocation de la mémoire. Indique à l'allocateur que ce tampon n'est plus nécessaire. Le tampon retourné doit avoir été alloué avec malloc.
  3. realloc réallocation de mémoire. Demande de changer la taille de la mémoire du tampon donné à une nouvelle taille. Le tampon retourné peut être le même que le tampon précédent, ou un nouveau tampon. Dans ce cas, le contenu du tampon est copié dans le nouveau tampon. Si ce n'est pas possible, elle renvoie null, mais ne désalloue pas le tampon donné.

Le tas est utilisé pour le stockage permanent de la mémoire. Il n'est pas nettoyé à la sortie des fonctions. La gestion de cette mémoire se fait manuellement (en C) ou automatiquement si vous utilisez RAII (resource acquisition is initialization) en C++.

Il y a de multiples inconvénients au heap :

  1. L'algorithme d'allocation et de désallocation est généralement non déterministe (non vrai sur ESP32). Cela signifie que le temps de traitement est aléatoire et que le résultat n'est pas garanti.
  2. Le pool de mémoire de l'algorithme se fragmente avec le temps. Cela signifie que même si vous n'utilisez que 60% de votre espace de tas, il peut y avoir des cas où vous ne pouvez pas allouer plus de mémoire.
  3. Pas de nettoyage automatique des allocations. Il peut y avoir une fuite si votre programme ne retourne pas la mémoire au tas.
  4. L'algorithme lui-même consomme de la mémoire du tas pour gérer les allocations. Ceci n'est pas négligeable sur un petit tas comme quelques kB.

La fragmentation est probablement le plus gros problème car c'est un problème rampant. Par exemple, disons que vous avez 2 objets (A, B) de taille différente et que vous les allouez dans cet ordre :

  1. Allouer A [ AAAA ...... ]
  2. Allouer B [ AAAABBB ..... ]
  3. Allouer A [ AAAABBBAAAA ..... ]
  4. Libérer le premier A [ ....BBBAAAA ..... ]
  5. Alloue B [ BBB.BBBAAAA .... ]
  6. Allouer A, il ne peut pas tenir entre les 2 instances B : [ BBB.BBBAAAAAAA .... ]
  7. L'espace ici entre les deux instances B est gaspillé et n'est pas récupérable tant que les deux instances B ne sont pas désallouées.
  8. Même dans ce cas, vous ne pourrez placer qu'une seule instance A dans l'espace libre, donc vous aurez toujours de l'espace perdu [AAAA...AAAAAAAA .... ]

Comme vous le voyez, si vous avez un objet avec une longue durée de vie, il causera une fragmentation dans le tas. En d'autres termes, cela va toujours augmenter jusqu'à ce que le tas ne puisse plus être alloué. Nous verrons ci-dessous comment résoudre ce problème (ou, au moins, le limiter autant que possible).

Stratégies pour une meilleure utilisation du heap

Limiter l'utilisation du tas

La première stratégie sur un système embarqué est de limiter au maximum l'utilisation du heap. Cela semble évident, mais il y a plusieurs paradigmes de programmation à utiliser pour cela.

Par exemple, en C++ (ou C, mais c'est plus difficile), vous préférerez utiliser des vues plutôt que des objets gérés en mémoire.

Typiquement, essayez d'utiliser std::string_view plutôt que std::string. La première est une chaîne de caractères en lecture seule, et n'alloue pas de mémoire, mais stocke seulement un pointeur sur la tête de la chaîne et la taille de la vue. La seconde copie les données autour, allouant de la mémoire pour stocker la copie. Le pire, c'est que l'allocation est faite avec un facteur de croissance exponentiel (ce qui est logique sur un PC mais pas sur un système embarqué).

Cela signifie que si votre chaîne de caractères fait 257 octets, elle consommera 512 octets de mémoire parce que qui sait, l'utilisateur pourrait en avoir besoin plus tard.

La même chose s'applique à std::vector et aux autres conteneurs. Si vous ne pouvez pas utiliser std::array, alors suivez toutes les utilisations de std::vector::push_back, std::copy et assurez-vous qu'il y a un std::vector::reserve avant pour la nouvelle taille finale attendue. Vous pouvez aussi appeler std::vector::shrink_to_fit après plusieurs opérations d'insertion/effacement de vecteurs pour réduire l'utilisation du tas.

Changez votre bibliothèque standard

Si vous connaissez à l'avance la taille prévue de votre conteneur en C++, vous pourriez être intéressé par la bibliothèque ETL qui est presque un équivalent 1:1 de la STL sans utiliser le tas.

Vous pouvez également utiliser la classe vector qui n'est pas sur-allocatrice comme celle-ci. La contrepartie est de passer plus de temps CPU à faire des allocations et à surcharger l'allocateur de tas lui-même avec beaucoup plus de petites allocations. Comme d'habitude, chaque situation est différente, à vous de voir.

Changer l'allocateur lui-même

En C++, il est presque impossible de changer l'allocateur pour une petite partie du code sans une réécriture majeure du code C++ lui-même. Ceci est dû au fait que l'ABI n'autorise qu'un seul opérateur new global (et non un opérateur new/delete basé sur un namespace). Cependant, il existe quelques moyens de réaliser cette opération, bien qu'ils soient un peu compliqués :

enum class AllocatorMode {
    Default,
    Custom,
};

thread_local AllocatorMode currentMode;

void * operator new(size_t n) {
    switch(currentMode) {
        case Custom: return customAllocator(n);
        case Default: return ::malloc(n);
    }
}

void * operator new[](size_t n) { return operator new(n); }
void * operator delete(void * p) { 
    switch(currentMode) {
        case Custom: return customDeallocator(n);
        case Default: return ::free(n);
    }
}         
void * operator delete[](size_t n) { return operator delete(n); }

void setAllocatorMode(const AllocatorMode mode) { currentMode = mode; }

// Simple RAII class to swap the allocator while the instance exists
struct CustomAllocator
{
    CustomAllocator() { currentMode = Custom; }
    ~CustomAllocator() { currentMode = Default; }
};

Ensuite, dans le code qui doit utiliser le nouvel allocateur, vous ferez ceci :

void someFunc() {
     CustomAllocator custom; // Set the allocator to use until this scope exits
     // call your library
     myLibrary.someMethod();
}

Ici, l'allocateur à utiliser dépend d'une variable locale du thread, donc tant que vous appelez le code de votre bibliothèque dans le même thread, cela fonctionnera.

Pourquoi voulez-vous changer d'allocateur ?

Comme indiqué ci-dessus, lorsqu'il est exécuté en continu, votre programme va se retrouver en situation d'absence de mémoire, en raison de la fragmentation du tas. Je n'ai pas encore vu de solution valable pour ce cas. Toutes les techniques que j'ai expérimentées ou apprises échouent soit parce qu'elles ne sont pas fiables (il y a trop de code utilisant le tas qu'il est presque impossible d'écrire un code 100% fiable dans des conditions sans tas), soit parce qu'elles ne sont pas pratiques (écrire un code 100% sans exception prend deux fois plus d'espace binaire et limiterait l'utilisation de bibliothèques écrites de la même façon, ce qui, à ma connaissance, n'existe pas au niveau du système d'exploitation).

Par exemple, en C++, l'absence de mémoire est exprimée par une exception. Vous ne pouvez pas logguer cette condition puisque l'écriture dans un stream utilise le tas et vous ne pouvez pas déclencher une exception dans le traitement d'une exception (ce qui conduit à std::terminate aussi connu comme game over).

De plus, l'activation de la gestion des exceptions dans le cadre d'un développement embarqué n'est généralement pas conseillée (en raison du gonflement du binaire lié au traitement des exceptions). Si votre bibliothèque utilise du code C, et que ce code C utilise setjmp/longjmp, alors vous n'avez pas de chances de toute façon.

Alors comment résoudre ce problème ?

La solution nucléaire est d'exécuter votre code dans un thread avec son propre allocateur (un allocateur qui gère un pool de mémoire de taille fixe, pas le tas du système). En cas d'événement de perte de mémoire, vous tuerez le thread et réinitialiserez l'allocateur à son état initial, puis redémarrerez le thread. Cela ne fonctionne que si votre code n'a pas d'effets secondaires (comme la gestion d'un état réseau ou d'un périphérique matériel). Dans ce cas, vous devrez enregistrer des gestionnaires qui peuvent être appelés à la fin du thread (et qui n'alloue pas dans le tas).

Si l'effet du redémarrage du thread est trop pénible pour l'utilisateur (comparé au redémarrage du système), vous devrez stocker un état de l'application dans une zone fixe et démarrer à partir de cet état lorsque le thread est lancé.

Article précédent Article suivant

Articles en relation