Version sans JavaScript, en lecture seule : les ateliers, les quiz et l’exécution du code n’y sont pas. Version complète · Catalogue · Se connecter
Support de cours · chapitre 3
La mémoire d'un programme
Où vivent les données - zone statique, pile et tas. Les constantes et les initialiseurs, la durée de vie des variables locales, malloc et free, la mémoire virtuelle, brk et mmap, les pools et les arènes. Et deux outils pour trouver les erreurs de mémoire.
Trois zones
Les données d'un programme vivent dans trois zones de la mémoire, qui se distinguent par leur durée de vie :
| Zone | Ce qu'on y trouve | Durée de vie | Qui réserve et libère |
|---|---|---|---|
| statique | variables globales, variables static, littéraux | tout le programme | le compilateur et le chargeur |
| pile | variables locales, paramètres, adresses de retour | l'appel de fonction en cours | automatiquement, à l'entrée et à la sortie des fonctions |
| tas | ce qu'on réserve avec malloc | jusqu'au free | le programmeur |
Le programme suivant affiche une adresse de chaque zone, et celle du code :
#include <stdio.h>
#include <stdlib.h>
int globale = 1;
const char *litteral = "une chaine";
int main(void)
{
int locale = 2;
int *tas = malloc(sizeof *tas);
printf("code (main) : %p\n", (void *)main);
printf("littéral : %p\n", (void *)litteral);
printf("globale : %p\n", (void *)&globale);
printf("tas : %p\n", (void *)tas);
printf("pile (locale) : %p\n", (void *)&locale);
free(tas);
return 0;
}
code (main) : 0x5b46218291a9
littéral : 0x5b462182a004
globale : 0x5b462182c010
tas : 0x5b464f80e2a0
pile (locale) : 0x7ffefd7043fc
Le code est en bas, suivi des littéraux (en lecture seule), puis des variables globales. Le tas vient ensuite, et grandit vers le haut. La pile est tout en haut de l'espace d'adressage, et grandit vers le bas. (Convertir l'adresse d'une fonction en void * n'est pas permis par la norme, -pedantic le signale ; c'est accepté sous Linux, et commode pour cette démonstration.)
La zone statique
Les chaînes littérales
Les données statiques sont décrites dans le fichier exécutable lui-même, rangées en sections : .rodata pour les constantes, .data pour les variables initialisées, .bss pour celles qui valent zéro (chapitre 1). Au lancement, le chargeur les met à leur place. On peut le voir dans l'assembleur produit par gcc -S pour ce fichier d'une ligne :
char *s = "une chaine";
.section .rodata
.LC0:
.string "une chaine"
.section .data.rel.local,"aw"
.align 8
.type s, @object
.size s, 8
s:
.quad .LC0
Il y a deux objets. La chaîne elle-même, sous l'étiquette .LC0, dans la section .rodata (read-only data) : le système la chargera dans des pages en lecture seule. Et la variable s, dans une section de données modifiables : 8 octets (.quad) qui contiennent l'adresse .LC0.
On retrouve ce qu'on a vu au chapitre 2 : écrire dans un littéral est un comportement indéfini. Ici, la page est protégée, et le système tue le programme :
#include <stdio.h>
int main(void)
{
const char *s = "chaine";
char *t = (char *)s;
t[0] = 'C';
printf("%s\n", s);
return 0;
}
Segmentation fault (core dumped)
La conversion (char *) fait taire le compilateur ; elle ne rend pas la mémoire modifiable.
Les macros
#define donne un nom à une valeur, pour tout le fichier :
#define TAILLE 100
C'est le préprocesseur qui remplace chaque TAILLE par 100, avant la compilation (chapitre 1). Il ne réserve aucune mémoire : pour le compilateur, TAILLE n'a jamais existé. Mais c'est un remplacement de texte, qui ne respecte ni les priorités ni la portée du C.
#include <stdio.h>
#define CARRE(x) x * x
#define CARRE_BIEN(x) ((x) * (x))
int main(void)
{
printf("%d\n", CARRE(1 + 2));
printf("%d\n", CARRE_BIEN(1 + 2));
printf("%d\n", 100 / CARRE_BIEN(5));
printf("%d\n", 100 / CARRE(5));
return 0;
}
5
9
4
100
CARRE(1 + 2) devient 1 + 2 * 1 + 2, qui vaut 5. 100 / CARRE(5) devient 100 / 5 * 5, qui vaut 100.
Les initialiseurs
Un tableau ou une structure s'initialise avec une liste entre accolades, dont les valeurs sont rangées dans l'ordre :
#include <stdio.h>
struct contact {
const char *nom, *prenom;
int age;
};
struct contact moi = {"Ritchie", "Dennis", 70};
struct contact lui = {.prenom = "Ken", .nom = "Thompson"};
int tab[5] = {4, 3, 2, 1, 0};
int zeros[100] = {0};
int debut[5] = {7, 8};
int main(void)
{
char s[] = {'H', 'e', 'l', 'l', 'o', '\0'};
char s2[] = "Hello";
printf("s = \"%s\", sizeof s = %zu, sizeof s2 = %zu\n", s, sizeof s, sizeof s2);
printf("%s %s, %d ans\n", moi.prenom, moi.nom, moi.age);
printf("%s %s, %d ans\n", lui.prenom, lui.nom, lui.age);
for (int i = 0; i < 5; i++)
printf("%d ", debut[i]);
printf("\n");
printf("%d %d\n", tab[0], zeros[99]);
return 0;
}
s = "Hello", sizeof s = 6, sizeof s2 = 6
Dennis Ritchie, 70 ans
Ken Thompson, 0 ans
7 8 0 0 0
4 0
- La taille d'un tableau initialisé peut être omise : le compilateur la compte.
sets2sont identiques,"Hello"n'est qu'une écriture plus courte de la liste de caractères. - On peut nommer les champs d'une structure (
.prenom = …), dans n'importe quel ordre. - Tout ce qui n'est pas initialisé vaut zéro, dès qu'une partie l'est :
debutfinit par trois zéros,lui.agevaut 0, et{0}met à zéro tout un tableau. - Les variables globales (et
static) sans initialiseur valent zéro elles aussi. Les variables locales sans initialiseur, elles, contiennent n'importe quoi.
La pile
Le cadre d'une fonction
À chaque appel de fonction, un cadre (stack frame) est empilé : il contient les paramètres, les variables locales, l'adresse où revenir à la fin de l'appel, et quelques registres sauvegardés. Au retour, le cadre est dépilé, et sa place sera réutilisée par le prochain appel. Réserver et libérer de la pile ne coûte presque rien : il suffit de déplacer le pointeur de pile.
int t[5];
réserve donc 20 octets dans le cadre courant, pour la durée de l'appel. Ni plus, ni moins.
Ne jamais renvoyer l'adresse d'une variable locale
int *f(void)
{
int x = 1;
return &x;
}
Au retour de f, x n'existe plus : son cadre a été dépilé. L'adresse renvoyée désigne une case que le prochain appel de fonction va réutiliser. gcc le signale :
local.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]
et, puisque c'est un comportement indéfini, il se permet même de renvoyer NULL à la place : printf("%p", f()) affiche (nil). Un autre compilateur renverra la vraie adresse, et le programme semblera marcher… jusqu'au prochain appel.
Une structure sur la pile
Inversement, quand une donnée ne sert que pendant l'appel, la pile est la place idéale : pas de malloc, pas de free, pas de fuite possible. L'exemple suivant parcourt une liste chaînée jusqu'à la première occurrence d'une valeur. Pour arrêter la boucle d'affichage, il insère temporairement dans la liste un maillon sentinelle, qui est une variable locale :
typedef struct s_list *t_list;
struct s_list {
int data;
t_list next;
};
/* affiche les éléments de l jusqu'à la première occurrence de bound incluse */
void afficher_jusqua(t_list l, int bound)
{
t_list tmp, save_pos;
struct s_list sentinel;
for (tmp = l; tmp != NULL && tmp->data != bound; tmp = tmp->next)
;
if (tmp == NULL)
return;
save_pos = tmp;
sentinel.data = -42;
sentinel.next = tmp->next;
tmp->next = &sentinel;
for (tmp = l; tmp != &sentinel; tmp = tmp->next)
printf("%d ", tmp->data);
printf("\n");
save_pos->next = sentinel.next;
}
Sur la liste 1 2 3 4 5, afficher_jusqua(l, 3) affiche 1 2 3. La sentinelle est repérée par son adresse, &sentinel : aucun maillon alloué ne peut avoir la même. Et la liste est remise en état avant le retour, avant que la sentinelle ne disparaisse : sinon la liste pointerait sur une case de pile morte.
(L'exemple est volontairement artificiel : un simple test tmp->data != bound ferait l'affaire. Il montre qu'une adresse sur la pile est une adresse comme une autre, tant que la fonction n'est pas terminée.)
Le tas
malloc, calloc, realloc, free
Quand une donnée doit survivre à la fonction qui la crée, ou que sa taille n'est connue qu'à l'exécution, on la place sur le tas, avec les fonctions de stdlib.h :
void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
void free(void *ptr);
malloc(n)réserve un bloc denoctets, non initialisés, et rend son adresse.calloc(nb, taille)réserve un tableau denbéléments detailleoctets, mis à zéro.realloc(p, n)change la taille du blocpennoctets. Si le bloc peut grandir sur place, il le fait ; sinon,reallocréserve un nouveau bloc, y copie l'ancien contenu, libère l'ancien et rend la nouvelle adresse.free(p)libère le blocp, qui doit venir de l'une des trois fonctions précédentes.free(NULL)ne fait rien.
Chaque bloc rendu est disjoint de tous les autres. S'il n'y a plus de place, les trois fonctions rendent NULL.
On écrit la taille avec sizeof appliqué à la variable elle-même, ce qui reste juste si son type change :
int *t = malloc(n * sizeof *t);
if (t == NULL) {
/* traiter l'erreur */
}
realloc peut déplacer le bloc :
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *p1 = malloc(32);
printf("p1 = %p\n", (void *)p1);
char *p2 = realloc(p1, 256);
printf("p2 = %p\n", (void *)p2);
free(p2);
return 0;
}
p1 = 0x62491eae42a0
p2 = 0x62491eae52e0
Après l'appel, p1 ne doit plus être utilisé : le bloc a été libéré.
L'alignement
Le processeur lit plus vite (et parfois ne sait lire que) les données rangées à une adresse multiple de leur taille : un double à une adresse multiple de 8, par exemple. malloc ne sait pas ce qu'on rangera dans le bloc : il rend donc une adresse convenable pour n'importe quel type. Cet alignement maximal est donné par _Alignof(max_align_t) : 16 octets sur un PC actuel en 64 bits.
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <stdint.h>
int main(void)
{
void *p[5];
printf("alignement maximal : %zu\n", _Alignof(max_align_t));
for (size_t i = 0; i < 5; i++) {
p[i] = malloc(i * 8 + 1);
printf("malloc(%2zu) = %p, reste %zu\n", i * 8 + 1, p[i],
(size_t)((uintptr_t)p[i] % _Alignof(max_align_t)));
}
for (size_t i = 0; i < 5; i++)
free(p[i]);
return 0;
}
alignement maximal : 16
malloc( 1) = 0x5857714852b0, reste 0
malloc( 9) = 0x5857714852d0, reste 0
malloc(17) = 0x5857714852f0, reste 0
malloc(25) = 0x585771485310, reste 0
malloc(33) = 0x585771485340, reste 0
Toutes les adresses sont multiples de 16. On remarque aussi qu'un bloc d'un octet en occupe 32 : malloc garde, juste avant chaque bloc, quelques octets pour lui (la taille du bloc, notamment), et arrondit.
Un vecteur qui grandit
realloc permet d'écrire un tableau dont la taille s'adapte au contenu. On garde le nombre d'éléments (taille) et la place réservée (capacite). Quand le tableau est plein, on double sa capacité :
#include <stdio.h>
#include <stdlib.h>
typedef struct s_vect *t_vect;
struct s_vect {
size_t taille, capacite;
int *tab;
};
t_vect vect_nouveau(void)
{
t_vect v = malloc(sizeof *v);
if (v == NULL)
return NULL;
v->taille = 0;
v->capacite = 1;
v->tab = malloc(v->capacite * sizeof *v->tab);
if (v->tab == NULL) {
free(v);
return NULL;
}
return v;
}
/* ajoute x à la fin de v ; rend 0, ou -1 si la mémoire manque */
int vect_ajouter(t_vect v, int x)
{
if (v->taille == v->capacite) {
size_t cap = 2 * v->capacite;
int *t = realloc(v->tab, cap * sizeof *t);
if (t == NULL)
return -1;
v->tab = t;
v->capacite = cap;
}
v->tab[v->taille++] = x;
return 0;
}
void vect_detruire(t_vect v)
{
free(v->tab);
free(v);
}
Ajouter 1000 éléments fait passer la capacité par 2 4 8 16 32 64 128 256 512 1024 : dix appels à realloc seulement. Pourquoi doubler, plutôt qu'ajouter une case à chaque fois ? Chaque realloc peut recopier tout le tableau. En ajoutant une case à la fois, $n$ ajouts coûteraient jusqu'à $1 + 2 + \dots + n$ copies, de l'ordre de $n^2$. En doublant, les copies font au plus $1 + 2 + 4 + \dots + n < 2n$ : un ajout coûte en moyenne un temps constant.
Sous malloc : la mémoire virtuelle
Des adresses qui ne sont pas physiques
Plusieurs programmes tournent en même temps sur la machine. Pourtant, chacun voit sa pile à peu près à la même adresse, et aucun ne peut lire la mémoire des autres. C'est que les adresses manipulées par un programme ne sont pas celles des barrettes de mémoire : ce sont des adresses virtuelles.
Un composant du processeur, la MMU (Memory Management Unit), traduit à la volée chaque adresse virtuelle en adresse physique, selon une table que le système tient à jour pour chaque processus. Cela règle plusieurs problèmes à la fois :
- chaque processus a son propre espace d'adressage, organisé toujours de la même façon, quelle que soit la place qu'il occupe réellement ;
- cet espace paraît continu au programme, même si la mémoire physique qui le porte est éparpillée ;
- une zone peut grandir (le tas, la pile) : il suffit d'ajouter des traductions ;
- un processus ne peut pas atteindre la mémoire d'un autre : les adresses qui ne lui appartiennent pas n'ont pas de traduction. Y accéder provoque une faute, et le système tue le programme : c'est l'« erreur de segmentation ».
La pagination
Traduire les adresses une à une demanderait une table immense. Sur une machine 32 bits, il y a $2^{32}$ adresses ; avec 4 octets par entrée, la table occuperait $4 \times 2^{32} = 2^{34}$ octets, soit 16 Gio, pour chaque processus.
On découpe donc la mémoire en blocs de taille fixe, en général 4 Kio ($2^{12}$ octets) : des pages pour la mémoire virtuelle, des cadres pour la mémoire physique. La table ne traduit plus que des numéros de page. Une adresse de 32 bits se coupe en deux :
La MMU remplace le numéro de page par le numéro du cadre, et garde la position. Il reste $2^{20}$ pages, soit une table de $4 \times 2^{20}$ octets = 4 Mio par processus : beaucoup mieux, mais encore trop pour un programme qui n'utilise que quelques pages. La table est donc elle-même découpée en arbre : sur 32 bits, un premier niveau de 1024 entrées, chacune pointant vers une table de second niveau de 1024 entrées, qui ne sont créées que pour les zones effectivement utilisées. Chaque table tient exactement dans une page (1024 × 4 octets). Les machines 64 bits utilisent quatre ou cinq niveaux.
Le break : brk et sbrk
L'espace d'adressage d'un processus Unix est organisé ainsi, des adresses basses aux hautes :
adresses basses
┌─────────────────────┐
│ code (.text) │ lecture et exécution
│ littéraux (.rodata) │ lecture seule
│ globales (.data) │ initialisées
│ globales (.bss) │ à zéro
├─────────────────────┤
│ tas ↓ grandit
├─────────────────────┤ ← le break
│ │
│ (non traduit) │
│ │
│ pile ↑ grandit
└─────────────────────┘
adresses hautes
La fin du tas s'appelle le break : au-delà, les adresses n'ont plus de traduction. Deux appels système le déplacent :
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);
brk(addr) place le break à l'adresse addr. sbrk(n) le déplace de n octets, et rend son ancienne position : c'est justement l'adresse du début de la zone gagnée. sbrk(0) donne la position actuelle. Ces fonctions ne font plus partie de la norme POSIX ; la bibliothèque C de Linux les fournit toujours, il faut compiler avec -D_DEFAULT_SOURCE pour les voir.
Un malloc minimal tient en trois lignes :
#include <stdio.h>
#include <unistd.h>
void *mon_malloc(size_t n)
{
void *p = sbrk(n);
if (p == (void *)-1)
return NULL;
return p;
}
int main(void)
{
printf("break initial : %p\n", sbrk(0));
int *a = mon_malloc(4 * sizeof (int));
int *b = mon_malloc(4 * sizeof (int));
a[0] = 1;
b[0] = 2;
printf("a = %p, b = %p\n", (void *)a, (void *)b);
printf("break final : %p\n", sbrk(0));
return 0;
}
$ gcc -std=c11 -Wall -Wextra -Werror -D_DEFAULT_SOURCE sbrk.c -o sbrk
$ ./sbrk
break initial : 0x64e15d379000
a = 0x64e15d39a000, b = 0x64e15d39a010
break final : 0x64e15d39a020
a et b se suivent, à 16 octets d'écart. Mais a n'est pas au break initial : entre les deux, le premier printf a lui-même appelé malloc pour son tampon (chapitre 5), et malloc a avancé le break de 132 Kio d'un coup. mon_malloc n'a pas de free, et ne se soucie pas de l'alignement.
mmap
L'appel système mmap sert à projeter un fichier en mémoire : ses octets deviennent accessibles comme ceux d'un tableau. Mais en mode anonyme, sans fichier, il réserve simplement des pages neuves, remplies de zéros :
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
int main(void)
{
size_t n = 100;
char *p = mmap(NULL, n, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return 1;
}
printf("p = %p, taille de page = %ld\n", (void *)p, sysconf(_SC_PAGESIZE));
p[4095] = 'x';
printf("p[4095] = %c\n", p[4095]);
munmap(p, n);
return 0;
}
$ gcc -std=c11 -Wall -Wextra -Werror -D_DEFAULT_SOURCE mmap.c -o mmap
$ ./mmap
p = 0x7e8b7d844000, taille de page = 4096
p[4095] = x
- Le premier argument est une adresse souhaitée ;
NULLlaisse le système choisir. PROT_READ | PROT_WRITE: les pages seront lisibles et modifiables.MAP_PRIVATE | MAP_ANONYMOUS: des pages propres au processus, qui ne correspondent à aucun fichier. Le descripteur vaut alors-1et le décalage0.- En cas d'échec,
mmapne rend pasNULLmaisMAP_FAILED. munmaprend les pages au système.
mmap travaille par pages entières : la demande de 100 octets a réservé une page de 4096, et p[4095] est bien à nous (on ne compte pas dessus dans un vrai programme). On réserve donc les petits blocs avec malloc, et mmap sert aux gros. C'est d'ailleurs ce que fait le malloc de Linux : au-delà de 128 Kio, il passe par mmap. Contrairement à sbrk, on peut utiliser mmap à côté de malloc sans risque.
D'autres façons de gérer la mémoire
malloc est généraliste : il accepte toutes les tailles, dans n'importe quel ordre. Quand on connaît mieux ses besoins, on peut faire plus simple et plus rapide, en prenant un gros bloc à malloc une fois, et en le découpant soi-même.
Les pools de recyclage
Une liste chaînée, un arbre, alloue et libère sans cesse de petits blocs tous de la même taille. On peut réserver d'un coup un tableau de blocs (le pool), et garder la liste des blocs libres :
- allouer, c'est prendre le premier bloc de la liste des libres ;
- libérer, c'est le remettre en tête de cette liste ;
- quand le pool est plein, on en réserve un second (pas avec
realloc, qui déplacerait les blocs déjà distribués, et donc invaliderait les pointeurs vers eux).
Les deux opérations se font en temps constant, sans jamais chercher.
L'allocation par vague : les arènes
Un programme travaille souvent par phases : lire une requête, la traiter, répondre ; tout ce qui a été réservé pendant la phase devient inutile à la fin. C'est la même logique que les variables locales d'une fonction, qui naissent et meurent ensemble. Une arène reproduit cela sur le tas :
- on réserve un gros bloc ;
- chaque allocation avance simplement un curseur dans ce bloc ;
- on ne libère jamais un petit bloc seul : à la fin de la phase, on remet le curseur au début, et tout est libéré d'un coup.
#include <stdlib.h>
#include <stddef.h>
struct arene {
char *debut;
size_t taille, utilise;
};
int arene_creer(struct arene *a, size_t taille)
{
a->debut = malloc(taille);
a->taille = taille;
a->utilise = 0;
return a->debut == NULL ? -1 : 0;
}
void *arene_allouer(struct arene *a, size_t n)
{
size_t al = _Alignof(max_align_t);
size_t pos = (a->utilise + al - 1) / al * al; /* arrondi au multiple de al */
if (pos + n > a->taille)
return NULL;
a->utilise = pos + n;
return a->debut + pos;
}
void arene_vider(struct arene *a)
{
a->utilise = 0;
}
void arene_detruire(struct arene *a)
{
free(a->debut);
a->debut = NULL;
}
arene_allouer arrondit la position au multiple de 16 supérieur, pour rendre des adresses aussi bien alignées que celles de malloc. Trois allocations de 40, 5 et 8 octets occupent ainsi les positions 0, 48 et 64, et utilise vaut 72 ; après arene_vider, la phase suivante réutilise exactement les mêmes adresses. Une allocation coûte une addition et une comparaison. Les compilateurs, les serveurs web, les moteurs de jeu utilisent tous cette technique.
Trouver les erreurs de mémoire
Les erreurs de mémoire sont sournoises : un débordement de quelques octets ne plante pas forcément le programme, et une fuite ne se voit pas sur un programme qui tourne une seconde. Deux outils les rendent visibles. Voici un programme qui contient les deux :
#include <stdlib.h>
#include <string.h>
char *dupliquer(const char *s)
{
char *d = malloc(strlen(s));
strcpy(d, s);
return d;
}
int main(void)
{
char *a = dupliquer("bonjour");
char *b = dupliquer("au revoir");
free(a);
(void)b;
return 0;
}
Il compile sans avertissement, s'exécute sans erreur et rend 0. Pourtant, malloc(strlen(s)) oublie la case du '\0' : strcpy écrit un octet de trop. Et b n'est jamais libérée.
valgrind
valgrind exécute le programme dans une machine simulée qui surveille chaque accès à la mémoire. On compile avec -g, pour qu'il puisse citer les numéros de ligne :
$ gcc -std=c11 -Wall -Wextra -Werror -g fuite.c -o fuite
$ valgrind --leak-check=full ./fuite
Invalid write of size 1
at 0x484F39E: strcpy (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
by 0x1091E3: dupliquer (fuite.c:7)
by 0x109204: main (fuite.c:13)
Address 0x4a8c047 is 0 bytes after a block of size 7 alloc'd
at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
by 0x1091CC: dupliquer (fuite.c:6)
by 0x109204: main (fuite.c:13)
…
9 bytes in 1 blocks are definitely lost in loss record 1 of 1
at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
by 0x1091CC: dupliquer (fuite.c:6)
by 0x109217: main (fuite.c:14)
Tout y est : une écriture d'un octet « 0 octet après un bloc de 7 », faite par strcpy à la ligne 7, dans un bloc réservé ligne 6 ; et 9 octets perdus, réservés ligne 6 lors de l'appel de la ligne 14. Le programme tourne sous valgrind 20 à 50 fois plus lentement, mais sans rien recompiler.
-fsanitize=address
Le compilateur peut aussi ajouter lui-même des vérifications autour de chaque accès à la mémoire : c'est AddressSanitizer. Le programme tourne à peine deux fois plus lentement, et s'arrête à la première erreur :
$ gcc -std=c11 -Wall -Wextra -Werror -g -fsanitize=address fuite.c -o fuite
$ ./fuite
==345136==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000017
WRITE of size 8 at 0x502000000017 thread T0
#0 0x79b3692a7922 in strcpy
#1 0x5651c085a263 in dupliquer fuite.c:7
#2 0x5651c085a284 in main fuite.c:13
0x502000000017 is located 0 bytes after 7-byte region [0x502000000010,0x502000000017)
allocated by thread T0 here:
#0 0x79b3692fd9c7 in malloc
#1 0x5651c085a24c in dupliquer fuite.c:6
Il détecte aussi les débordements sur la pile (l'exemple magic du chapitre 2), l'utilisation d'un bloc après free, les doubles free, et, en fin de programme, les fuites.