Séance 5 — Allocation dynamique et structures chaînées

Propriété, tableau dynamique, listes, piles et files, erreurs du tas, intérieur de malloc

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

Des structures de données qui grandissent à l’exécution : un tableau dynamique qui double sa capacité, une liste de nœuds alloués un à un, une pile et une file sur l’un ou l’autre. Chaque bloc a un propriétaire qui le libère ; les erreurs se lisent dans les rapports de valgrind et d’AddressSanitizer, et l’en-tête des blocs montre ce que fait malloc.

  • Dire qui alloue, qui libère et jusqu’à quand un bloc est valide (la propriété).
  • Programmer un tableau dynamique qui double, justifier son coût amorti, éviter les pointeurs invalidés par realloc.
  • Insérer, chercher, supprimer et libérer dans une liste chaînée, avec un pointeur de pointeur.
  • Réaliser une pile et une file sur tableau dynamique et sur liste.
  • Lire dans un rapport de valgrind ou d’AddressSanitizer une fuite, un double free, un use after free.
  • Lire les en-têtes des blocs de la glibc et suivre la liste libre d’un allocateur first fit.

La propriété d’un bloc

Rappel de la Séance 3

Les structures de données de la séance reposent sur les quatre fonctions de <stdlib.h> vues en Séance 3.

appel rend à retenir
malloc(n) un bloc de \(n\) octets, ou NULL contenu indéterminé
calloc(k, t) un bloc de \(k\times t\) octets à zéro, ou NULL
realloc(p, n) le bloc redimensionné, ou NULL peut déplacer le bloc ; en cas d’échec, p reste valide
free(p) rien free(NULL) est sans effet ; p ne doit plus servir

Les erreurs déjà rencontrées

La fuite de mémoire (un bloc dont plus aucune variable ne garde l’adresse), le double free (arrêt par la glibc, SIGABRT), l’accès après free (comportement indéfini : la lecture rendait 1431655769, le lien écrit par la glibc dans le bloc libéré).

Lecture. Un tableau dynamique ou une liste multiplie les blocs : il faut savoir, pour chacun, qui le libère et quand.

Quoi, pourquoi, quand ça échoue : la propriété

Quoi

Le propriétaire d’un bloc est le code chargé de le libérer, une fois et une seule. Une fonction qui rend un bloc alloué en transfère la propriété à l’appelant ; une fonction qui reçoit un pointeur sans le libérer l’emprunte : le bloc doit rester valide pendant l’appel. La durée de vie d’un bloc va du malloc au free.

Pourquoi

Le C ne libère rien de lui-même : la propriété, écrite dans le contrat de chaque fonction (« l’appelant libère »), dit où placer chaque free. Une structure qui possède ses blocs se libère par une fonction dédiée (vec_liberer, liberer).

Quand ça échoue

Aucun propriétaire : fuite. Deux propriétaires : double free. Un emprunteur qui garde l’adresse au-delà de la durée de vie : pointeur pendant, puis use after free.

Emprunt d’une adresse, copie possédée

char *dupliquer(const char *s)
{
    char *d = malloc(strlen(s) + 1);
    if (d != NULL)
        strcpy(d, s);
    return d;
}
int main(void)
{
    const char *villes[3] = {"Thies", "Kaolack",
                             "Kolda"};
    char tampon[16];
    char *emprunt[3];
    char *copie[3];
    for (int i = 0; i < 3; i++) {
        strcpy(tampon, villes[i]);
        emprunt[i] = tampon;   /* emprunt */
        copie[i] = dupliquer(tampon);  /* copie */
    }
    for (int i = 0; i < 3; i++) {
        printf("%s, %s\n", emprunt[i], copie[i]);
        free(copie[i]);
    }
    return 0;
}

Résultat \(\rightarrow\)

Kolda, Thies
Kolda, Kaolack
Kolda, Kolda

Sortie d’erreur sous valgrind \(\rightarrow\)

All heap blocks were freed -- no leaks are possible

Lecture. Les emprunt[i] désignent tous tampon ; dupliquer rend un bloc que main possède et libère.

Le tableau dynamique

Le type Vecteur

typedef struct {
    int *donnees;       /* bloc du tas */
    size_t taille;      /* elements ranges */
    size_t capacite;    /* places du bloc */
} Vecteur;
int vec_init(Vecteur *v, size_t cap)
{
    v->donnees = malloc(cap * sizeof(int));
    v->taille = 0;
    v->capacite = cap;
    return v->donnees != NULL;
}
void vec_liberer(Vecteur *v)
{
    free(v->donnees);
    *v = (Vecteur){NULL, 0, 0};
}
int vec_ajouter(Vecteur *v, int x)
{
    if (v->taille == v->capacite) {
        size_t cap = v->capacite ? 2 * v->capacite : 1;
        int *t = realloc(v->donnees, cap * sizeof *t);
        if (t == NULL)
            return 0;   /* ancien bloc intact */
        v->donnees = t;
        v->capacite = cap;
    }
    v->donnees[v->taille] = x;
    v->taille++;
    return 1;
}

Lecture. Plein, le bloc double (une capacité 0 passe à 1) ; si realloc échoue, l’ancien bloc reste valide (et à libérer).

Un tableau dynamique, pas à pas

Adresses simplifiées ; sous gdb, v est en 0x7fffffffec50 (pile : dépend de l’environnement et du chemin du programme), le bloc en 0x5555555592a0 (tas).

Sortie \(\rightarrow\)

4 4

