Aller au contenu

Utilisation de la mémoire dynamique dans un programme

L’allocation mémoire dynamique

L’allocation dynamique de la mémoire est un outil puissant, mais dangereux dans les systèmes embarqués. L’allocation dynamique est utilisée à l’exécution du code (runtime) alors que l’allocation statique est gérée à la compilation (compiletime).

Prenons le cas d’un tableau de données (array), il n’est pas toujours possible d’en connaitre la dimension avant l’exécution. Une des solutions consiste à pré-réserver en mémoire une zone mémoire d’une taille aussi grande que possible quitte à ne pas l’utiliser. Cette méthode n’est pas optimale et nécessite de la mémoire excédentaire non utilisée.

Ceci peut être optimisé par l’utilisation de l’allocation dynamique à l’exécution du code. Cependant l’allocation dynamique pose d’autres problèmes :

  • le risque d’oublier de dé-allouer la mémoire après usage,
  • le manque de place mémoire pour allouer une nouvelle place mémoire,
  • les fragments de mémoire résultants des tailles variables d’allocations,
  • l’obligation d’avoir une excellente pré-connaissance des besoins en mémoire.

En cas d’impossibilité d’allouer un nouvel espace mémoire, nous pouvons nous retrouver dans une situation critique qui amène souvent au crash de l’application.

Dans les systèmes embarqués, nous préférons (conseillons), si possible, d’allouer l’ensemble des ressources nécessaires à l’application directement à la création du programme.

Les deux espaces d’allocation dynamique de mémoire sont le tas (heap) et la pile (stack).

Le tas correspond à une allocation, ou dé-allocation, aléatoire et introduit des résidus et des discontinuités avec le temps. Les deux principaux cas d’erreurs sont :

  • le manque de place d’allocation,
  • de trop petits espaces contigus libres pour allouer la mémoire demandée.

La pile correspond à une allocation, et dé-allocation, linéaire en mode ‘first push - last pull’. Son principal risque d’allocation reste le dépassement de capacité ‘stack overflow’. Une des raisons classiquement connues de dépassement de capacité sur la pile est la récursivité, mode de programmation prohibé dans les systèmes embarqués.

Note historique : nous parlons parfois de ‘heap-stack collision’ lors des dépassements de capacité en mémoire dynamique. Cela vient du fait que la zone d’allocation de mémoire dynamique était souvent répartie entre la pile et le tas avec, en adresses basses, le tas qui était alloué vers le haut et, en adresses hautes, la pile qui était allouée vers le bas. Dans les systèmes actuels, la taille et les positions de la pile et du tas sont définies à l’édition des liens et pas nécessairement de manière contiguë.

Ce chapitre passe en revue les différents cas d’application et les différentes options existantes en C, CPP et Mbed OS pour la gestion de la mémoire.

En C

En C nous avons des fonctions d’allocation mémoire malloc, calloc qui retournent un pointeur générique void* et qui nécessitent de reprendre le bon type pointé (typecasting) afin de permettre une arithmétique sur pointeurs associée au type pointé.

  • La libération mémoire est explicite et se fait avec la fonction free.
  • Les allocations mémoire dynamiques se font uniquement sur le tas (heap).
  • Il n’existe pas de contrôle d’accès en mémoire dans le programme. Ceci induit trois risques principaux :
    • L’accès en zone mémoire non alloué.
    • L’accès hors de la zone mémoire allouée (out-of-bounds memory access).
    • La libération d’une zone déjà libérée (double free)
typedef struct {
    int a;
    float b;
} my_struct_t;

...

my_struct_t* pdata;
int len = 10;
pdata = (my_struct_t*) malloc(len * sizeof(my_struct_t));

...

free(pdata);

En CPP

Bien que l’allocation mémoire C soit autorisée et possible en CPP, celle-ci est fortement déconseillée.

En CPP, comme en C, l’allocation, ou la dé-allocation, de la mémoire sur le tas est réalisée manuellement contrairement à Java ou Python.

