Les problèmes de plomberie

Vous avez déjà probablement eu a retrouver des "fuites" de mémoires, c'est-à-dire à trouver des zones mémoires qui ont été allouées, mais qui ne sont pas désallouées par la suite. Dans cette partie, nous allons faire un tour d'horizon des divers outils disponibles sous Linux qui peuvent aider dans cette recherche.

Vous savez déjà probablement qu'une zone de mémoire allouée est automatiquement restituée au système d'exploitation lors de la terminaison du processus. Dans ce cas, pourquoi s'obstiner à rechercher ces fuites ? Celles qui sont situées dans les programmes qui n'allouent dynamiquement que peu de mémoire et dont le temps de fonctionnement est très cours (comme par exemple, les commandes ls ou ps) ne prettent pas à conséquences bien que ce soit un style de programmation relativement peu esthétique. Par contre, si des fuites surviennent dans des programmes qui utilisent beaucoup de mémoire ou qui sont sensés tourner pendant longtemps, alors ce problème devient réelement génant. Lequel d'entre vous n'a pas déjà eu à redémarrer Netscape ou son serveur X (voire les deux) parce qu'il occupait plus de 100 Mo en mémoire ?

Les fuites ne sont pas les seuls problèmes liés à l'allocation mémoire. Les langages C et C++ n'effectuant pas de tests pour vérifier si l'on accéde bien toujours à des zones mémoires correctes, qu'elles aient été allouées dynamiquement (par un appel à la fonction malloc()) ou non (tableau statique). Dans ce cas, cela se terminera dans la plupart des cas par un résultat erroné ou par une erreur de segmentation (voire l'un suivi de l'autre).
Heureusement, il existe sous Linux quelques outils qui permettent de grandement simplifier la chasse aux bugs.

    Electric Fence

Electric Fence (litéralement "cloture électrique") est un outil très simple, mais il permet néanmoins d'intercepter un grand nombre de problèmes grâce à un minimum d'efforts. Plus particulièrement, Electric Fence rend possible la détection des erreurs suivantes :
- accès en dehors des zones allouées par la fonction malloc() ("buffer overruns" et "buffer underruns")
- accès à une zone mémoire retournée au système par un appel à la fonction free().
- détection des problèmes d'alignement.
A la différence des autres outils similaires, Electric Fence détecte non seulement les tentatives d'écritures en dehors des zones allouées, mais également les tentatives de lecture. C'est, en grande partie, ce qui fait l'intérêt de cet outil.

Electric Fence se présente sous la forme d'une bibliothèque avec laquelle vous devrez "linker" votre programme. Vous pouvez choisir d'utiliser la bibliothèque libefence explicitement lors de l'édition de liens finale en ajoutant l'option '-lefence' lors de l'appel à la commande gcc, cependant il est beaucoup plus pratique de forcer l'édition de liens au moment de l'exécution grâce à la variable d'environnement LD_PRELOAD. Cela peut se faire simplement à partir de la ligne de commande :
# LD_PRELOAD=libefence.so.0.0 ./monprog
ou bien depuis gdb grace à la directive 'set environment LD_PRELOAD=libefence.so.0.0'.

Voyons concrétement avec un exemple comment s'utilise Electric Fence. Considérons le petit programme suivant :
 

eftest1.c
#include  <stdio.h>
#include  <errno.h>

int main (int argc, char **argv) {
    int  *p, i;

    if (! (p = (int *) malloc(10*sizeof(int)))) {
       perror("echec de malloc:");
       exit(-1);
    }
    for (i=0; i<=10; i++) {
        p[i]=i;
    }
    printf("Fin du test\n");
    exit(0);
}

