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 unnull
, 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 desizeof
.- 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
.