Le langage C:
Allocateur de mémoire
Par :
Guilhem de Wailly (gdw@erian-concept.com)
Résumé
Dans cette série d'articles, nous présentons le langage C. Il ne s'agit pas de réécrire les ouvrages de référence donnés en annexe, mais de donner, au travers du C, une méthode de programmation basée sur la notion d'interface.
Au fur et à mesure de notre progression, nous décrirons les éléments indispensables du langage et conduirons le lecteur à consulter les ouvrages de références. Le but poursuivi est clairement de parvenir à construire des programmes de manière segmentée.
Ce mois-ci, nous programmerons un allocateur de mémoire qui visera à remplacer les fonctions standard de la famille de malloc(). Moins performant que la version d'origine, notre allocateur apportera une aide importante à la mise au point en détectant les blocs corrompus et les blocs non libérés.
Description
Tout au long de ces articles, nous avons présenté, puis abondamment utilisé l'allocation dynamique de la mémoire. L'allocation statique de la mémoire permet de créer des programmes rapides et simples, car la mémoire nécessaire et allouée sous la forme de tableaux au démarrage du programme. De cette manière, il n'existe pas de temps de création et libération de la mémoire. Par contre, le désavantage majeur est que le programme ne sait pas ajuster sa consommation de mémoire en fonction de ses besoins et que le programmeur est souvent obligé de surdimensionner ses tableaux.
L'allocation dynamique a permis à l'informatique en général de faire un grand bond en avant et de proposer des logiciels adaptables et évolutifs. L'allocation dynamique est en fait une vue de l'esprit car dans un ordinateur, la mémoire n'est pas fabriquée au fur et à mesure des besoins ! Il s'agit donc bien de gérer une quantité de mémoire finie en la répartissant entre les différents processus.
Chaque programme possède au démarrage une certaine quantité de mémoire allouée par le système d'exploitation et il la gère sous la forme de blocs alloués et libres. Il est possible qu'à un instant donné, le programme ait besoin de plus de mémoire, et dans ce cas, il formule une requête au système d'exploitation qui la satisfait ou non.
D'un point de vue général, la mémoire dite dynamique est constituée d'une liste de blocs libres que l'allocateur découpe en fonction des besoins et regroupe lorsque ces blocs sont ``libérés''.
Ce mois-ci, nous allons décrire un allocateur de mémoire simple. Mais rendons à Ritchie ce qui est à Kernighan et vice versa (ce sont les inventeurs du langage C) : l'allocateur est tiré de leur livre ``The C programming Language'', Prentice-Hall, Inc, la bible du langage C qui servit pendant longtemps de norme, avant que le comité de l'ANSI ne normalise ce langage.
Cet allocateur permettra dans une certaine mesure d'aider à la mise au point des programmes en contrôlant que les blocs alloués ont été correctement libérés. Il existe des outils comme ElectricFence qui permettent de contrôler que l'on accède jamais en dehors des blocs alloués, que ce soit en lecture ou en écriture. Notre outil permettra dans une certaine mesure de vérifier que les écritures non pas eu lieu en dehors des blocs.
Les sources de l'allocateur sont disponibles sur http://www.erian-concept.com/pub/heap.tgz.
Entête de la bibliothèque
Comme à l'accoutumé, nous commençons par l'écriture de l'entête heap.h de la bibliothèque de fonctions qui déclare le prototype des fonctions, les constantes et les variables :
#ifndef
___HEAP_H
#define ___HEAP_H
void
heap_init (unsigned heap_size);
void
heap_end (void);
char * heap_malloc
(unsigned nbytes);
char * heap_calloc
(unsigned size,
unsigned
number);
char * heap_realloc (char *
pointer,
unsigned size);
void
heap_free (char * pointer);
unsigned
heap_size (char * pointer);
void
heap_stats (unsigned * current,
unsigned * maximum,
double
* total,
unsigned *
mallocs);
#endif /* ___HEAP_H */
Nous retrouvons les équivalents des fonctions malloc(), calloc(), realloc() et free(). Les fonctions heap_init() et heap_end() servent à initialiser et à terminer la bibliothèque de fonctions. Ces fonctions doivent être utilisées respectivement avant toute utilisation et après toute utilisation du tas. On trouve la fonction heap_size() qui renvoie la taille d'un bloc et la fonction heap_stats() qui permet d'obtenir des informations sur l'utilisation de la mémoire (nombre d'octets actuellement alloués, nombre maximum d'octets simultanément alloués, total de la mémoire allouée et nombre d'allocations).
Nous verrons la prochaine fois comment replacer automatiquement les appels aux fonctions standard comme malloc() par des appels aux fonctions équivalentes de cette bibliothèque, comme heap_malloc().
Passons maintenant à la réalisation de la bibliothèque.
Réalisation de la bibliothèque
La réalisation de cette bibliothèque heap.c débute une fois de plus par l'inclusion des entêtes de fonctionnalités utilisées ;
#define
___HEAP_C
#include <heap.h>
#include
<errno.h>
#include <stdio.h>
#include
<stdlib.h>
Nous entrons maintenant dans le vif du sujet. Nous prévoyons d'utiliser des fonctions d'aide à la mise au point. Pour l'instant, ces fonctions seront des macros inactives. La prochaine fois, elles seront définie :
#ifdef
__DEBUG_HEAP
/*******************************************
*
M I S E A U P O I N T D U T A S
*
*******************************************/
/*
Ce sera pour la prochaine fois (:-) */
#else /*
__DEBUG_HEAP */
# define _debug_heap_add(p) ((char
*)(p))
# define _debug_heap_del(p) ((char *)(p))
#
define _debug_heap_end()
# define
_debug_heap_init()
#endif /*__DEBUG_HEAP */
Nous déclarons ensuite un certain nombre de définitions et type de données :
/*
Taille en octets de l'unité d'allocation */
#define
NALLOC 2048
/* Type d'alignement en mémoire. Ici,
*
alignement sur des mots */
#define ALIGN_TYPE int
/*
Entête des blocs mémoire */
typedef union
u_header * Header;
typedef union u_header {
struct
{
unsigned size; /* Taille du bloc */
Header ptr; /* Entête suivant */
} s;
ALIGN_TYPE align; /* alignement */
} _Header;
/*
Variables locales pour les statistiques */
static unsigned
_current, _maximum, _mallocs;
static double
_total;
/* Variable locale des gestion du tas */
static
_Header _base; /* Base du tas */
static
Header _allocp = 0; /* Premier bloc
* libre */
La mémoire sera allouée par blocs de NALLOC octets. Cette granularité permettra d'éviter une trop grande fragmentation de la mémoire. En effet, au bout d'un certain temps, lorsque beaucoup de mémoire a été allouée et libérée, on observe la présence de nombreux petits blocs de mémoire libres séparés par des blocs alloués (ce qui interdit leur réunion en blocs libres plus gros). Afin d'éviter cela, la mémoire est allouée en unité, ici, de deux kilo-octets.
Les processeurs peuvent accéder n'importe quel octet de la mémoire. Cependant, la mémoire est organisée en mots de 32 bits, voire 64 bits sur la prochaine génération de processeurs. Or les accès sont plus rapides lorsque les octets sont alignés sur des mots. On déclare donc un type de donnée, ici int, qui forcera l'alignement des blocs alloués. On pourra forcer l'alignement sur des mots de 64 bits en associant à ALIGN_TYPE le type double.
Les blocs de mémoire possèdent tous un entête contenant la taille du bloc en unité d'allocation (ici, 2048 bits) et un pointeur vers le bloc suivant. Cet entête est déclaré comme une union entre une structure contenant ces informations et le champ forçant l'alignement.
Nous trouvons ensuite des variables privées utiliser pour maintenir des informations concernant l'allocateur, comme le nombre d'octets actuellement alloué par l'utilisateur, le maximum d'octets alloué simultanément, le nombre d'allocations effectuées et la somme de la mémoire allouée (cumul de la taille en octets des espaces alloués).
Nous trouvons ensuite les variables locales de gestion du tas. Le premier entête du tas, _base, est une variable statique qui n'est pas allouée dynamiquement. Ces champs size et ptr seront initialisés lors de l'initialisation du tas. Le pointeur _allocp contient l'adresse de l'entête du dernier bloc alloué ou zéro.
Initialisation et terminaison
L'initialisation de la bibliothèque se contente de donner aux variables locales leur valeur initiale :
/*
I N T E R F A C E */
/* Initialisation de la bibliothèque
*/
void heap_init (unsigned heap_size) {
/*
Initialisation du tas */
_base.s.ptr = _allocp = &
_base;
_base.s.size = 0;
_current = 0;
_maximum
= 0;
_mallocs = 0;
_totals = 0.0;
/*
Intégration du premier bloc de mémoire
* dans la
mémoire libre */
heap_free (heap_malloc
(heap_size));
}
La fonction de terminaison contrôle la cohérence du tas. Nous verrons cela la prochaine fois.
/*
Terminaison de la bibliothèque */
void heap_end
() {
/* Vérification de l'intégrité du
tas */
_debug_heap_end();
/* Réinitialisation
des variables */
_allocp = 0;
}
Allocation
La fonction d'allocation est la suivante :
/*
Équivalent de la fonction standard
* malloc() */
char
* heap_malloc (unsigned nbytes) {
Header p, q;
unsigned nunits;
if (! nbytes) {
return
0;
}
/* Nombre d'unités de mémoire
demandées */
nunits = 1 + (nbytes / sizeof
(_Header));
q = _allocp;
if (! q) {
_base.s.ptr
= _allocp = q = & _base;
_base.s.size = 0;
}
/*
Recherche des blocs libres d'un bloc
* suffisamment grand ...
*/
for (p = q->s.ptr; ; q = p, p = p->s.ptr) {
/* Si la taille du bloc libre est
* suffisante ... */
if (p->s.size >= nunits) {
if
(p->s.size == nunits) {
/* Si la taille est
exactement celle
* demandée */
q->s.ptr = p->s.ptr;
}
else {
/* Sinon, scinder le bloc ; le bloc
* alloué
est placé à la fin. */
p->s.size -=
nunits;
p += p->s.size;
p->s.size
= nunits;
}
/* Mise à jours des
statistiques */
_mallocs++;
_current +=
nbytes;
_total += (double) nbytes;
if
(_current > _maximum) {
_maximum = _current;
}
/* */
_allocp = q;
/* Ajout du pointeur pour
la mise au
* point */
_debug_heap_add ((char
*)(p + 1));
/* Retourner le pointeur */
return (char *) (p + 1);
}
if (p ==
_allocp) {
/* Demander de la mémoire
supplémentaire
* au système d'exploitation.
*/
p = _morecore (nunits);
if (p == 0)
{
/* S'il n'y a plus de mémoire,
*
positionner la variable errno, et
* retourner le pointeur
nul */
errno = ENOMEM;
return
0;
}
}
}
}
La fonction d'allocation commence par calculer le nombre d'unités de mémoire demandées. Si la bibliothèque vient juste d'être initialisée, _allocp vaut zéro. Dans ce cas, on utilise l'entête _base pour créer une liste de blocs libres contenant un seul élément de taille nulle ; cela obligera la fonction à réclamer plus de mémoire au système d'exploitation. On obtiendra alors la structure suivante :
Figure
1: Structure après réclamation de mémoire
auprès du système.
Dès lors, la fonction parcourt la liste de blocs libres qui commence avec le pointeur _allocp à la recherche d'un bloc suffisamment grand pour accueillir la zone de mémoire demandée. Si le bloc possède exactement la taille demandée, il est utilisé intégralement et on aurait :
Figure
1: Structure après allocation de toute la mémoire
libre.
Dans le cas ou le bloc libre est plus grand que la taille demandée, le bloc alloué est placé à la fin du bloc libre et le bloc libre est scindé en deux, ce qui donne la structure suivante :
Figure
1: Structure des données après l'allocation d'un petit
bloc.
Après un certain temps de fonctionnement et des allocations et des libérations entrelacées, la structure du tas pourrait être comme dans la figure suivante :
Figure
1: Après un certain nombre d'allocations / libérations
de blocs ...
La fonction utilise la conversion des pointeurs pour provoquer la granularité dans l'allocation de la mémoire. En effet, un pointeur sur caractères incrémenté de un pointe sur le caractère suivant ; de même, un pointeur sur une structure de donnée incrémenté de un pointe sur la structure suivante. Entre les deux pointeurs, l'incrément effectif n'a pas été de un, mais de la taille de l'objet pointé, c'est à dire un dans le premier cas et la taille de la structure pointée dans le second cas.
Les blocs ont une taille minimum, celle de la structure _Header. La liste des blocs libres est maintenue par ordre croissant d'adresses dans la zone de mémoire à allouer.
Lorsqu'aucun bloc libre n'est capable d'accueillir la demande, la fonction _morecore() est appelée pour réclamer plus de mémoire au système d'exploitation. En cas de succès, le mémoire libre obtenue est intégrée dans la liste des blocs libres ; on procède alors à une nouvelle itération qui ne doit pas échouer. En cas d'échec, lorsque _morecore() retourne zéro, la constante ENOMEM définie dans le fichier errno.h est placée dans la variable globale errno de manière à être conforme à la norme ANSI, puis le pointeur nul est retourné.
Examinons maintenant la manière avec laquelle la mémoire est réclamée au système d'exploitation.
Demande de mémoire
D'une manière très générale, tout processus est associé à aux moins trois zones de mémoire : une zone pour le code, une zone pour la pile et une zone pour les données. La fonction sbrk() permet d'augmenter la taille de la zone des données de n octets. La fonction retourne alors un pointeur sur la nouvelle zone des données de n octets, ou le pointeur valant -1 en cas d'échec. Nous allons utiliser cette fonction pour réclamer de la mémoire :
/*
Demande plus de mémoire au système
*
d'exploitation */
static Header _morecore (unsigned
nunits) {
Header up;
unsigned rnu;
/*
Nombre d'unités de mémoire */
rnu = NALLOC *
(1 + nunits / NALLOC);
/* Demande d'extension du tas */
up = (Header) sbrk (rnu * sizeof (_Header));
if
(-1 == (int) up) {
return 0;
}
/*
Initialisation de l'entête */
up->s.size = rnu;
/* Ajoute du pointeur dans le tableau
* des pointeurs
alloués. */
_debug_heap_add ((char * ) (up +
1));
/* Libération de ce pointeur pour
*
l'ajouter dans la mémoire
* allouable. */
heap_free ((char * ) (up + 1));
/* Retourner le
pointeur. */
return _allocp;
}
La fonction calcule donc le nombre d'unités de mémoire demandées puis invoque sbrk(). En cas de succès, l'entête du bloc nouvellement alloué est initialisé puis il est intégré dans la liste des blocs libres avec une invocation à la fonction heap_free(). Le pointeur _allocp pointe alors sur le dernier bloc alloué et c'est celui-ci qui est retourné.
La prochaine fois, nous examinerons comment libérer la mémoire, les autres fonctions d'allocation et l'aide à la mise au point. Enfin, nous terminerons en modifiant le fichier d'entête heap.h afin que les appels aux fonctions standard de la famille de malloc() soient automatiquement remplacés par des appels à nos fonctions au moment de la compilation.
L'auteur
Guilhem de Wailly, directeur de la société Erian Concept, partenaire NetUltra (www.netultra.net) et Getek (www.getek.fr).
|
Votre interlocuteur Linux ! Support, formations, configurations, administration, développements Linux. http://www.erian-concept.com |
Références
Langage C - norme ANSI : bon ouvrage de référence
Ph.
DRIX
Masson
Programmer en C++: une bonne référence
S.C.
DEWHURST et K.T. STARK
Masson
Le Langage C: la bible!
B.W. KERNIGHAN et D.M.
RITCHIE
Masson
gcc: compilateur C du GNU, et tous les outils de
développement
http://www.gnu.org
Kdevel: un environnement KDE de programmation
C
http://samuel.cs.uni-potsdam.de/~smeier/kdevelop_new/index.html