Les deux opérateurs utilisés sont new et delete. De par la nature du CPP qui est orienté objet, ces opérateurs permettent la création et la création dynamique d’objets, de structure, de tableaux, de données simples, etc.

Object* ptrObject;
ptrObject = new Object;

int* ptrData;
ptrData = new int[len];

...

delete ptrObject;

delete[] ptrData;

Pour rappel, en CPP le constructeur permet d’initialiser l’objet à sa création et le destructeur de sauvegarder, ou nettoyer, les états nécessaires avant sa libération mémoire.

Nous avons quelques avantages de new par rapport à malloc:

  • l’opérateur new peut être surchargé (overloaded),
  • en cas d’erreur d’allocation, new lève une exception, malloc retourne un null,
  • new pointe directement sur le bon type de donnée et ne nécessite pas de trans-typage (typecasting),
  • new traite automatiquement la taille de l’objet pointé et ne nécessite pas de sizeof.
  • En CPP, les objets sont directement initialisés à l’allocation mémoire grâce au constructeur.

La durée de vie d’un objet sur le tas (heap) dépend de l’instant de son allocation et de sa destruction, indépendamment de l’emplacement de sa création. La gestion des pointeurs sur l’objet est indépendante de l’objet dynamique et doit se faire manuellement et aux bons endroits, par le concepteur de programme.

Une autre solution, préférable en CPP, consiste à définir les objets dans des classes et utiliser le constructeur pour son initialisation et sa création.

struct Base
{
    int n;
};   

struct Class : public Base
{
    int x;

    Class(int a, int b) : Base{a}, x(x) {}

    ...

};

Dans l’exemple ci-dessus, Base est initialisé dans le constructeur de Class et sa gestion devient transparente et encapsulée dans Class. Sa durée de vie et son scope est lié à Class. Sa gestion devient automatique et ne nécessite plus de new et delete explicite. Ceci est possible car le Base est créé automatiquement dans le même “scope” que Class.

Un autre exemple en rapport avec le TP02 est donné ci-dessous, celui-ci démontre comment initialiser des objets locaux (cb_, BME_ et button_) sur la pile dans le constructeur de SensorServer.

Notez particulièrement l’ordre de création des objets locaux, cb_ est construit avant, et doit être construit avant sensor_, car sensor_ a besoin de l’objet cb_ dans son constructeur!

class SensorServer {

  public:
    SensorServer()
      :   cb_(ClickBoard::RIGHT),
          sensor_(cb_.SDA, cb_.SCL, kSensorAddress),
          button_(PA_0, PullDown)
      {
          sensor_.init();
          button_.rise(callback(this, &buttonHandler));
      }

  private:
      ClickBoard cb_;
      BME sensor_;
      InterruptIn button_;
}

Le modèle mémoire de Mbed OS

Classiquement Mbed OS dispose à minima d’une zone de mémoire ROM (Flash) et d’une zone de mémoire RAM. Celles-ci sont directement liées au STM32 dans le cas de notre plateforme.

Mbed OS permet de réaliser un seul programme, mais avec plusieurs tâches dans des threads. Ceci implique d’avoir un contexte d’exécution par thread et la capacité à passer d’une tache à l’autre en changeant de contexte. Ceci implique d’avoir une pile (stack) associée à chaque thread.

Le modèle mémoire de Mbed OS est décrit sous ce lien.

Nous trouvons en mémoire ROM:

  • la table des vecteurs (ro),
  • le code de l’application,
  • les données de l’application,
  • en option le ‘bootloader’.

Nous trouvons en mémoire RAM:

  • Les éléments statiques du programme:
    • une table de vecteurs (rw),
    • une zone de récupération de données en cas de crash expliquée dans le traitement des erreurs de Mbed OS,
    • les données globales et statiques du programme,
    • les piles pour les threads globaux (main, timer, idle, scheduler, ISR).
  • Les éléments en zone dynamique:
    • le tas (heap) pour les allocations dynamiques,
    • les piles (stack) associées à chaque thread utilisateur.