Lecture. Ligne 36 : v, 24 octets dans la pile (pointeur, taille, capacité), non initialisés.

Lecture. malloc(8) : la glibc réserve un bloc de 32 octets, dont 24 utiles, et en garde la taille dans l’en-tête, 8 octets avant l’adresse rendue (0x21 : 32, bit 0 à 1).

Lecture. donnees[0] = 7, puis taille passe à 1.

Lecture. taille \(=\) capacite \(=\) 2 : le bloc est plein.

Lecture. Plein : realloc demande 16 octets. Le bloc en a 24 d’utiles : la glibc le garde en place, même adresse 0x40, même en-tête ; capacite double.

Lecture. Quatrième élément, sans réallocation.

Lecture. De nouveau plein : l’ajout suivant demandera 32 octets, plus que les 24 utiles du bloc.

Coût amorti du doublement

        if (taille == cap) {
            size_t nc = pas == 0 ? 2 * cap : cap + pas;
            int *u = realloc(t, nc * sizeof(int));
            if (u == NULL)
                break;
            appels++;
            pire += taille;
            if (u != t)
                deplacements++;
            t = u;
            cap = nc;
        }

Résultat \(\rightarrow\)

double n =    1000 :    10 realloc,     1.02 copies par ajout au pire, 0 deplacements
double n = 1000000 :    20 realloc,     1.05 copies par ajout au pire, 7 deplacements
+1     n =    1000 :   999 realloc,   499.50 copies par ajout au pire, 1 deplacements
+1     n =  100000 : 99999 realloc, 49999.50 copies par ajout au pire, 1 deplacements

Lecture. En doublant, \(1+2+\dots+2^{k-1}<2^k<2n\) : moins de 2 copies par ajout (coût amorti constant) ; de 1 en 1, \(n/2\).

Un pointeur dans le bloc, après realloc

int *t = malloc(2 * sizeof(int));
t[0] = 221;
int *premier = &t[0];  /* dans le bloc */
char *voisin = malloc(16);  /* suit t */
int *u = realloc(t, 8 * sizeof(int));
if (u == NULL)
    return 1;
t = u;
printf("t[0] = %d\n", t[0]);
printf("*premier = %d\n", *premier);
free(voisin);
free(t);

Résultat \(\rightarrow\)

t[0] = 221
*premier = 1431655769

Sortie d’erreur avec AddressSanitizer \(\rightarrow\)

==PID==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
    #0 0x555555555391 in main s2_invalide.c:14
...
0x502000000010 is located 0 bytes inside of 8-byte region [0x502000000010,0x502000000018)
freed by thread T0 here:
    #0 0x7ffff78fc778 in realloc asan_malloc_linux.cpp:85
    #1 0x5555555552ea in main s2_invalide.c:9
...
SUMMARY: AddressSanitizer: heap-use-after-free s2_invalide.c:14 in main

Lecture. voisin suit le bloc : realloc le déplace, recopie et libère l’ancien. premier désigne l’ancien bloc : le lire est un use after free (gcc l’avertit ; ici 1431655769). Garder un indice, pas un pointeur.

Exercice : un tableau dynamique dans un dump

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x00 2020 0000 0000 0000 0000 0000 0000 0000
0x08 0505 0000 0000 0000 0000 0000 0000 0000
0x10 0606 0000 0000 0000 0000 0000 0000 0000
0x18 2121 0000 0000 0000 0000 0000 0000 0000
0x20 2424 0000 0000 0000 2929 0000 0000 0000
0x28 1b1b 0000 0000 0000 1e1e 0000 0000 0000
0x30 2323 0000 0000 0000 0000 0000 0000 0000

Vecteur v recopié en 0x00, son bloc en 0x20 avec l’en-tête de la glibc (adresses abrégées) ; vec_init(&v, 3), puis 5 ajouts.

Énoncé

  1. donnees, taille, capacite ;
  2. les éléments ;
  3. l’en-tête : bloc, octets utiles ;
  4. ajouter 39 puis 28 : realloc, capacité, octets demandés.

Correction

  1. donnees \(=\) 0x20, taille \(=\) 5, capacite \(=\) 6.
  2. 36, 41, 27, 30, 35 ; la case en 0x34 est libre.
  3. 0x21 : 32 octets (bit 0 à part), 24 utiles : 6 int.
  4. 39 remplit la dernière case ; 28 trouve le bloc plein : un realloc de 48 octets, capacité 12.

La liste chaînée

Quoi, pourquoi, quand ça échoue : la liste chaînée

Quoi

Une suite de nœuds alloués un à un sur le tas ; chaque nœud contient une valeur et l’adresse du suivant (Séance 4), NULL pour le dernier. La liste est désignée par un pointeur vers son premier nœud, la tête ; une liste vide est NULL.

Pourquoi

Insérer ou retirer un nœud ne déplace aucun autre élément : on réécrit un ou deux pointeurs. Un nœud ne change jamais d’adresse, si bien qu’un pointeur vers lui reste valide tant qu’il est dans la liste, contrairement à un élément de tableau dynamique.

Quand ça échoue

Atteindre le \(k\)e élément parcourt \(k\) nœuds. Chaque nœud coûte un malloc : 16 octets de nœud, un bloc de 32 dans la glibc. Pour modifier la tête, une fonction reçoit son adresse (Noeud **).

Insertion en tête, pas à pas

Adresses simplifiées ; sous gdb, tete est en 0x7fffffffec60 (pile : selon l’environnement), nœuds en 0x5555555592a0 et 0x5555555592c0.

