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 (run time) alors que l’allocation statique est gérée à la compilation (compile time).

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).

Les opérations sur le tas sont l’allocation, ou la dé-allocation de blocs et peuvent introduire 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 (fragmentation).

La pile correspond à une allocation, et dé-allocation, linéaire en mode first in (push) - last out (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 et C++ 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 C++

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

En C++, 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 C++ 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 C++ 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 C++, 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 C++, 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.