Mbed OS alloue dynamiquement une nouvelle pile (stack) sur le tas (heap) pour chaque nouveau thread.

La partie statique est déterminée à la compilation (compiletime) et la partie dynamique à l’exécution (runtime). Comme vu plus avant, la répartition mémoire est définie à l’édition des liens (linktime).

Allocation mémoire sous Mbed OS

Le profile RTOS de Mbed OS dispose d’une série d’outils pour gérer l’allocation de la mémoire dynamiquement de la manière la plus sûre possible.

La classe MemoryPool permet de définir et de gérer un pool de zones mémoire de même taille. Une bonne approche consiste à initialiser des pools de mémoire à la création du programme dans lesquels nous pouvons aller puiser à la demande dans cette ressource mémoire pré-allouée.

Exercice Utilisation de la mémoire dynamique dans un programme/1

Mbed OS nous met à disposition un objet Queue qui gère une liste d’entiers ou de pointeurs sur des objets.

Pour démontrer l’utilisation de la classe MemoryPool, nous vous proposons de réaliser une solution avec des threads producteurs (p1, p2, etc.) et des threads consommateurs (c1, c2, etc.) qui s’échangent des données au travers d’une queue de messages. Chaque élément de la queue est implémenté dans une zone mémoire du MemoryPool. Les messages contiennent deux champs:

  • le nom du producteur (p1, p2, etc.),
  • l’index du message de ce producteur.

Les producteurs produisent des messages à des fréquences différentes. Les consommateurs ont une capacité limitée à consommer les messages. Les fréquences et les capacités sont simulées par des attentes ThisThread::sleep_for() dans les threads.

Nous vous proposons de tester le comportement de blocage du système lorsque la queue est pleine ou qu’il n’y a plus de zone mémoire disponible pour créer un message. Ceci arrive lorsque les producteurs ont un débit supérieur aux consommateurs.

Vous pouvez tester la solution avec une queue de taille supérieure, inférieure ou égale à la taille du pool de mémoire.

Note

La classe Mail réalise de fait la liaison d’une Queue et d’un MemoryPool

Solution
monitor_producer_consumer.cpp (solution)
#include "mbed.h"


int main()
{

}

Analyse de la mémoire

Lors de la mise au point du programme, il est nécessaire de tester la stabilité mémoire de l’application.

Mbed OS propose deux outils pour analyser la mémoire de votre code.

  • Le traçage des allocation et dé-allocations mémoire qui active une fonction de callback qui est appelée à chaque création ou destruction de mémoire malloc, realloc, calloc et free.
  • La statistique des états mémoire. Nous pouvons mettre des sondes logicielles dans notre code pour collecter les états des différents composants mémoire dynamiques dans le programme afin de surveiller son évolution durant l’exécution du code.

Exercice Utilisation de la mémoire dynamique dans un programme/2

Dans l’exercice précédent, remplacer le MemoryPool par une allocation dynamique de mémoire malloc et free et regarder le comportement à l’aide des deux méthodes décrites ci-avant. Les problèmes intéressants sont:

  • l’oubli du free,
  • une allocation mémoire illimitée lorsque les producteurs ont un débit supérieur aux consommateurs (choisir une taille de queue en conséquence).

Optimisation mémoire

Chaque librairie, classe, élément non utilisé de code surcharge la mémoire de code et de données.

Mbed OS propose comment optimiser la mémoire statique de votre code en :

  • utilisant des librairies simplifiées sur les systèmes embarqués,
  • enlevant les modules de code non utilisés,
  • en enlevant les codes de tests dans la version de production (ceci nécessite par contre d’avoir des modules de test unitaires séparés du code de production),
  • en optimisant les options du compilateur,
  • en suppriment les destructeurs des objets (possible pour un programme qui ne s’arrête jamais).