Sortie \(\rightarrow\)

5 -> 12 -> NULL

Lecture. tete = NULL : la liste est vide.

Lecture. L’appel reçoit &tete : pt contient l’adresse de tete (0x40), et non sa valeur.

Lecture. malloc rend un bloc de 16 octets en 0x80, non initialisé.

Lecture. n->valeur reçoit 12.

Lecture. *pt lit tete à travers pt : NULL ; le nouveau nœud précède l’ancienne tête.

Lecture. *pt = n écrit dans tete, la variable de main : tete vaut 0x80.

Lecture. Second appel : un nouveau stack frame, au même endroit ; pt vaut encore &tete.

Lecture. Second bloc en 0xA0, 32 octets plus loin (16 de nœud, 16 d’en-tête et d’arrondi).

Lecture. n->valeur reçoit 5.

Lecture. n->suivant = *pt : l’ancienne tête, 0x80.

Lecture. tete vaut 0xA0 : 5 est en tête.

Lecture. Le parcours part de tete ; avec un paramètre Noeud *, seule une copie aurait changé.

Sans pointeur de pointeur

void inserer_tete(Noeud *tete, int v)  /* copie */
{
    Noeud *n = malloc(sizeof *n);
    n->valeur = v;
    n->suivant = tete;
    tete = n;           /* la copie */
}
int main(void)
{
    Noeud *tete = NULL;
    inserer_tete(tete, 12);
    inserer_tete(tete, 5);
    printf("%p\n", (void *)tete);

Résultat \(\rightarrow\)

(nil)

Sortie d’erreur sous valgrind \(\rightarrow\)

16 bytes in 1 blocks are definitely lost in loss record 1 of 2
   at 0x4846828: malloc (vgpreload_memcheck)
   by 0x109185: inserer_tete (s3_mauvaise.c:9)
   by 0x1091CE: main (s3_mauvaise.c:17)
...
16 bytes in 1 blocks are definitely lost in loss record 2 of 2
   at 0x4846828: malloc (vgpreload_memcheck)
   by 0x109185: inserer_tete (s3_mauvaise.c:9)
   by 0x1091DF: main (s3_mauvaise.c:18)
...
   definitely lost: 32 bytes in 2 blocks
   indirectly lost: 0 bytes in 0 blocks

Lecture. tete = n modifie une copie : la tete de main reste NULL, les deux nœuds sont perdus (32 octets definitely lost).

Insertion en queue, recherche

int inserer_queue(Noeud **pp, int v)
{
    while (*pp != NULL)     /* jusqu'au NULL */
        pp = &(*pp)->suivant;
    Noeud *n = malloc(sizeof *n);
    if (n == NULL)
        return 0;
    n->valeur = v;
    n->suivant = NULL;
    *pp = n;
    return 1;
}
Noeud *chercher(Noeud *p, int v)
{
    while (p != NULL && p->valeur != v)
        p = p->suivant;
    return p;               /* NULL : absent */
}
    afficher(tete);
    Noeud *x = chercher(tete, 9);
    printf("%d\n", x->suivant->valeur);
    printf("%p\n", (void *)chercher(tete, 6));

Résultat \(\rightarrow\)

4 -> 9 -> 2 -> NULL
2
(nil)

Lecture. pp avance de champ suivant en champ suivant jusqu’au NULL final, où *pp = n range le nœud : la liste vide n’est pas un cas à part (pp reste &tete). chercher rend le nœud ou NULL.

Suppression d’un nœud, pas à pas

Sortie \(\rightarrow\) Adresses simplifiées ; gdb : premier nœud 0x5555555592e0, tete 0x7fffffffec60 (pile, selon l’environnement).

3 -> 21 -> NULL

Lecture. Lignes 44 à 46 : nœuds alloués du dernier au premier ; la liste ne suit pas l’ordre des adresses.

Lecture. pp reçoit &tete : *pp est le pointeur à réécrire si le nœud cherché est en tête.

Lecture. *pp vaut 0x80, et (*pp)->valeur vaut 3 : ce n’est pas 8, la boucle continue.

Lecture. pp désigne maintenant le champ suivant du premier nœud, en 0x88.

Lecture. *pp vaut 0x60, dont la valeur est 8 : la boucle s’arrête.

Lecture. *pp n’est pas NULL ; n garde l’adresse du nœud à retirer.

Lecture. *pp = n->suivant écrit 0x40 dans ce champ : le premier nœud saute le deuxième.

Lecture. free(n) rend le bloc. Le même code traite la tête : pp vaut alors &tete.

Lecture. Au retour, pp et n disparaissent avec le stack frame ; la liste compte deux nœuds.

Parcours et libération complète

void liberer(Noeud **pt)
{
    Noeud *p = *pt;
    while (p != NULL) {
        Noeud *s = p->suivant;  /* avant free */
        free(p);
        p = s;
    }
    *pt = NULL;
}
void afficher(const Noeud *p)
{
    for (; p != NULL; p = p->suivant)
        printf("%d -> ", p->valeur);
    printf("NULL\n");
}
    liberer(&tete);
    printf("%p\n", (void *)tete);

Résultat \(\rightarrow\)

(nil)

Sortie d’erreur sous valgrind \(\rightarrow\)

All heap blocks were freed -- no leaks are possible

Lecture. Chaque nœud est un bloc : la liste se libère nœud par nœud, en lisant le suivant avant le free. *pt = NULL laisse à l’appelant une liste vide plutôt qu’une tête pendante.

Exercice : une suppression dans un dump

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x18 2121 0000 0000 0000 0000 0000 0000 0000
0x20 0f0f 0000 0000 0000 00 00 00 00
0x28 6060 0000 0000 0000 0000 0000 0000 0000
0x30 00 00 00 00 00 00 00 00
0x38 2121 0000 0000 0000 0000 0000 0000 0000
0x40 0606 0000 0000 0000 00 00 00 00
0x48 202020 000000 000000 000000 000000 000000 000000 000000
0x50 00 00 00 00 00 00 00 00
0x58 2121 0000 0000 0000 0000 0000 0000 0000
0x60 0909 0000 0000 0000 00 00 00 00
0x68 0000 0000 0000 0000 0000 0000 0000 0000

Trois Noeud du tas avec leurs en-têtes (adresses abrégées) ; tete \(=\) 0x40 ; supprimer du pas à pas.

Énoncé

  1. taille des blocs, des nœuds ;
  2. la liste depuis tete ;
  3. supprimer(&tete, 15) : pp, octets modifiés ;
  4. puis supprimer(&tete, 6).

Correction

  1. En-têtes 0x21 : 32 octets par nœud de 16.
  2. 6 (0x40), 15 (0x20), 9 (0x60), puis NULL.
  3. pp : &tete, puis 0x48 ; en 0x48 : 60 00 …\ 00 ; bloc 0x20 libéré.
  4. 6 est en tête : *pp est tete, qui devient 0x60 ; puis free du bloc 0x40.

Piles et files

Une pile, deux réalisations

int vec_depiler(Vecteur *v, int *x)
{
    if (v->taille == 0)
        return 0;           /* pile vide */
    v->taille--;
    *x = v->donnees[v->taille];
    return 1;
}
    while (vec_depiler(&a, &x))
        printf("%d ", x);
    printf("| ");
    while (liste_depiler(&b, &x))
        printf("%d ", x);
    printf("\n");
int liste_depiler(Noeud **pt, int *x)
{
    Noeud *n = *pt;
    if (n == NULL)
        return 0;
    *x = n->valeur;
    *pt = n->suivant;
    free(n);
    return 1;
}
    for (int i = 1; i <= 5; i++) {
        vec_ajouter(&a, 10 * i);
        inserer_tete(&b, 10 * i);
    }

Résultat \(\rightarrow\)

50 40 30 20 10 | 50 40 30 20 10 

Lecture. Une pile rend d’abord le dernier élément rangé (LIFO). Sur le Vecteur, empiler est vec_ajouter, le sommet est la fin ; sur une liste, empiler est inserer_tete, le sommet est la tête. Mêmes sorties.

Une file sur liste

typedef struct {
    Noeud *tete;            /* on defile ici */
    Noeud *queue;           /* on enfile ici */
} File;
int defiler(File *f, int *x)
{
    Noeud *n = f->tete;
    if (n == NULL)
        return 0;
    *x = n->valeur;
    f->tete = n->suivant;
    if (f->tete == NULL)
        f->queue = NULL;    /* file vide */
    free(n);
    return 1;
}
int enfiler(File *f, int x)
{
    Noeud *n = malloc(sizeof *n);
    if (n == NULL)
        return 0;
    n->valeur = x;
    n->suivant = NULL;
    if (f->queue == NULL)
        f->tete = n;        /* file vide */
    else
        f->queue->suivant = n;
    f->queue = n;
    return 1;
}

Résultat \(\rightarrow\)

4 9 2 | 1

Lecture. Une file rend les éléments dans l’ordre d’arrivée (FIFO) : queue évite de parcourir la liste pour enfiler. Cas limites : enfiler dans une file vide met tete à jour ; défiler le dernier remet queue à NULL.

Une file circulaire sur tableau dynamique

typedef struct {
    int *t;
    size_t debut;       /* indice du premier */
    size_t n;           /* nombre d'elements */
    size_t cap;
} FileC;
int fc_enfiler(FileC *f, int x)
{
    if (f->n == f->cap && !agrandir(f))
        return 0;
    f->t[(f->debut + f->n) % f->cap] = x;
    f->n++;
    return 1;
}
int fc_defiler(FileC *f, int *x)
{
    if (f->n == 0)
        return 0;
    *x = f->t[f->debut];
    f->debut = (f->debut + 1) % f->cap;
    f->n--;
    return 1;
}
    fc_enfiler(&f, 4);
    fc_enfiler(&f, 9);
    fc_enfiler(&f, 2);
    fc_defiler(&f, &x);
    fc_defiler(&f, &x);
    fc_enfiler(&f, 7);
    fc_enfiler(&f, 5);
    etat(&f);

Lecture. debut avance modulo cap ; la case libre est (debut + n) % cap : 7 et 5 prennent les cases 3 et 0.

Agrandissement d’une file circulaire

/* double la capacite, remet dans l'ordre */
int agrandir(FileC *f)
{
    size_t c = 2 * f->cap;
    int *u = calloc(c, sizeof *u);
    if (u == NULL)
        return 0;
    for (size_t i = 0; i < f->n; i++)
        u[i] = f->t[(f->debut + i) % f->cap];
    free(f->t);
    f->t = u;
    f->debut = 0;
    f->cap = c;
    return 1;
}
    fc_enfiler(&f, 6);
    fc_enfiler(&f, 8);
    etat(&f);

Résultat \(\rightarrow\)

debut 2, n 3, cap 4 : 5 9 2 7
debut 0, n 5, cap 8 : 2 7 5 6 8 0 0 0

Lecture. Pleine (n == cap), la file passe à un bloc deux fois plus grand où les éléments sont recopiés dans l’ordre de la file : un realloc garderait l’ordre des cases (5 6 2 7).

Coût des opérations

opération tableau dynamique liste chaînée
ajout en fin \(O(1)\) amorti (doublement) \(O(1)\) avec un pointeur queue, sinon \(O(n)\)
ajout en tête \(O(n)\) (décalage) \(O(1)\)
accès au \(k\)e élément \(O(1)\) \(O(k)\)
recherche d’une valeur \(O(n)\) \(O(n)\)
suppression d’un élément trouvé \(O(n)\) (décalage) \(O(1)\) si l’on tient pp
mémoire par élément sa taille (plus les places libres) un bloc : 32 octets pour un int (glibc)
pointeurs vers les éléments invalidés par realloc stables jusqu’à la suppression

Choix

Pile : tableau dynamique ou liste, toutes deux en \(O(1)\). File : tableau circulaire, ou liste avec tete et queue. Le tableau range les éléments côte à côte (un seul bloc, accès par indice) ; la liste évite toute recopie et garde chaque élément à son adresse.

Exercice : une file circulaire

FileC f, capacité 4, t mis à zéro par calloc, debut \(=\) n \(=\) 0. Opérations, dans l’ordre :

enfiler 5, 8, 3 ; defiler ; defiler ; enfiler 6, 1, 2 ; enfiler 4.

Énoncé

  1. l’état (debut, n, t) après les trois premiers enfiler ;
  2. les valeurs défilées, et l’état ;
  3. l’état après enfiler 6, 1, 2 ;
  4. ce que fait enfiler 4, et l’état final.

Correction

  1. Cases 0, 1, 2 : debut 0, n 3, t \(=\) 5 8 3 0.
  2. 5 puis 8 ; debut 2, n 1 ; t est inchangé (5 8 3 0) : défiler ne fait qu’avancer debut.
  3. 6 en case \((2+1)\%4=3\), 1 en case 0, 2 en case 1 : debut 2, n 4, t \(=\) 1 2 3 6 : pleine.
  4. Pleine : bloc de 8 cases, éléments recopiés depuis debut : 3 6 1 2, puis 4 en case 4 ; debut 0, n 5, t \(=\) 3 6 1 2 4 0 0 0.

Les erreurs du tas

La fuite d’une liste

    Noeud *tete = nouveau(21, NULL);
    tete = nouveau(8, tete);
    tete = nouveau(3, tete);
    printf("%d\n", tete->valeur);
    free(tete);

Résultat \(\rightarrow\)

3

Sortie d’erreur sous valgrind \(\rightarrow\)

32 (16 direct, 16 indirect) bytes in 1 blocks are definitely lost in loss record 2 of 2
   at 0x4846828: malloc (vgpreload_memcheck)
   by 0x1091A5: nouveau (s5_fuite.c:9)
   by 0x1091FB: main (s5_fuite.c:19)
...
   definitely lost: 16 bytes in 1 blocks
   indirectly lost: 16 bytes in 1 blocks

Lecture. Seule la tête est rendue. Le deuxième nœud n’est plus désigné que par un bloc libéré : definitely lost ; le troisième, par le deuxième : indirectly lost. LeakSanitizer donne la même répartition.

Libération qui lit un nœud libéré

    Noeud *tete = nouveau(21, NULL);
    tete = nouveau(8, tete);
    tete = nouveau(3, tete);
    for (Noeud *p = tete; p; p = p->suivant)
        free(p);
    printf("liste liberee\n");

Compilateur \(\rightarrow\)

s5_liberer.c:21:32: warning: pointer 'p' used after 'free' [-Wuse-after-free]

Fin du processus : signal SIGSEGV (Segmentation fault)

Sortie d’erreur avec AddressSanitizer \(\rightarrow\)

==PID==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000058
READ of size 8 at 0x502000000058 thread T0
    #0 0x555555555383 in main s5_liberer.c:21
...
0x502000000058 is located 8 bytes inside of 16-byte region [0x502000000050,0x502000000060)
freed by thread T0 here:
    #0 0x7ffff78fc4d8 in free asan_malloc_linux.cpp:52
    #1 0x55555555535e in main s5_liberer.c:22
...
SUMMARY: AddressSanitizer: heap-use-after-free s5_liberer.c:21 in main

Lecture. free(p) a écrit à l’offset 8, là où était suivant, la clé de la glibc (aléatoire) : p reçoit cette clé, et le free de cette adresse arrête le processus (ici SIGSEGV). ASan s’arrête dès la lecture.

Un pointeur pendant après une suppression

    Noeud *tete = nouveau(21, NULL);
    tete = nouveau(8, tete);
    tete = nouveau(3, tete);
    Noeud *x = chercher(tete, 8);
    supprimer(&tete, 8);
    printf("%d\n", x->valeur);
    liberer(&tete);

Résultat \(\rightarrow\)

1431655769

Sortie d’erreur avec AddressSanitizer \(\rightarrow\)

==PID==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000030
READ of size 4 at 0x502000000030 thread T0
    #0 0x555555555757 in main s5_pendant.c:49
...
0x502000000030 is located 0 bytes inside of 16-byte region [0x502000000030,0x502000000040)
freed by thread T0 here:
    #0 0x7ffff78fc4d8 in free asan_malloc_linux.cpp:52
    #1 0x555555555517 in supprimer s5_pendant.c:29
...
SUMMARY: AddressSanitizer: heap-use-after-free s5_pendant.c:49 in main

Lecture. x garde l’adresse du nœud 8 après sa suppression : un pointeur pendant ; sans outil, x->valeur lit 1431655769. Le rapport dit quoi (4 octets lus dans un bloc libéré), où (ligne 49) et qui a libéré (supprimer).

Le même accès sous valgrind

Résultat \(\rightarrow\)

8

Sortie d’erreur sous valgrind \(\rightarrow\)

Invalid read of size 4
   at 0x109368: main (s5_pendant.c:49)
 Address 0x4a7e090 is 0 bytes inside a block of size 16 free'd
   at 0x484988F: free (vgpreload_memcheck)
   by 0x109290: supprimer (s5_pendant.c:29)
   by 0x109363: main (s5_pendant.c:48)
 Block was alloc'd at
   at 0x4846828: malloc (vgpreload_memcheck)
   by 0x1091C5: nouveau (s5_pendant.c:9)
   by 0x109324: main (s5_pendant.c:45)
valgrind ASan
recompiler non oui
bloc libéré mis de côté interdit
après l’erreur continue s’arrête
fuites bilan final LeakSanitizer
valeur non initialisée signalée à l’usage (test, appel, affichage) non

Lecture. valgrind exécute le programme tel quel et retarde la réutilisation des blocs libérés : la lecture rend encore 8, mais elle est signalée, avec le free et l’allocation. Les deux outils se complètent.

Un double free par un pointeur pendant

    Noeud *tete = nouveau(21, NULL);
    tete = nouveau(8, tete);
    tete = nouveau(3, tete);
    Noeud *x = chercher(tete, 8);
    supprimer(&tete, 8);
    free(x);
    liberer(&tete);

Sortie d’erreur \(\rightarrow\)

free(): double free detected in tcache 2

Fin du processus : signal SIGABRT (Aborted)

Sortie d’erreur avec AddressSanitizer \(\rightarrow\)

==PID==ERROR: AddressSanitizer: attempting double-free on 0x502000000030 in thread T0:
    #0 0x7ffff78fc4d8 in free asan_malloc_linux.cpp:52
    #1 0x5555555556d4 in main s5_double.c:49
...
0x502000000030 is located 0 bytes inside of 16-byte region [0x502000000030,0x502000000040)
freed by thread T0 here:
    #0 0x7ffff78fc4d8 in free asan_malloc_linux.cpp:52
    #1 0x5555555554b7 in supprimer s5_double.c:29
...
SUMMARY: AddressSanitizer: double-free asan_malloc_linux.cpp:52 in free

Lecture. supprimer a déjà rendu le nœud : free(x) le libère une seconde fois. La glibc le détecte ici grâce à la clé du bloc libéré (section 6), sans garantie ; AddressSanitizer nomme les deux free.

Exercice : diagnostiquer quatre programmes

int *t = malloc(3 * sizeof(int));  /* (a) */
for (int i = 0; i <= 3; i++)
    t[i] = 10 * i;
Noeud *p = tete;                   /* (b) */
free(tete);
tete = NULL;
printf("%d\n", p->valeur);
for (int i = 0; i < 3; i++) {      /* (c) */
    Noeud *n = malloc(sizeof *n);
    n->valeur = i;
    n->suivant = tete;
    tete = n;
}
tete = NULL;
Pile a = {malloc(4 * sizeof(int)), 0};  /* (d) */
Pile b = a;
free(a.t);
free(b.t);

Noeud comme dans la séance ; Pile : struct { int *t; size_t n; }. Compilés avec -fsanitize=address : nommer l’erreur, la ligne du rapport qui la décrit, et la correction.

Correction

  1. heap-buffer-overflow, WRITE of size 4, « 0 bytes after 12-byte region » : t[3] ; écrire i < 3.
  2. heap-use-after-free, READ of size 4 : p pend ; lire avant le free.
  3. LeakSanitizer : 16 octets directs, 32 indirects ; libérer avant de perdre tete.
  4. attempting double-free : b copie le pointeur, pas le bloc ; un seul free.

L’intérieur de malloc

En-tête et alignement des blocs

size_t demande[6] = {0, 1, 24, 25, 40, 100};
unsigned char *p[6];
for (int i = 0; i < 6; i++)
    p[i] = malloc(demande[i]);     /* avant tout printf */
for (int i = 0; i < 6; i++) {
    size_t entete = *(size_t *)(p[i] - 8);    /* 8 octets avant le bloc */
    size_t bloc = entete & ~(size_t)7;          /* sans les 3 bits de poids faible */
    printf("malloc(%3zu) : %p  en-tete 0x%02zx  bloc %3zu  utile %3zu\n",
           demande[i], (void *)p[i], entete, bloc, bloc - 8);
}

Résultat \(\rightarrow\)

malloc(  0) : 0x5555555592a0  en-tete 0x21  bloc  32  utile  24
malloc(  1) : 0x5555555592c0  en-tete 0x21  bloc  32  utile  24
malloc( 24) : 0x5555555592e0  en-tete 0x21  bloc  32  utile  24
malloc( 25) : 0x555555559300  en-tete 0x31  bloc  48  utile  40
malloc( 40) : 0x555555559330  en-tete 0x31  bloc  48  utile  40
malloc(100) : 0x555555559360  en-tete 0x71  bloc 112  utile 104

Lecture. Ici glibc 2.39 : bloc \(=\) plus petit multiple de 16 \(\geq\) demande \(+\) 8, au moins 32 ; bit 0 : précédent occupé. Lire avant le bloc : comportement indéfini, ici pour observer seulement.

Dans le tas de la glibc, pas à pas

Adresses simplifiées ; a est en 0x5555555592a0, son en-tête en 0x555555559298. Ici glibc 2.39 ; la clé, tirée au hasard, n’est pas montrée.

Sortie \(\rightarrow\)

1

Lecture. malloc(20) : un bloc de 32 octets, dont 24 utiles ; l’en-tête 0x21 précède l’adresse rendue (a \(=\) 0x40).

Lecture. Le bloc suivant commence juste après : son en-tête en 0x58, b \(=\) 0x60.

Lecture. « Matam » et son terminateur : 6 octets.

Lecture. « Podor » dans b.

Lecture. ua garde l’adresse de a en entier, avant le free : après, le pointeur ne doit plus servir, pas même à une comparaison.

Lecture. free(a) : le bloc entre dans le tcache ; ses 8 premiers octets reçoivent le lien vers le suivant (NULL masqué par adresse >> 12), les 8 autres une clé contre le double free.

Lecture. malloc(20) reprend le dernier bloc libéré de cette taille : c \(=\) 0x40. La clé est effacée, le lien reste : un bloc rendu par malloc n’est pas initialisé.

Lecture. « Louga » recouvre le lien.

Lecture. c et l’ancienne valeur de a sont égales : un pointeur gardé après free désigne désormais les données de c.

Dernier libéré, premier servi

void *a = malloc(40);
void *b = malloc(40);
uintptr_t ua = (uintptr_t)a;    /* en entiers */
uintptr_t ub = (uintptr_t)b;
free(a);
free(b);                /* dernier libere */
void *e = malloc(24);   /* autre taille */
void *c = malloc(40);
void *d = malloc(40);
printf("a %#" PRIxPTR "\nb %#" PRIxPTR "\n", ua, ub);
printf("e %p\nc %p\nd %p\n", e, c, d);
free(c);
free(d);
free(e);

Résultat \(\rightarrow\)

a 0x5555555592a0
b 0x5555555592d0
e 0x555555559300
c 0x5555555592d0
d 0x5555555592a0

Lecture. Les blocs libérés de 48 octets attendent dans une liste propre à leur taille : le dernier rendu est le premier repris (c reçoit b, d reçoit a). malloc(24), un bloc de 32, prend une place neuve.

Un mini-allocateur : les données

Un allocateur pédagogique sur un tableau statique de 64 octets : en-tête de 8 octets, blocs multiples de 16, et une liste libre dont le lien s’écrit dans les données des blocs libres.

typedef struct Bloc {
    size_t taille;          /* en-tete */
    struct Bloc *suivant;   /* si libre */
} Bloc;
static alignas(16) unsigned char tas[TAILLE_TAS];
static Bloc *libre = NULL;  /* liste libre */
static void mon_init(void)
{
    libre = (Bloc *)(tas + 8);
    libre->taille = TAILLE_TAS - 16;
    libre->suivant = NULL;
}
static void mon_free(void *p)
{
    Bloc *b = (Bloc *)((char *)p - 8);
    b->suivant = libre;  /* dans les donnees */
    libre = b;
}

Lecture. Premier en-tête en tas + 8 : les données de chaque bloc tombent sur un multiple de 16. mon_free met le bloc en tête de la liste libre, sans fusionner les voisins. Lire le tableau à travers un Bloc * contourne la règle du type effectif (C17 6.5 §7) : un allocateur réel s’appuie sur son compilateur.

mon_malloc : premier bloc qui convient

static void *mon_malloc(size_t n)
{
    size_t t = (n + 8 + 15) / 16 * 16;
    Bloc **pp = &libre;
    while (*pp != NULL && (*pp)->taille < t)
        pp = &(*pp)->suivant;
    if (*pp == NULL)
        return NULL;
    Bloc *b = *pp;
    if (b->taille - t >= 16) {      /* decoupe */
        Bloc *r = (Bloc *)((char *)b + t);
        r->taille = b->taille - t;
        r->suivant = b->suivant;
        *pp = r;
        b->taille = t;
    } else {
        *pp = b->suivant;   /* en entier */
    }
    return (char *)b + 8;
}

Lecture. pp parcourt la liste libre comme dans supprimer. Le premier bloc assez grand est choisi (first fit) ; un reste d’au moins 16 octets devient un bloc libre, à sa place dans la liste.

Le mini-allocateur, pas à pas

Adresses : décalages dans tas, qui est en 0x555555558020 (.bss) ; les liens sont montrés de même.

Sortie \(\rightarrow\)

c == a : 1

Lecture. Un seul bloc libre de 48 octets en 0x08 : son en-tête, puis le lien NULL. Les données des blocs tomberont sur un multiple de 16.

Lecture. mon_malloc(6) : \(6+8\) arrondi à 16. Le bloc libre est découpé : 16 octets pour a (0x10), le reste, 32 octets, devient le bloc libre en 0x18.

Lecture. « Kolda » occupe 6 des 8 octets de a.

Lecture. mon_malloc(4) : le premier bloc libre assez grand est en 0x18 ; découpé à son tour, il laisse un bloc libre de 16 octets en 0x28.

Lecture. 221 \(=\) 0xdd, en little-endian.

Lecture. mon_free(a) : le bloc passe en tête de la liste libre ; son lien, écrit dans les données, recouvre « Kolda » (comme le tcache de la glibc).

Lecture. mon_malloc(8) : il faut 16 octets ; le premier bloc de la liste, en 0x08, convient exactement : il sort de la liste sans découpe, c \(=\) 0x10.

Lecture. « Fatick » et son terminateur : 7 octets.

Lecture. c réutilise le bloc de a : premier bloc libre qui convient (first fit), dernier libéré en tête de liste.

Exercice : le tas du mini-allocateur

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x00 00 00 00 00 00 00 00 00
0x08 1010 0000 0000 0000 0000 0000 0000 0000
0x10 4848 0000 0000 0000 0000 0000 0000 0000
0x18 2020 0000 0000 0000 0000 0000 0000 0000
0x20 0808 0000 0000 0000 0000 0000 0000 0000
0x28 6f6f 7272 0000 00 00 00 00 00
0x30 00 00 00 00 00 00 00 00
0x38 1010 0000 0000 0000 0000 0000 0000 0000
0x40 eaea 0707 0000 0000 0000 0000 0000 0000
0x48 1010 0000 0000 0000 0000 0000 0000 0000
0x50 0000 0000 0000 0000 0000 0000 0000 0000
0x58 00 00 00 00 00 00 00 00

TAILLE_TAS vaut 96 ; adresses : décalages dans tas, liens compris ; libre \(=\) 0x18.

Énoncé

  1. les blocs depuis 0x08, et la liste libre ;
  2. le contenu des blocs ;
  3. mon_malloc(4) : bloc choisi, octets écrits ;
  4. puis mon_malloc(20).

Correction

  1. En-têtes en 0x08 (16), 0x18 (32), 0x38 (16), 0x48 (16) ; libres : 0x18 \(\rightarrow\) 0x08 \(\rightarrow\) 0x48 ; 0x38 occupé.
  2. 0x40 : ea 07, 2026 ; 0x28 : 6f 72 00, fin de « Ziguinchor », recouvert par le lien.
  3. Il faut 16 : 0x18 (32) est découpé et rend 0x20 ; en-tête 16 ; bloc libre en 0x28 (16, lien 0x08) \(=\) libre.
  4. Il faut 32 : NULL, malgré 48 octets libres, en trois blocs de 16 : fragmentation.

Synthèse

À retenir

À retenir

  1. Chaque bloc a un propriétaire, qui le libère une fois ; une fonction qui rend un bloc en transfère la propriété, une fonction qui reçoit un pointeur l’emprunte.
  2. Un tableau dynamique garde taille et capacité et double la capacité quand il est plein : coût amorti constant par ajout. Le résultat de realloc passe par un pointeur temporaire ; après un ajout qui peut réallouer, les pointeurs dans le bloc sont à recalculer.
  3. Une liste chaînée relie des nœuds alloués un à un ; une fonction qui modifie la tête reçoit un Noeud **, et un pointeur de pointeur qui avance de champ suivant en champ suivant traite insertion et suppression sans cas particulier.
  4. On libère une liste nœud par nœud, en lisant le suivant avant le free.
  5. Pile : tableau ou liste en \(O(1)\) ; file : tableau circulaire, ou liste avec tête et queue.
  6. valgrind et AddressSanitizer nomment l’erreur, la ligne de l’accès, celle du free et celle de l’allocation ; sans outil, ces erreurs passent souvent inaperçues.
  7. La glibc aligne sur 16 et range un en-tête de taille avant chaque bloc ; un bloc libéré garde un lien vers d’autres blocs libres et sera réutilisé.

Prochaine séance

Séance 6 : pointeurs de fonctions et généricité

Les tableaux, listes, piles et files de cette séance ne rangent que des int. La séance suivante les rend génériques : des éléments désignés par void *, une taille d’élément passée en paramètre, et des pointeurs de fonctions pour comparer, afficher ou libérer les éléments (qsort, bsearch).

À préparer

Le TD et le TP de la séance 5 : dumps d’un tableau dynamique, d’une liste et du mini-allocateur, file circulaire, rapports de valgrind et d’AddressSanitizer à lire ; tableau dynamique, liste et allocateur à compléter sur machine.

Références

  • R. E. Bryant, D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective, 3e éd., Pearson, 2016 : § 9.9 (allocation dynamique : en-têtes, alignement, liste libre, placement first fit, découpe, fragmentation), § 9.11 (erreurs de mémoire courantes).
  • R. C. Seacord, Effective C, 2e éd., No Starch Press, 2024 : chap. 6 (mémoire allouée dynamiquement, realloc, erreurs, outils).
  • K. N. King, C Programming: A Modern Approach, 2e éd., Norton, 2008 : chap. 17 (allocation dynamique, listes chaînées, pointeurs de pointeurs).
  • B. W. Kernighan, D. M. Ritchie, The C Programming Language, 2e éd., Prentice Hall, 1988 : chap. 6 (structures auto-référentielles), § 8.7 (un allocateur de mémoire).
  • ISO/IEC 9899:2018 (C17) : 6.2.4 (durée de vie des objets), 7.22.3 (fonctions de gestion de la mémoire).

Mesures de la séance : x86-64, Linux 6.18, gcc 13.3.0 (-std=c17 -Wall -Wextra -O0 -g), glibc 2.39, valgrind 3.22.0 ; adresses rendues déterministes par setarch -R.