Retrouvez cet article dans : Linux Magazine 109
Valgrind est un outil très puissant pour détecter et corriger une grande partie des problèmes de gestion mémoire de vos applications écrites en C ou C++, et ainsi éviter des plantages aléatoires ou certains trous de sécurité. Cet article vous permet de vous familiariser avec lui, en vous présentant son usage le plus classique.
Valgrind est composé d’un cœur et de différents outils (cachegrind, callgrind, helgrind, massif et memcheck). Nous nous intéresserons ici à l’outil par défaut, memcheck, qui est celui pour lequel Valgrind est habituellement utilisé.
memcheck analyse toutes les allocations, libérations et tous les accès à la mémoire. Cela ralentit fortement l’exécution du programme, mais permet d’obtenir des informations très précises.
Les principaux types de problèmes signalés sont les suivants :
● accès hors d’une zone allouée (dépassement d’un tableau, accès à un indice négatif...) ;
● accès à une zone précédemment libérée ;
● libération d’une zone non allouée (pointeur corrompu) ou déjà libérée ;
● utilisation d’une zone non initialisée ;
● fuite mémoire.
|
1 |
Préparer votre programme |
Afin que Valgrind soit vraiment utilisable, il est nécessaire d’avoir les informations de debug pour votre binaire. Si vous le compilez vous-même, il faut donc ajouter l’option -g à votre appel de GCC. Si vous utilisez les paquets de votre distribution GNU/Linux, il vous faudra en général installer un paquet supplémentaire. Ces paquets sont appelés -debug chez Mandriva, -debuginfo chez Fedora, -dbg chez Debian, etc. Des instructions pour les diverses distributions sont par exemple disponibles (en anglais) sur http://live.gnome.org/GettingTraces/DistroSpecificInstructions.
|
2 |
Lancer votre programme dans memcheck |
Si votre programme se lance habituellement par la commande plop coin coin, il vous suffit d’utiliser à la place valgrind plop coin coin.
Vous pouvez aussi ajouter des options à Valgrind. Ces options sont à ajouter avant votre commande (sinon, elles seraient transmises à votre programme au lieu d’être interprétées par Valgrind). Nous en rencontrerons en particulier dans la section traitant de la recherche de fuites mémoire.
Valgrind affichera alors un message indiquant qu’il charge memcheck et listant les copyrights, puis des erreurs au fur et à mesure qu’elles sont rencontrées.
Les erreurs sont affichées sous la forme suivante :
|
==21031== Invalid read of size 1 ==21031== at 0x804839A: main (read.c:5) ==21031== Address 0x4180032 is 0 bytes after a block of size 10 alloc’d ==21031== at 0x40204E5: malloc (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==21031== by 0x8048390: main (read.c:3) |
21031 est le numéro du processus. Cela permet surtout de voir quand le problème est en fait dans l’un des fils du vôtre et pas directement dans votre code.
La première ligne indique l’erreur, ici la lecture d’un octet à un endroit où l’on n’aurait pas dû accéder. Les lignes suivantes indiquent la pile d’appel, une seule ligne ici car l’erreur se situe dans main(), ligne 5 de read.c.
Dans le cas où Valgrind parvient à identifier le bloc mémoire auquel vous vouliez accéder, il indique également où il avait été alloué ou libéré. Dans notre cas, on avait alloué un bloc de taille 10 à la ligne 3 de read.c, et on essaye d’accéder en lecture 0 octet après celui-ci, donc au 11ème octet.
Lorsque la même erreur se produit plusieurs fois lors de la même exécution de votre programme, Valgrind ne l’affiche qu’une fois. L’option -v permet de lui demander d’afficher à la fin le nombre de fois que chaque erreur s’est produite.
Si votre programme génère trop d’erreurs (1000 différentes ou 1000000 au total), Valgrind finit par abandonner avec un message sympathique :
|
==31573== More than 1000 different errors detected. I’m not reporting any more. ==31573== Final error counts will be inaccurate. Go fix your program! ==31573== Rerun with --error-limit=no to disable this cutoff. Note ==31573== that errors may occur in your program without prior warning from ==31573== Valgrind, because errors are no longer being displayed. |
Vous pouvez supprimer cette limite avec l’option --error-limit=no, mais cela risque de provoquer une consommation mémoire très élevée et des performances fortement dégradées.
|
3 |
Les différentes erreurs signalées |
|
3.1 |
Lecture ou écriture invalide |
Cette erreur indique que votre programme lit ou écrit hors des zones mémoire auxquelles il devrait avoir accès. Cela permet notamment de repérer les accès au-delà de la fin d’un tableau ou d’une chaîne et les accès à des adresses invalides comme NULL (ou NULL+42).
L’exemple suivant montre les messages d’erreur pour une lecture après la fin de la zone allouée et une lecture dans une zone précédemment libérée. Comme vous pouvez le constater, les messages indiquent exactement comment se situe l’accès par rapport à la zone mémoire la plus proche (respectivement 0 octet après la fin, donc l’octet suivant la fin de la zone, et 0 octet à l’intérieur, donc le premier octet de la zone).
|
#include <stdlib.h> #include <string.h> int main() { char *a = (char *)malloc(10); char b; /* Lecture après la fin de la zone allouée */ b = a[10]; free(a); /* Lecture dans une zone libérée */ b = a[0]; return 0; } ==21281== Invalid read of size 1 ==21281== at 0x804839A: main (read.c:8) ==21281== Address 0x4180032 is 0 bytes after a block of size 10 alloc’d ==21281== at 0x40204E5: malloc (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==21281== by 0x8048390: main (read.c:4) ==21281== ==21281== Invalid read of size 1 ==21281== at 0x80483AE: main (read.c:13) ==21281== Address 0x4180028 is 0 bytes inside a block of size 10 free’d ==21281== at 0x40200FF: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==21281== by 0x80483AA: main (read.c:10) |
|
Note |
|
Cet exemple montre une lecture, mais une écriture serait beaucoup plus dangereuse. |
|
3.2 |
Utilisation d’une zone mémoire non initialisée |
Lorsque vous n’initialisez pas une variable, sa valeur initiale est inconnue. Faire un test sur cette variable ou la passer à une autre fonction ou un appel système entraîne donc un comportement aléatoire.
En général, lorsque Valgrind vous signale cette erreur, cela vous permet de repérer un chemin d’exécution que vous n’aviez pas prévu et où, par exemple, un pointeur n’est jamais affecté avant son utilisation. La correction de cette erreur est souvent triviale, et peut par exemple consister à initialiser le pointeur à NULL, et vérifier avant son utilisation qu’il n’est plus NULL.
L’exemple suivant indique les messages lorsqu’un test dépend d’une valeur non initialisée et lorsque qu’un paramètre d’un appel système n’est pas entièrement initialisé (dans notre cas, il s’agit d’un entier qui ne l’est donc pas du tout, mais, pour un buffer, cela peut être intéressant à savoir).
|
#include <stdlib.h> int main() { int n; /* Test d’une variable non initialisée */ if(n > 1) { /* Passage d’une variable non initialisée à un appel système */ return n; } return 0; } ==21397== Conditional jump or move depends on uninitialised value(s) ==21397== at 0x8048329: main (init.c:6) ==21397== ==21397== Syscall param exit_group(exit_code) contains uninitialised byte(s) ==21397== at 0x40CF158: _Exit (in /lib/i686/libc-2.6.1.so) ==21397== by 0x4054F97: (below main) (libc-start.c:254) |
|
3.3 |
Libération invalide |
Le pointeur passé à free doit forcément être celui vers le début d’une zone allouée précédemment et non encore libérée. Les problèmes de libération les plus courants sont donc de libérer 2 fois la même zone mémoire ou d’avoir modifié le pointeur accidentellement et demander à libérer une adresse au milieu (voire en dehors) de la zone voulue.
|
#include <stdlib.h> int main() { char *a = (char *)malloc(10); free(a); /* Double libération */ free(a); /* Libération d’une adresse n’étant pas le début d’une zone allouée */ free((void*)42); return 0; } ==21334== Invalid free() / delete / delete[] ==21334== at 0x40200FF: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==21334== by 0x80483A9: main (free.c:9) ==21334== Address 0x4180028 is 0 bytes inside a block of size 10 free’d ==21334== at 0x40200FF: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==21334== by 0x804839E: main (free.c:6) ==21334== ==21334== Invalid free() / delete / delete[] ==21334== at 0x40200FF: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==21334== by 0x80483B5: main (free.c:14) ==21334== Address 0x2A is not stack’d, malloc’d or (recently) free’d |
|
3.4 |
Libération avec une fonction incorrecte |
Lorsque vous faites du C++, vous libérez un objet créé par new à l’aide de delete, et une zone allouée par malloc/calloc/realloc/... avec free. Il arrive parfois que des gens se mélangent les pinceaux et libèrent avec free un objet alloué par new.
|
#include <string> int main() { std::string *s = new std::string("plop"); free(s); return 0; } ==16330== Mismatched free() / delete / delete [] ==16330== at 0x40200FF: free (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==16330== by 0x804860B: main (free.cpp:5) ==16330== Address 0x42A4028 is 0 bytes inside a block of size 4 alloc’d ==16330== at 0x4020C8C: operator new(unsigned) (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==16330== by 0x80485D2: main (free.cpp:4) |
|
3.5 |
Recouvrement de la source et de la destination |
Certaines fonctions C (memcpy, strcpy, strncpy, strcat, strncat) copient une zone mémoire vers une autre. Si une partie de la source et de la destination se superposent il y a un risque de corruption lors de la copie. memcheck le signale donc par l’erreur "Source and destination overlap".
Dans le fichier C suivant, par exemple, la source et la destination se superposent de 2 octets et Valgrind le détecte bien.
|
#include <string.h> int main() { char a[10] = "azertyuio"; /* Copie d’une chaîne par dessus une partie d’elle même */ strncpy(a, a+2, 8); return 0; } ==19188== Source and destination overlap in strncpy(0xBEDBFF41, 0xBEDBFF43, 8) ==19188== at 0x40219A8: strncpy (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==19188== by 0x80483BC: main (overlap.c:6) |
|
3.6 |
Avertissements |
Valgrind peut aussi émettre de simples avertissements. Par exemple, si vous utilisez un descripteur de fichier invalide, Valgrind vous le signalera, mais ce n’est pas forcément un problème dans la mesure où l’appel système renverra une erreur et que vous êtes censé vérifier le résultat d’un write !
|
#include <unistd.h> int main() { write(424242, "42", 2); return 0; } ==17498== Warning: invalid file descriptor 424242 in syscall write() |
|
4 |
Erreurs non détectées |
Je ne vais bien sûr pas vous lister ici toutes les erreurs non détectées par Valgrind. J’en oublierais forcément. Il est toutefois important de savoir que certaines erreurs courantes de gestion mémoire, pouvant avoir de graves conséquences sur la sécurité de votre application, ne sont pas détectées par Valgrind. Je parle des débordements sur la pile.
Il s’agit d’accès en écriture à des données qui n’ont pas été allouées par un malloc, mais placées sur la pile (variables locales d’une fonction ou allocation avec alloca). Lorsque vous dépassez d’un tel tableau, vous risquez de modifier des données importantes comme l’adresse de retour de la fonction ! Il s’agit donc d’une source très commune de failles de sécurité.
Pour détecter ce genre de problèmes et éviter par exemple l’exécution de code par ce biais, l’approche la plus commune consiste à utiliser des mécanismes de protection de pile (stack smashing protection) à base de canaris. Il s’agit de marqueurs qui s’ils sont modifiés indiquent que vous avez débordé, et votre programme est alors immédiatement interrompu pour éviter tout risque. GCC inclut depuis sa version 4.1 un tel mécanisme, utilisable avec les options de compilation -fstack-protector et -fstack-protector-all. Ces options sont utilisées par défaut dans la plupart des distributions GNU/Linux modernes, et même activées par défaut dans le compilateur sous OpenBSD.
Je ne m’attarderai pas plus sur ce sujet qui n’est pas le cœur de cet article, mais, dans la mesure où c’est la principale source de failles de sécurités dans le code C, il m’a semblé utile de le mentionner.
Abordons maintenant un autre domaine, moins critique pour la sécurité, dans lequel Valgrind peut également vous aider.
|
5 |
Recherche des fuites mémoire |
Pour éviter que votre programme ne devienne aussi gourmand que XXX (remplacer XXX par celui que vous avez besoin de redémarrer au moins une fois par jour pour que votre machine reste utilisable), il est très fortement suggéré de veiller à corriger toutes les fuites mémoire avant de le distribuer.
Pour rappel, une fuite mémoire ça consiste à allouer de la mémoire et oublier de la libérer quand on n’en n’a plus besoin, voire se retrouver à ne plus être en mesure de la libérer.
Il existe plusieurs sortes de fuites mémoire, la plus simple à détecter étant celle où vous affectez une nouvelle valeur à un pointeur sans avoir libéré la mémoire vers laquelle il pointait, et n’avez donc plus aucun moyen de libérer cette mémoire. Valgrind détecte de telles fuites à la fin de l’exécution en parcourant l’intégralité de la mémoire non libérée par votre programme à la recherche d’un pointeur vers les différentes zones non libérées. Si certaines zones de mémoire non libérée ne sont plus référencées par aucun pointeur, il vous listera les zones perdues et là où elles avaient été allouées.
Vous pouvez aussi demander à Valgrind de vous indiquer celles pour lesquelles il restait des pointeurs, mais qui n’ont quand même pas été libérées avant de sortir de votre programme. Cela indique généralement que vous avez oublié de libérer ces zones et qu’elles consomment donc inutilement de la mémoire, mais cela peut aussi être un choix de ne pas les libérer lorsque vous quittez dans la mesure ou l’OS s’en chargera de toute façon. Pour que Valgrind vous les indique également, il faut ajouter l’option --show-reachable.
Prenons l’exemple du fichier C suivant, contenant 2 fuites mémoire.
|
#include <stdlib.h> int main() { void **a = (void **) malloc(42*sizeof(void *)); void *b = malloc(1); a[0] = b; a = NULL; b = NULL; return 0; } ==17686== malloc/free: in use at exit: 169 bytes in 2 blocks. |
Valgrind va détecter les 2, le pointeur sur la zone de 168 octets (4*42) est clairement perdu dans la mesure où l’on stocke NULL dans a sans avoir copié le pointeur avant. Dans le cas de b, il reste un pointeur vers la zone de 1 octet, mais ce pointeur est situé dans une zone à laquelle on n’a plus accès : c’est donc une fuite indirecte.
|
==17686== malloc/free: 2 allocs, 0 frees, 169 bytes allocated. ==17686== For counts of detected errors, rerun with: -v ==17686== searching for pointers to 2 not-freed blocks. ==17686== checked 49,500 bytes. ==17686== ==17686== 169 (168 direct, 1 indirect) bytes in 1 blocks are definitely lost in loss record 2 of 2 ==17686== at 0x4021828: malloc (in /usr/lib/valgrind/x86-linux/vgpreload_memcheck.so) ==17686== by 0x8048360: main (leak.c:3) ==17686== ==17686== LEAK SUMMARY: ==17686== definitely lost: 168 bytes in 1 blocks. ==17686== indirectly lost: 1 bytes in 1 blocks. ==17686== possibly lost: 0 bytes in 0 blocks. ==17686== still reachable: 0 bytes in 0 blocks. ==17686== suppressed: 0 bytes in 0 blocks. |
Après avoir indiqué quelques statistiques sur les allocations et les libérations, Valgrind détaille chaque fuite trouvée, puis indique un résumé des fuites, classées par type.
● definitely lost indique les zones vers lesquelles il ne reste plus le moindre pointeur (ici 1 zone de 168 octets).
● indirectly lost indique les zones vers lesquelles il reste un pointeur, mais celui-ci n’est plus accessible(ici 1 zone de 1 octets).
● possibly lost indique les zones vers lesquelles il reste un pointeur, mais le pointeur ne pointe pas vers le début du bloc. Il est fort probable que soit le pointeur ait été modifié, soit l’adresse apparaisse juste par hasard (mais rien ne vous empêche de décider de stocker l’adresse des zones allouées +1, et de faire -1 avant de libérer).
● still reachable indique les zones vers lesquelles il reste un pointeur, mais il n’est pas libéré avant de sortir. Ce n’est pas forcément un problème.
● suppressed indique les erreurs masquées (voir la fin de l’article).
Les fuites où vous oubliez de libérer la mémoire au moment où elle n’est plus nécessaire, mais conservez un pointeur et les libérez avant de sortir ne peuvent pas être détectées par memcheck, mais un autre outil de la suite Valgrind vous y aidera : massif, qui fait des statistiques sur l’accès aux différentes zones allouées et vous permettra de trouver celles qui restent longtemps alloués alors qu’elles ne servent plus. Ce type de fuite peut par exemple avoir lieu lorsque vous avez votre propre système d’allocation mémoire (avec la fonction d’allocation qui ajoute le pointeur à une liste, donc il sera bien libéré en sortie même si vous-même le perdez dans le reste de l’application) ou, par exemple, un tableau que vous faites grossir quand il est plein, mais que vous ne réduisez jamais.
|
6 |
Utilisation de memcheck.h |
Pour faciliter grandement la recherche d’information, vous pouvez ajouter #include <valgrind/memcheck.h> au début d’un fichier source. Cela vous donne accès a quelques macros permettant d’interagir avec memcheck. Par exemple, VALGRIND_DO_QUICK_LEAK_CHECK permet de détecter les fuites mémoire en cours d’exécution de votre programme au lieu d’attendre la fin.
|
#include <stdlib.h> #include <valgrind/memcheck.h> int main() { unsigned int leaked, dubious, reachable, suppressed; void **a = (void **) malloc(42*sizeof(void *)); void *b = malloc(1); a[0] = b; VALGRIND_DO_QUICK_LEAK_CHECK a = NULL; VALGRIND_DO_QUICK_LEAK_CHECK b = NULL; VALGRIND_DO_QUICK_LEAK_CHECK return 0; } ==3238== searching for pointers to 2 not-freed blocks. ==3238== checked 49,488 bytes. ==3238== ==3238== LEAK SUMMARY: ==3238== definitely lost: 0 bytes in 0 blocks. ==3238== possibly lost: 0 bytes in 0 blocks. ==3238== still reachable: 169 bytes in 2 blocks. ==3238== suppressed: 0 bytes in 0 blocks. ==3238== Rerun with --leak-check=full to see details of leaked memory. ==3238== searching for pointers to 2 not-freed blocks. ==3238== checked 49,516 bytes. ==3238== ==3238== LEAK SUMMARY: ==3238== definitely lost: 168 bytes in 1 blocks. ==3238== possibly lost: 0 bytes in 0 blocks. ==3238== still reachable: 1 bytes in 1 blocks. ==3238== suppressed: 0 bytes in 0 blocks. ==3238== Rerun with --leak-check=full to see details of leaked memory. ==3238== searching for pointers to 2 not-freed blocks. ==3238== checked 49,548 bytes. ==3238== ==3238== LEAK SUMMARY: ==3238== definitely lost: 169 bytes in 2 blocks. ==3238== possibly lost: 0 bytes in 0 blocks. ==3238== still reachable: 0 bytes in 0 blocks. ==3238== suppressed: 0 bytes in 0 blocks. ==3238== Rerun with --leak-check=full to see details of leaked memory. |
|
7 |
Les faux positifs |
Il arrive que des accès mémoire étranges soient légitimes (surtout des lectures). Par exemple, Python effectue des lectures dans des zones non initialisées, puis va détecter que le contenu n’est pas valide, ce qui est beaucoup plus performant que d’aller vérifier que cette zone de mémoire est bien dans la liste des zones valides.
Afin de quand même voir les véritables erreurs, il existe les fichiers de suppression qui indiquent quelles sont les erreurs à masquer. Vous devez indiquer ces fichiers à Valgrind avec l’option --suppressions=/chemin/fichier.supp.
Si vous avez des erreurs dans une application, ou dans une bibliothèque que vous utilisez, commencez par voir s’il n’existe pas un tel fichier. Vous pouvez également en écrire pour masquer certaines erreurs qui ne dépendent pas de vous et qui polluent le test de votre application. Le moyen le plus simple pour générer un fichier de suppressions est l’option --gen-suppressions=yes, mais je n’entrerai pas dans les détails ici, et, si vous en arrivez à ce genre d’utilisation avancée, je vous invite à lire le manuel de Valgrind qui est très complet.
|
8 |
Conclusion |
Comme vous avez pu le voir, les erreurs détectées par Valgrind sont souvent triviales à corriger, mais peuvent avoir des conséquences fâcheuses. Amusez-vous à lancer Valgrind sur les applications que vous utilisez tous les jours. Vous serez surpris des résultats...
C’est maintenant à vous de jouer, pour corriger vos applications, ou même celles des autres !
|
Auteur : Pascal (CMoi) Terjan |
Retrouvez cet article dans : Linux Magazine 109