(Les lecteurs attentifs auront certainement déjà vu qu'il y à une erreur dans le code, mais merci de ne rien dire pour le moment afin de ne pas me gacher mon effet de surprise que je réserve pour plus tard ;-)

On compile ce programme grâce à la commande :
$ gcc -g eftest1.c -o eftest1
puis on l'exécute :
$ ./eftest1
Fin du test
$

Tout s'est donc passé comme prévu ; du moins en apparence, car cela ne veut pas dire que le programme fonctionne correctement. Pour nous en convaincre relançons ce même programme, mais cette fois en utilisant Electric Fence :
$ LD_PRELOAD=libefence.so.0.0 ./eftest1

  Electric Fence 2.0.5 Copyright (C) 1987-1998 Bruce Perens.
Segmentation fault.
$

On note au passage qu'Electric Fence a bien été trouvé comme l'indique le message (Ne vous laissez pas impressionner par le fait que la dernière version d'Electric Fence date de 1998; cela est tout simplement dû au fait que cet utilitaire remplit parfaitement son rôle et qu'aucun bug n'y a été découvert depuis cette date). Mais on voit surtout que le programme a planté avant la fin à cause d'une erreur de segmentation. Pour y voir plus clair revoyons la scène avec gdb (à défaut de ralenti).

$ gdb -q ./eftest1
(gdb) set environment LD_PRELOAD=libefence.so.0.0
(gdb) run
Starting program: /home/vincent/articles/./eftest1

  Electric Fence 2.0.5 Copyright (C) 1987-1998 Bruce Perens.

Program received signal SIGSEGV, Segmentation fault.
0x8048520 in main (argc=1, argv=0xbffffd44) at eftest1.c:12
12         p[i]=i;
(gdb) print i
$1 = 10

A ce stade, on en sait déjà un peu plus. D'une part, on sait précisément quelle ligne du programme a provoquée l'erreur ; d'autre part, on a pu déterminer que la variable i valait 10 au moment du plantage. A partir de là, l'erreur est évidente : l'appel de malloc() a alloué un segment de 10 mots machine, alors que la boucle for essaye d'en utiliser 11 (de 0 à 10, cela fait bien 11, pas 10).

Pour cet exemple, on a utilisé le mode de fonctionnement par défaut de Electric Fence, à savoir les problèmes de dépassements par le haut (buffer overruns). Du fait de sa conception, Electric Fence ne permet pas de détecter en une seule exécution les dépassements par le haut et par le bas.
En fait Electric Fence utilise le hardware gérant la mémoire virtuelle (la MMU) pour placer une page inaccessible après (ou avant selon l'option utilisée) chaque plage de mémoire allouée. De la sorte, lorsque le programme tente de lire ou d'écrire cette page inaccessible, une erreur de segmentation est déclenchée. A partir de ce moment, il est trival de trouver l'instruction fautive grâce à gdb, comme l'a montré l'exemple précédent. De même une zone mémoire qui a été libérée par un appel de la fonction free() sera rendue inaccessible, de sorte qu'une tentative d'accès après libération provoque un plantage du programme.
En ce qui concerne son utilisation, Electric Fence peut être utilisé pour simplement générer un fichier core qui sera utilisé pour une analyse post-mortem; cette méthode est cependant déconseillée, car du fait qu'Electric Fence alloue systèmatiquement 2 pages mémoire (soit 8 Ko sur les architectures i386), le programme débugué nécessitera une quantité de mémoire considérablement plus importante.
 
Figure 1 - Système d'allocation mémoire par défaut

A titre de référence, voici la liste des variables d'environnement succeptibles d'être utilisées pour modifier le comportement d'Electric Fence :

EF_DISABLE_BANNER
    Si cette variable a une valeur différente de zéro, alors Electric Fence n'affichera pas de message lors de son démarrage. L'utilisation de cette fonctionnalité est extrêmement déconseillée si vous linkez votre programme statiquement, car vous risquer d'oublier d'enlever Electric Fence dans la version en production de votre programme, avec les problèmes de performances que cela comporte.

EF_PROTECT_BELOW
    Par défaut (comme le montre la figure 1 qui ne devrait pas être bien loin si elle ne s'est pas perdue au département PAO), Electric Fence bloque l'accès à la page qui suit la zone allouée. Par conséquent si l'on tente d'accéder en dehors de la zone allouée par le bas, le problème ne sera pas détecté. Pour palier à ce problème, lorsque la variable EF_PROTECT_BELOW a une valeur non nulle, le mécanisme inverse est utilisé, c'est-à-dire que c'est la première page qui est bloquée, et la zone mémoire allouée est placée au début de la deuxième page (voir la Figure 2).
 
Figure 2 - Allocation mémoire avec EF_PROTECT_BELOW

EF_PROTECT_FREE
    Si cette variable est positionnée, Electric Fence ne retournera pas la mémoire désallouée avec free() au système, mais au contraire, il la rendra totalement inaccessible au programme. Cette fonctionnalité est très utile si vous suspectez que le programme que vous tentez de débugger essaye d'accéder à des zones mémoire précédemment désallouées. Attention, cette fonctionnalité décuple les besoins en mémoire de l'application, vu que la mémoire allouée est rendue inutilisable.

EF_ALLOW_MALLOC_0
    Par défaut, Electric Fence intercepte les appels à la fonction malloc() avec une taille nulle, car ils sont le plus souvent dus à un bug (quel pourrait bien être l'intérêt d'allouer une zone de taille nulle?!). Cependant cette fonctionnalité peut être désactivée en donnant une valeur non-nulle à la variable d'environnement EF_ALLOW_MALLOC_0.

EF_ALIGNMENT
    Ce paramètre permet de changer la valeur de l'alignement des zones mémoire retournées par Electric Fence. En quoi est-ce important? Tout simplement parce que par défault, les tailles des zones mémoires sont des multiples de la taille des mots mémoire, soit 4 octets (32 bits) sur la plupart des architectures ou 8 octets (64 bits) sur les Alpha et UltraSparc. Etant donné que chaque zone mémoire est un multiple de 4 octets, un dépassement de moins de 3 octets ne sera pas intercepté par Electric Fence.
Pour vous convaincre (si si, je vois bien à votre air dubitatif que vous n'étes pas totalement convaincu), modifions l'exemple eftest1.c précédent de la manière suivante :
 

eftest2.c
#include  <stdio.h>
#include  <errno.h>

int main (int argc, char **argv) {
    char *p;
    int  i;

    if (! (p = (char *) malloc(10*sizeof(char)))) {
       perror("echec de malloc:");
       exit(-1);
    }
    for (i=0; i<=10; i++) {
        p[i]=i;
    }
    printf("Fin du test\n");
    exit(0);
}

Comme vous pouvez le voir, la seule modification a consisté à remplacer le tableau d'"int" par un tableau de "char".
Maintenant, compilons et exécutons ce programme :

# gdb -q ./eftest2
(gdb) set environment LD_PRELOAD=libefence.so.0.0
(gdb) run
Starting program: /home/vincent/articles/./eftest2

  Electric Fence 2.0.5 Copyright (C) 1987-1998 Bruce Perens.
Fin du test

Program exited normally.

Surprise... Cette fois l'erreur de dépassement n'a pas été trouvée par Electric Fence, car lorsque l'allocation de 10 octets a été demandé, c'est en fait un nombre entier de mots machine de 4 octets qui ont été alloués (3 dans notre exemple), ce qui nous fait 12 caractères. Le code de l'exemple n'accédant qu'aux 11 premiers, aucune erreur n'a été détectée. Par contre, si l'on tente d'accéder au delà de ces 12 octets, alors l'erreur sera interceptée; vous pouvez le tester par vous-même en remplaçant le "10" de la boucle "for" par "12".
Alors comment faire pour détecter les problèmes d'allocation mémoire dont la taille est inférieure à la taille d'un mot machine? Heureusement, Electric Fence fourni un mécanisme pour contourner ce problème : la variable EF_ALIGNMENT peut être utilisée pour définir sur combien d'octets l'alignement des zones allouées sera fait. Pour détecter les dépassements d'un seul octet, il suffit donc de positionner cette variable à 1. Pourquoi n'est ce pas fait par défaut ? Tout simplement à cause du fait que la fonction malloc() est sensée retourner une zone mémoire alignée sur un mot machine et que tous les processeurs ne sont pas capables d'accéder à des données qui ne respectent pas scrupuleusement cette contrainte d'alignement (les processeurs Alpha par exemple).

(gdb) set environment EF_ALIGNMENT=1
(gdb) run
Starting program: /home/vincent/articles/./eftest2

  Electric Fence 2.0.5 Copyright (C) 1987-1998 Bruce Perens.

Program received signal SIGSEGV, Segmentation fault.
0x804857c in main (argc=1, argv=0xbffffcc4) at eftest2.c:13
13              p[i]=i;
(gdb)

Cette fois, en enlevant la contrainte d'alignement, le problème est bien intercepté.
 
 
Astuce
Lorsqu'Electric Fence est utilisé pour débugger un programme, les besoins en mémoire de celui-ci sont décuplés, et il se peut que vous atteignez rapidement les limites de la mémoire virtuelle de votre machine. Si vous rencontrez ce problème, il est facile d'y remédier temporairement grâce à un fichier de swap supplémentaire (oui : un fichier, pas une partition)
Supposons que vous estimiez à 80 Mo votre besoin de mémoire supplémentaire. Il vous suffit de :
1 - Créer un fichier vide de 80 Mo :
# dd if=/dev/zero of=/tmp/extraswap count=80 bs=1M
80+0 records in
80+0 records out
2 - Le "formatter" avec la commande mkswap :
# mkswap /tmp/extraswap
Setting up swapspace version 1, size = 83881984 bytes
3 - Monter le fichier créé :
# swapon /tmp/extraswap

A ce stade, vous pouvez utiliser la commande 'free' pour vérifier que la taille de votre espace de swap a bien augmenté de 80 Mo. Lorsque vous n'aurez plus besoin de ce swap supplémentaire, vous pourrez le désactiver grâce à la commande 'swapoff /tmp/extraswap', puis effacer le fichier.
Attention : si vous effacez le fichier avant d'utiliser la commande swapoff, vous avez de fortes chances (99%) de planter votre système.

    strace & ltrace

    Ces 2 outils ne sont pas aussi génériques que ceux que nous venons de présenter, et leur intérêt est souvent plus limité pour débugger un programme dont vous êtes l'auteur. Cependant, ils ont pour particularité (à la différence des outils présentés jusqu'à présent) d'être extrêmement utiles pour déterminer ce qui se passe dans un programme pour lequel vous ne disposez pas du code source.

        strace

    Cet outil permet de visualiser les appels systèmes effectués par le programme tracé. Notons au passage que le système Solaris comporte également un outil similaire, nommé 'truss' dans les anciennes versions et 'trace' dans les versions plus récentes.
Essayons strace sur le programme d'exemple eftest2 dont nous avons donné le code source un peu plus haut :
 
# strace ./eftest2
execve("./eftest2", ["./eftest2"], [/* 25 vars */]) = 0
brk(0)                                  = 0x8049840
old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x40016000
open("/etc/ld.so.preload", O_RDONLY)    = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY)      = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=38822, ...}) = 0
old_mmap(NULL, 38822, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40017000
close(3)                                = 0
open("/lib/libc.so.6", O_RDONLY)        = 3
fstat(3, {st_mode=S_IFREG|0755, st_size=1057576, ...}) = 0
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\224\314"..., 4096) = 4096
[...]
brk(0x8049cb8)                          = 0x8049cb8
brk(0x804a000)                          = 0x804a000
[...]
ioctl(1, TCGETS, {B9600 opost isig icanon echo ...}) = 0
write(1, "Fin du test\n", 12)           = 12
[...]
close(3)                                = 0
munmap(0x40017000, 4096)                = 0
_exit(0)                                = ?
#

Outre les premières lignes qui correspondent à l'initialisation du processus et des bibliothèques dynamiques qu'il utilise, on voit les appels à brk (appel système utilisé par la fonction malloc) et à write (correspondant à la fonction printf).

Ce type d'outil est par exemple très utile si l'on cherche à connaître les fichiers d'initialisation lus par un programme, auquel cas il suffira de rechercher les occurences de l'appel système 'read'.

        ltrace

Le fonctionnement de ltrace est relativement similaire à celui de strace, à la différence près que ltrace montre non pas les appels systèmes, mais les appels aux fonctions de bibliothèques. Voici par exemple les informations fournies par ltrace sur le même programme d'exemple.
 
# ltrace ./eftest2
__libc_start_main(0x08048610, 1, 0xbffffd14, 0x080483ec, 0x080486dc <unfinished ...>
__monstartup(0x080484f0, 0x080486f8, 0xbffffcb8, 0x4004f4a8, 0x401221e8) = 0
atexit(0x08048470)                                = 0
__register_frame_info(0x08049730, 0x08049828, 0xbffffcb8, 0x4004f4a8, 0x401221e8) = 0x40122e60
mcount(0x08049744, 0xbffffcc8, 0xbffffce8, 0x4003dbcc, 1) = 0x40120a58
malloc(10)                                        = 0x08049cb0
printf("Fin du test\n"Fin du test
)                           = 12
exit(0)                                           = <void>
_mcleanup(0x401221e8, 0x40015ec0, 1, 0, 0x4004f360) = 0x08049ca8
__deregister_frame_info(0x08049730, 0x400163f8, 0xbffffc6c, 0x400e8121, 0x08049848) = 0x08049828
+++ exited (status 0) +++
#

La plupart des appels mis en évidence sont en fait internes au fonctionnement de la libc, mais l'on voit tout de même les appels des 2 fonctions de bibliothèques faits par notre programme : malloc() et printf().

Maintenant, non seulement, vous n'avez plus aucune excuse pour avoir des fuites mémoire dans vos programmes, mais vous étes également armés pour corriger les bugs de tout autre programme que vous pourrez être amené à utiliser.
 
 
Références
GNU gdb :
    http://sources.redhat.com/gdb/
Electric Fence : 
    Disponible en paquet Debian sous le nom 'electric-fence'. 
    Sources : ftp://ftp.perens.com/pub/ElectricFence/
KDevelop :
    http://www.kdevelop.org/
gcc-checker : 
    http://www.gnu.org/software/checker/checker.html
glib : 
    ftp://ftp.gtk.org/pub/gtk/v1.2/
strace :
    http://www.wi.leidenuniv.nl/~wichert/strace/
ltrace :
    ftp://ftp.debian.org/debian/dists/unstable/main/source/utils/ltrace_0.3.10.tar.gz