Note
Roadmap :
- reprendre les exemples en
Cen utilisant desintsuite au changement dans le programme.
Remarque
Ce chapitre se concentre sur la manière dont un programme compilé gère la mémoire. Il est question, en particulier, de la notion de variable. Le modèle dans lequel on se place est celui du langage C où une variable est un emplacement mémoire.
Organisation de la mémoire¶
Note
Ici, je fais le choix d'une présentation assez informelle pour ne pas qu'elle soit trop liée à la réalité d'un compilateur en particulier.
Un programme compilé gère la mémoire d'un ordinateur de deux manières très différentes
- statiquement : c'est le cas des variables locales ou globales définies dans le programme. Au moment de la compilation, le compilateur dispose de l'information suffisante pour prévoir de la place en mémoire pour stocker ces données.
- dynamiquement : c'est le cas des objets dont la taille n'est connue qu'à l'exécution et peut varier selon l'état du programme. C'est alors au moment de l'exécution que le programme va faire une demande d'allocation pour obtenir une place mémoire.
En termes d'allocation statique, on peut distinguer plusieurs types de mémoire :
- les variables globales initialisées qui seront stockées dans le binaire et placées en mémoire dans une zone spécifique chargée avec le binaire
- les variables globales non initialisées dont seule la déclaration sera dans le binaire et qui seront allouées, placées en mémoire et initialisées à 0 au moment du chargement du binaire
- les variables locales et les paramètres qui sont placés dans une pile afin de les allouer uniquement au moment de l'exécution du bloc ou de la fonction
L'allocation dynamique utilise une zone mémoire appelée tas dont une possible organisation est développée dans la partie Réalisation d'un système d'allocation de mémoire.
Considérons le programme C suivant.
const int a = 42;
int b[] = { 1, 2, 3 };
int c;
int f(int x, int y)
{
int z = x;
z = z * y;
return z;
}
int main(int argc, char **argv)
{
const int d = 1664;
c = f(a, d);
return 0;
}
Il est possible d'observer la manière dont sa mémoire sera répartie en utilisant la commande objdump :
$ gcc -c memoire.c
$ objdump -x memoire.o
memoire.o: file format elf64-x86-64
memoire.o
architecture: i386:x86-64, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x0000000000000000
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000053 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 0000000c 0000000000000000 0000000000000000 00000094 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 000000a0 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000a0 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 00000013 0000000000000000 0000000000000000 000000a4 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000b7 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000b8 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 memoire.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g O .rodata 0000000000000004 a
0000000000000000 g O .data 000000000000000c b
0000000000000000 g O .bss 0000000000000004 c
0000000000000000 g F .text 000000000000001f f
000000000000001f g F .text 0000000000000034 main
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000042 R_X86_64_PLT32 f-0x0000000000000004
0000000000000048 R_X86_64_PC32 c-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
0000000000000040 R_X86_64_PC32 .text+0x000000000000001f
On retrouve dans les sections mémoire :
.textcontenant le programme binaire.datacontenant les variables globales initialisées et non constantes.bsscontenant les variables globales non initialisées (ou initialisées à 0).rodatacontenant les variables globales initialisées mais constantes.
Ici, le schéma mémoire est un peu plus compliqué car une zone mémoire distincte est prévue pour les variables constantes initialisées pour des raisons de sécurité.
La pile et le tas sont automatiques et n'ont pas besoin de figurer dans le binaire, c'est pour cela qu'on ne les trouve pas dans la liste.
Dans cette description mémoire, on trouve la table des symboles qui décrit où vont se trouver en mémoire certaines variables.
| Nom de variable | Sorte de déclaration | Section mémoire |
|---|---|---|
a |
constante globale initialisée | .rodata |
b |
globale initialisée | .data |
c |
globale non init. | .bss |
La section suivante permettra de compléter le tableau en étudiant comment les paramètres et variables locales sont gérés. On remarque cependant qu'il n'y a pas de symboles pour ceux-ci. En effet, le nom des variables locales est a priori perdu après la compilation contrairement aux variables globales.
Pointeurs¶
Dans le modèle du C, une variable est un emplacement mémoire, et chaque emplacement mémoire possède une adresse : c'est le numéro de son premier octet. Une adresse est donc un simple entier, mais le C lui associe un type pour savoir comment interpréter ce qui s'y trouve.
Définition
Un pointeur est une valeur qui représente une adresse mémoire. Si T
est un type, le type T * est celui des pointeurs vers une valeur de type
T.
- Si
xest une variable de typeT, l'expression&xest l'adresse dex, de typeT *. - Si
pest de typeT *, l'expression*pdésigne l'emplacement mémoire d'adressep, vu comme une valeur de typeT. On dit qu'on déréférencep.
#include <stdio.h>
int main(void)
{
int x = 3;
int *p = &x; // p contient l'adresse de x
printf("adresse de x : %p, valeur pointée : %d\n", (void *)p, *p);
*p = 4; // on écrit à l'adresse contenue dans p, donc dans x
printf("x vaut maintenant %d\n", x);
return 0;
}
Ce programme affiche par exemple :
adresse de x : 0x16bc467e0, valeur pointée : 3
x vaut maintenant 4
L'adresse affichée change d'une exécution à l'autre : x est une variable
locale, placée dans la structure pile de main.
Remarque
- L'étoile joue deux rôles différents : dans une déclaration
int *p, elle fait partie du type ; dans une expression*p, c'est l'opérateur de déréférencement. Attention, dansint *p, q;seulepest un pointeur,qest unint. *ppeut être placé à gauche d'une affectation :*p = 4modifie la mémoire pointée, pasplui-même.- Un pointeur est lui-même une variable, qui occupe 8 octets sur une
architecture 64 bits, quel que soit le type pointé. On peut donc
prendre son adresse :
&pest de typeint **. - La valeur spéciale
NULL(l'adresse 0) indique un pointeur qui ne pointe sur rien. La déréférencer est une erreur, voir la partie Protection mémoire.
Passage par adresse¶
En C, les paramètres d'une fonction sont passés par valeur : comme on l'a vu dans la partie Piles d'exécution, l'appel copie la valeur des arguments dans la structure pile de la fonction appelée. Modifier un paramètre ne modifie donc que cette copie.
Pour qu'une fonction puisse modifier une variable de l'appelant, on lui passe l'adresse de cette variable : c'est le passage par adresse. L'exemple classique est l'échange de deux variables.
void echange_faux(int a, int b)
{
int tmp = a;
a = b;
b = tmp;
}
void echange(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(void)
{
int x = 1;
int y = 2;
echange_faux(x, y); // x = 1, y = 2 : seules les copies ont été échangées
echange(&x, &y); // x = 2, y = 1
return 0;
}
Lors de l'appel echange(&x, &y), la structure pile de echange contient
deux paramètres a et b qui sont des copies des adresses de x et de
y. Ces adresses désignent des emplacements de la structure pile de main :
*a = *b écrit donc directement dans x.
Remarque
C'est aussi ce mécanisme qui permet à une fonction de renvoyer plusieurs
résultats, ou d'éviter de recopier une grosse structure en passant son
adresse. On le retrouve dans scanf("%d", &n), qui doit écrire dans n.
Pointeurs et tableaux¶
Les éléments d'un tableau sont rangés de manière contiguë en mémoire. Dans
presque toutes les expressions, un tableau t est converti automatiquement en
l'adresse de sa première case &t[0].
On peut ajouter un entier à un pointeur : si p est de type T *, alors
p + i est l'adresse située i cases de type T plus loin, c'est-à-dire
i * sizeof(T) octets plus loin. C'est l'arithmétique des pointeurs. Par
définition, t[i] est exactement *(t + i).
#include <stdio.h>
int somme(int *t, int n)
{
int s = 0;
for (int i = 0; i < n; i++) {
s += t[i]; // identique à s += *(t + i);
}
return s;
}
int main(void)
{
int t[4] = { 1, 2, 3, 4 };
int *p = t; // t est converti en &t[0]
printf("%d %d %d\n", *p, *(p + 2), p[3]); // 1 3 4
printf("%p %p\n", (void *)p, (void *)(p + 1)); // adresses distantes de 4
printf("somme : %d\n", somme(t, 4)); // 10
printf("somme de la fin : %d\n", somme(t + 2, 2)); // 7
printf("%zu %zu\n", sizeof(t), sizeof(p)); // 16 8
return 0;
}
Remarque
- Passer un tableau à une fonction revient donc à passer l'adresse de sa
première case : le tableau n'est pas copié et la fonction peut le
modifier. En revanche, sa taille est perdue, c'est pourquoi on passe
toujours la longueur
nen paramètre supplémentaire. - Dans
somme,sizeof(t)vaudrait 8, la taille d'un pointeur, et non la taille du tableau. C'est une erreur classique. - L'appel
somme(t + 2, 2)montre qu'on peut faire travailler une fonction sur une portion de tableau sans rien recopier. - Un tableau n'est pas pour autant un pointeur : on ne peut pas écrire
t = p, etsizeof(t)dansmaindonne bien 16 octets. - Rien ne vérifie qu'un accès
t[i]reste dans les bornes du tableau : un débordement lit ou écrit silencieusement dans une autre zone mémoire.
Pointeurs vers le tas¶
Les pointeurs sont indispensables pour l'allocation dynamique, présentée dans
la partie suivante : la fonction malloc renvoie l'adresse d'une zone du tas,
et la seule manière d'y accéder est de conserver ce pointeur. Contrairement à
une variable locale, cette zone survit à la fin de la fonction qui l'a allouée,
jusqu'à l'appel de free.
#include <assert.h>
#include <stdlib.h>
int *cree_tableau(int n, int v)
{
int *t = malloc(n * sizeof(int));
assert(t != NULL);
for (int i = 0; i < n; i++) {
t[i] = v;
}
return t;
}
int main(void)
{
int *t = cree_tableau(5, 7);
t[0] = 1;
free(t); // c'est à l'appelant de libérer la zone
t = NULL;
return 0;
}
Erreurs classiques¶
Le compilateur ne vérifie pas qu'un pointeur désigne une zone valide. Les erreurs suivantes produisent un comportement indéterminé : le programme peut planter, afficher des valeurs absurdes ou sembler fonctionner normalement, ce qui les rend difficiles à détecter.
-
Pointeur pendant (dangling pointer) : un pointeur vers une zone qui n'existe plus. C'est le cas d'un pointeur vers une variable locale d'une fonction terminée, dont la structure pile a été dépilée :
int *adresse_locale(void) { int x = 42; return &x; // x disparaît avec la structure pile de l'appel }Le compilateur produit ici un avertissement. C'est aussi le cas d'un pointeur vers une zone du tas après son
free, ce qui est plus sournois quand plusieurs pointeurs désignent la même zone :#include <stdlib.h> int main(void) { int *t = malloc(3 * sizeof(int)); int *u = t; // u et t pointent sur la même zone free(t); u[0] = 1; // u est un pointeur pendant return 0; } -
Double libération (double free) : appeler deux fois
freesur la même adresse, ou appelerfreesur une adresse qui ne provient pas demalloc. Des exemples sont donnés dans la partie Libération. Dans le système d'allocation réalisé plus loin, ce sont lesassertde la fonctionliberationqui détectent ces erreurs. -
Fuite mémoire : perdre le dernier pointeur vers une zone allouée avant de l'avoir libérée. La zone ne pourra plus jamais être libérée.
int *t = malloc(3 * sizeof(int)); t = malloc(5 * sizeof(int)); // la première zone n'est plus accessible free(t);
Remarque
- Une bonne habitude est d'affecter
NULLà un pointeur juste après l'avoir libéré : une utilisation ultérieure provoque alors une erreur immédiate au lieu d'un comportement indéterminé, etfree(NULL)ne fait rien. - L'option de compilation
-fsanitize=addressdegccetclangajoute des vérifications à l'exécution. Le programme précédent s'arrête alors avec le messageERROR: AddressSanitizer: heap-use-after-freequi indique la ligne fautive. Sous Linux, elle signale aussi les fuites mémoire à la fin de l'exécution. Il est vivement conseillé de l'utiliser pendant la mise au point des programmes.
Portée d'un identificateur¶
En ce qui concerne une variable dans un programme, on peut définir deux notions d'apparence assez similaire.
D'une part la portée d'un identificateur qui correspond au texte du programme :
Définition
La portée d'un identificateur est définie par la zone du texte d'un programme dans laquelle il est possible d'y faire référence sans erreurs.
Remarque
Dans le langage C, un identificateur peut être utilisé dès sa
déclaration mais tant que la variable n'est pas initialisée, le comportement
n'est pas spécifié et il faut considérer cela comme une erreur. Le compilateur
produit ainsi un avertissement quand on utilise le paramètre -Wall.
Dans le cas d'une définition, il est ainsi possible de faire référence à l'identificateur
dans l'expression de son initialisation : int x = x. Ce cas est pathologique
et le fait qu'on compte la ligne de déclaration dans la portée ne devrait pas
inciter à écrire ce genre de code qui produira, de toute façon, une erreur
avec les options -Wall -Werror sous clang (sous gcc, il faut ajouter
l'option -Winit-self).
Dans le programme :
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
La portée des identificateurs est :
| Identificateur | Portée |
|---|---|
a |
1-13 |
x |
3-7 |
y |
5-7 |
f |
3-13 |
g |
9-13 |
z |
11-13 |
Pour une fonction, afin de pouvoir écrire des fonctions récursives, l'identificateur est utilisable dès la fin de son en-tête, donc en particulier dans le corps de la fonction.
Comme la portée est une notion associée aux identificateurs, elle est indépendante de la notion de variables. Si on considère le programme suivant :
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
L'identificateur i a pour portée les lignes 3-6 et 10-13.
Un autre phénomène plus complexe peut se produire quand on redéfinit un identificateur dans sa portée.
Considérons le programme suivant
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Ici, l'identificateur i a pour portée les lignes 3-13 mais dans les lignes
7-10 il y a un phénomène dit de masquage où la première définition est cachée
par la seconde.
L'identificateur associé à une variable globale a pour portée l'ensemble des lignes allant de sa déclaration à la fin du fichier.
Remarque
En C, la portée d'un identificateur est statique : elle dépend uniquement du texte du programme au moment de la compilation.
En Python, la portée d'un identificateur est également déterminée par le texte du programme, mais le fait qu'un identificateur soit effectivement défini à un endroit donné peut dépendre de l'exécution. Par exemple si on considère le programme Python
1 2 | |
Utiliser x après ces lignes provoque une erreur (NameError) ou non
selon que la condition est réalisée ou non : cette vérification n'a lieu
qu'à l'exécution.
Piles d'exécution, variables locales et paramètres¶
On a vu qu'en raison de leur durée de vie, les variables globales étaient allouées dès le chargement du programme. Pour les variables locales ainsi que la mécanique des appels, on utilise une pile.
Cette pile d'exécution est représentée en mémoire par un tableau et un indicateur de sommet de pile (le pointeur de pile).
Le remplissage de ce tableau s'effectue souvent des adresses hautes vers les adresses faibles : on empile en faisant diminuer les adresses.
Remarque
En fait, il existe des architectures où les adresses sont croissantes. Ce qui importe est que le tas et la pile aient des comportements opposés pour qu'ils puissent grandir dans la même zone mémoire.
Un compilateur peut faire le choix d'utiliser directement des registres processeurs pour les variables locales ou pour passer des paramètres à une fonction. Ici, pour simplifier, on va supposer que ce n'est pas le cas et que tout passe par la pile d'exécution.
Remarque
Afin de pouvoir appeler une fonction dans une bibliothèque potentiellement compilée avec un autre compilateur, il est nécessaire d'avoir une convention d'appels de fonctions. Une telle convention est appelée une interface applicative binaire (Application Binary Interface).
La convention System V AMD64 ABI qui est celle de Linux et macOS sur des
architectures x86 64 bits
consiste à utiliser des registres entiers pour les six premiers arguments
entiers ou pointeurs et des registres flottants pour les huit premiers arguments
flottants. Les arguments suivants sont alors passés sur la pile (donc dès le
septième entier/pointeur ou neuvième flottant).
La convention cdecl qui est assez répandue sur les architectures x86 32 bits
consiste à utiliser la pile. Par contre la valeur de retour est présente dans
des registres comme pour la convention System V AMD64 ABI.
Lors d'un appel d'une fonction passant par la pile, on commence par empiler les paramètres (souvent de la droite vers la gauche) puis on empile l'adresse à laquelle doit revenir l'exécution une fois que la fonction aura terminé son exécution.
Au début de l'exécution de cette fonction, on sauvegarde sur la pile l'adresse de
base de la structure pile de l'appelant (registre ebp en 32 bits), puis on
déplace le sommet de pile pour réserver de la place pour les variables locales.
L'empreinte sur la pile d'un appel de fonction est appelée une structure pile (stack frame en anglais). La pile est alors organisée, depuis l'appel au point d'entrée du programme, par empilement et dépilement de structures piles.
Voici un exemple possible de l'état de la pile d'exécution lors de l'exécution d'un
programme compilé avec la norme cdecl (Il suffit d'ajouter l'argument
-m32 pour compiler en 32 bits). Sur cette figure, le sommet de pile (adresses
les plus faibles) est en haut et la structure pile de main est en dessous.
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
f(10,12) sur la pileÀ chaque appel de fonction, on va donc empiler une structure de pile complète, puis la dépiler à la sortie. Ce mécanisme est essentiel pour permettre la récursivité car il permet d'effectuer plusieurs appels d'une même fonction sans risquer que la mémoire utilisée lors d'un des appels interfère avec un autre. On comprend également les limites de la récursivité ici car cet empilement successif de structures de piles peut dépasser la taille maximale de la pile d'exécution : on parle alors de dépassement de pile ou stack overflow en anglais.
Remarque
Il est possible de définir des variables locales qui soient situées au même
emplacement mémoire pour tous les appels d'une fonction, en les déclarant avec
le mot-clé static : on parle de variables locales statiques.
Ce mécanisme est essentiellement géré comme les variables globales et il ne sera pas développé dans la suite.
Note
Je me demande s'il faudrait parler plus précisément de la manière dont la pile est gérée avec les registres ebp/esp. Mais ça ne me semble pas apporter grand chose ici.
Allocation dynamique¶
Comme cela a été vu dans la section Organisation de la mémoire, il est possible d'allouer dynamiquement de la mémoire. Pour gérer cette allocation dynamique, on passe par une zone mémoire appelée le tas ainsi que par un mécanisme d'allocation et de libération de mémoire au niveau du système.
Pour l'utilisateur, cette gestion interne est transparente et on peut se contenter de considérer qu'il y a deux mécanismes :
- l'allocation mémoire où on demande à ce qu'une zone mémoire d'une certaine taille soit allouée
- la libération mémoire où on signale que la zone mémoire peut être récupérée par le système.
Naturellement, la mémoire d'un ordinateur étant finie, il est très important de libérer au plus tôt la mémoire non utilisée pour éviter d'épuiser la mémoire. Quand un programme ne libère pas toute la mémoire qu'il alloue, on parle de fuite mémoire. L'empreinte mémoire d'un tel programme peut alors croître jusqu'à rendre le programme ou le système inutilisable.
Allocation¶
Pour allouer une zone mémoire, on utilise la fonction malloc dans stdlib.h
de signature :
void *malloc(size_t size)
Ici size indique le nombre d'octets à allouer et la fonction renvoie un
pointeur vers le premier octet alloué. Comme la fonction ne connaît pas le type
d'objets alloués, on utilise ainsi le type void *.
Ce type joue un rôle spécial : une valeur de type void * est convertie
implicitement vers n'importe quel type pointeur, on peut donc affecter
directement la valeur de retour sans rien avoir à écrire d'autre que l'appel à malloc :
char *t = malloc(n);
Remarque
Dans le langage C++ qui peut être vu comme un successeur de C, il est
obligatoire de préciser ici le nouveau type à l'aide
d'un transtypage. Pour convertir la valeur x vers le type
char * on écrit alors (char *)x. Ainsi, pour allouer un tableau de n
caractères, on utilisera :
char *t = (char *) malloc(n);
Bien que ce ne soit pas nécessaire en C, il est fréquent de rencontrer des programmes présentant de tels transtypages qui sont superflus mais corrects syntaxiquement en C.
Pour obtenir la taille à allouer, il peut être utile d'utiliser l'opérateur
sizeof qui prend en entrée un type ou une valeur et renvoie sa taille. Ainsi
on peut allouer un tableau de \(n\) entiers non signés ainsi :
unsigned int *t = malloc( sizeof(unsigned int) * n );
et cet appel ne dépend pas de la taille prise par un unsigned int sur
l'architecture.
Une autre raison de l'utilisation de sizeof est l'extensibilité. Par exemple,
si on a un struct point représentant des points en 2D :
struct point {
float x;
float y;
};
on peut allouer un tableau de \(n\) points ainsi :
struct point *t = malloc( sizeof(struct point) * n );
Si jamais on change la structure pour représenter des points en 3D ainsi :
struct point {
float x;
float y;
float z;
};
il sera inutile de changer le code d'allocation du tableau car sizeof(struct point)
tiendra compte automatiquement du changement.
Si jamais une erreur empêche d'allouer la mémoire - ce qui peut être le cas
s'il n'y a plus de mémoire disponible - le pointeur renvoyé par malloc a la
valeur spéciale NULL.
Remarque
La zone mémoire renvoyée par malloc n'est pas initialisée. On ne peut pas
supposer qu'elle soit remplie de la valeur 0. Il faut donc manuellement
initialiser la mémoire après le retour de malloc.
Libération¶
Pour libérer la mémoire, on utilise la fonction free également présente dans stdlib.h et
dont la signature est :
void free(void *ptr);
Remarque
Il est très important d'utiliser uniquement un pointeur obtenu précédemment par
un appel à malloc (et pas encore libéré) et de ne pas l'utiliser plus d'une fois.
Le programme suivant provoque une erreur free(): invalid pointer à
l'exécution mais est détecté par un avertissement du compilateur :
warning: attempt to free a non-heap object ‘a’.
#include <stdlib.h>
int main()
{
int a;
free(&a);
return 0;
}
Le programme suivant alloue un tableau de deux char et appelle free sur
l'adresse de la seconde case. En faisant cela, il n'y a pas d'avertissement car
on appelle free sur un objet qui est effectivement sur le tas. On obtient
alors à nouveau une erreur à l'exécution free(): invalid pointer.
#include <stdlib.h>
int main()
{
char *a = malloc(2);
free(&(a[1]));
return 0;
}
Le programme suivant libère deux fois la mémoire et provoque l'erreur
free(): double free detected in tcache 2 à l'exécution.
#include <stdlib.h>
int main()
{
char *a = malloc(2);
free(a);
free(a);
return 0;
}
Protection mémoire¶
Comme on l'a vu dans la première partie, quand un programme s'exécute il a un environnement mémoire constitué de plusieurs zones, parfois appelées segments, avec le droit d'écriture dans certaines d'entre elles.
Le système d'exploitation protège ainsi la mémoire et, au niveau matériel,
l'unité de gestion de la mémoire connait les adresses accessibles à un
programme. En cas d'accès anormal, le matériel provoque une erreur qui remonte
au système d'exploitation qui termine l'exécution du programme avec une erreur
souvent intitulée Segmentation fault.
Voici quelques exemples commentés produisant des erreurs de type segmentation fault
à l'exécution.
Lecture à l'adresse 0 (pointeur NULL), ce qui provoque en pratique toujours
une erreur sur les systèmes usuels. Même problème avec une adresse inaccessible
ou invalide.
int main()
{
int *a = 0;
return a[0];
}
Écriture dans une zone en lecture seule comme le segment du code.
int main()
{
int *a = (int*) (&main);
// a pointe sur le corps de la fonction main
*a = 0;
return 0;
}
Réalisation d'un système d'allocation de mémoire¶
Note
Prérequis : listes chaînées
Afin de comprendre comment fonctionne le tas, et en particulier malloc et free, on va simuler ici ce comportement en allouant une grande plage de mémoire avec malloc et en gérant le découpage et l'allocation de celle-ci.
Pour gérer les blocs mémoires libres, on utilise une liste circulaire. Une liste circulaire est une liste chaînée avec un lien supplémentaire entre le premier et le dernier maillon, ce qui fait qu'on peut considérer n'importe quel maillon comme étant la tête de la liste.
Une liste chaînée :
Ici le dernier maillon comprend un pointeur qui ne pointe sur rien indiqué par
\(\times\), en pratique il a la valeur NULL.
Une liste circulaire :
Le seul changement est donc de faire pointer le dernier maillon sur le premier. Le fait d'avoir préservé les valeurs dans les maillons permet ici de voir ce qu'est devenu le premier maillon, mais on peut accéder à cette liste par n'importe lequel de ces maillons.
Les nœuds de la liste circulaire des blocs auront pour valeur un triplet
(adresse,taille,libre) qui indique qu'à l'adresse adresse il y a un bloc de
taille octets et le booléen libre indique si ce bloc a été alloué ou non.
Pour cela on commence par définir une structure bloc et une fonction de
création d'un bloc :
struct bloc {
void *adresse;
uint32_t taille;
bool libre;
struct bloc *suivant;
};
struct bloc *cree_bloc(void *adresse, uint32_t taille, bool libre)
{
struct bloc *b = malloc(sizeof(struct bloc));
b->adresse = adresse;
b->taille = taille;
b->libre = libre;
return b;
}
Ce code nécessite d'inclure stdlib.h, stdint.h (pour uint32_t) et
stdbool.h (pour bool), ainsi que assert.h et stdio.h pour la suite.
On définit ensuite deux variables globales :
blocs_libresqui va pointer sur un maillon de la liste circulaire des blocsplage_memoirequi pointe sur l'adresse de la plage mémoire que l'on va gérer et servira à la libérer en sortie de programme.
struct bloc *blocs_libres;
void *plage_memoire;
La fonction creation_blocs_libres permet de créer la liste circulaire avec un premier
bloc qui pointe sur lui-même et qui correspond à l'adresse que l'on va placer
dans plage_memoire.
void creation_blocs_libres(uint32_t taille_bloc_initial)
{
plage_memoire = malloc(taille_bloc_initial);
blocs_libres = cree_bloc(plage_memoire, taille_bloc_initial, true);
blocs_libres->suivant = blocs_libres; // boucle initiale
}
Pour libérer la liste à la sortie du programme, on définit la fonction
destruction_blocs_libres qui présente ainsi le parcours usuel d'une liste
circulaire : on procède comme pour une liste chaînée classique mais, au lieu
d'utiliser un test bloc_courant->suivant == NULL pour l'arrêt, il faut se
souvenir du premier bloc et tester pour voir si on est revenu au point de
départ. On n'oublie pas de libérer l'espace plage_memoire à la fin.
void destruction_blocs_libres()
{
struct bloc *premier_bloc = blocs_libres;
struct bloc *bloc_courant = premier_bloc;
// on boucle pour libérer chaque maillon
while (true) {
struct bloc *bloc_suivant = bloc_courant->suivant;
free(bloc_courant);
if (bloc_suivant == premier_bloc) break;
bloc_courant = bloc_suivant;
}
// on libère la plage mémoire initiale
free(plage_memoire);
}
Pour allouer \(t\) octets, on parcourt la liste des blocs jusqu'à trouver un bloc
\(b\) libre de taille \(b.t\) telle que \(b.t \ge t\). Si un tel bloc n'existe pas, on renvoie le
pointeur NULL signe d'un échec d'allocation. Sinon, on indique que le bloc
est occupé, on va renvoyer l'adresse du bloc obtenu mais, si \(b.t > t\) on
insère après \(b\) un nouveau bloc libre de taille \(b.t - t\). Dans tous les cas,
on fait pointer la liste des blocs libres vers le bloc qui suit \(b\), qui est
peut-être le bloc nouvellement créé et a de grandes chances d'être libre.
Ce mécanisme est implémenté dans la fonction allocation :
void *allocation(uint32_t taille)
{
struct bloc *premier_bloc = blocs_libres;
struct bloc *bloc_courant = premier_bloc;
while (!bloc_courant->libre || bloc_courant->taille < taille)
{
bloc_courant = bloc_courant->suivant;
if (bloc_courant == premier_bloc)
{
// Retour au point de départ : échec d'allocation
return NULL;
}
}
// bloc_courant pointe sur un bloc libre de bonne taille
void *adresse = bloc_courant->adresse;
bloc_courant->libre = false;
if (bloc_courant->taille > taille) {
// on le sépare en deux pour récupérer la place
struct bloc *bloc_libre = cree_bloc((uint8_t *)adresse + taille,
bloc_courant->taille - taille, true);
bloc_courant->taille = taille;
bloc_libre->suivant = bloc_courant->suivant;
bloc_courant->suivant = bloc_libre;
}
// On pointe sur le bloc suivant qui est sûrement libre
blocs_libres = bloc_courant->suivant;
return adresse;
}
Pour libérer un bloc, on parcourt la liste jusqu'à trouver le bloc
correspondant à l'adresse à libérer et on indique que le bloc est libre. Ici,
il y a deux assert permettant de s'assurer que l'adresse est bien celle d'un
bloc et que le bloc n'a pas déjà été libéré.
void liberation(void *adresse)
{
struct bloc *premier_bloc = blocs_libres;
struct bloc *bloc_courant = premier_bloc;
while (bloc_courant->adresse != adresse)
{
bloc_courant = bloc_courant->suivant;
// adresse invalide
assert(bloc_courant != premier_bloc);
}
// pas de double libération
assert(!bloc_courant->libre);
// on libère l'adresse
bloc_courant->libre = true;
}
Voici un premier programme de test de ces fonctions qui alloue 10 octets puis
effectue plusieurs allocations. L'allocation de c échoue car il n'y a plus
de place libre.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Ce programme affiche alors
Allocation a:0x55c6c0c922a0 b:0x55c6c0c922a5 c:(nil) d:0x55c6c0c922a8
On remarque que d n'est pas placé à l'adresse libérée de b : la recherche
commence au bloc pointé par blocs_libres, c'est-à-dire au bloc libre de 2 octets
créé lors de l'allocation de b.
Voici l'évolution de la liste circulaire en présentant les maillons sous la forme :
L'évolution de la liste des blocs entre les lignes 3 et 9 est alors :
-
Ligne 3
-
Ligne 5
-
Ligne 6
-
Ligne 8
-
Ligne 9
Cette méthode d'allocation a un défaut majeur : elle fragmente l'espace libre.
Dans le programme suivant, il sera impossible d'allouer b car la liste
circulaire contient deux blocs libres de 5 octets et non un bloc libre de 10
octets.
creation_blocs_libres(10);
uint8_t *a = allocation(5);
liberation(a);
uint8_t *b = allocation(10);
Remarque
On peut observer ce phénomène sur le diagramme précédent à la ligne 8 où deux blocs contigus sont libres et pourraient être fusionnés en un unique bloc de 5 octets.
On peut éviter cela en effectuant une phase de coalescence des blocs libres à la libération. Comme la liste circulaire est simplement chaînée, on ne peut fusionner un bloc libre qu'avec les blocs suivants. En utilisant une liste circulaire doublement chaînée, on pourrait également fusionner avec les blocs précédents.
Deux précautions sont nécessaires :
- les maillons de la liste sont rangés par adresses croissantes à partir du bloc
d'adresse
plage_memoire(les découpages insèrent toujours le nouveau bloc juste après le bloc découpé) ; le maillon qui suit le dernier bloc est donc le premier bloc de la plage, qui ne lui est pas contigu en mémoire : il ne faut pas les fusionner ; - le maillon pointé par
blocs_librespeut faire partie des maillons fusionnés et donc libérés : on fait alors pointerblocs_libressur le bloc libéré.
Pour cela on change la fonction liberation ainsi :
void liberation(void *adresse)
{
struct bloc *premier_bloc = blocs_libres;
struct bloc *bloc_courant = premier_bloc;
while (bloc_courant->adresse != adresse)
{
bloc_courant = bloc_courant->suivant;
// adresse invalide
assert(bloc_courant != premier_bloc);
}
// pas de double libération
assert(!bloc_courant->libre);
// on libère l'adresse
bloc_courant->libre = true;
premier_bloc = bloc_courant;
bloc_courant = bloc_courant->suivant;
// on fusionne avec les blocs libres suivants qui sont contigus
while (bloc_courant != premier_bloc && bloc_courant->libre
&& bloc_courant->adresse != plage_memoire)
{
struct bloc* actuel = bloc_courant;
premier_bloc->taille += bloc_courant->taille;
bloc_courant = bloc_courant->suivant;
free(actuel); // on libere le bloc inutile
}
premier_bloc->suivant = bloc_courant;
// blocs_libres pointait peut-être sur un maillon libéré
blocs_libres = premier_bloc;
}