Skip to content

Latest commit

 

History

History
365 lines (244 loc) · 16.2 KB

File metadata and controls

365 lines (244 loc) · 16.2 KB

🔝 Retour au Sommaire

30.1.1 — memcheck : Détection de fuites

Introduction

Memcheck est l'outil par défaut et le plus utilisé de la suite Valgrind. Son rôle principal est de surveiller chaque opération mémoire d'un programme — allocations, libérations, lectures, écritures — et de signaler toute incohérence. La section 30.1 a présenté les principes généraux de Valgrind et une première exécution. Cette sous-section approfondit l'utilisation de Memcheck spécifiquement pour la détection de fuites mémoire, en explorant les différents scénarios de fuites, les options de ligne de commande adaptées, et les stratégies pour exploiter efficacement les résultats.


Anatomie d'un rapport de fuite

Reprenons un programme volontairement fautif et exécutons-le avec les options détaillées :

// fuites_multiples.cpp
#include <cstring>
#include <string>

struct Config {
    char* hostname;
    int port;

    Config(const char* h, int p) : port(p) {
        hostname = new char[std::strlen(h) + 1];
        std::strcpy(hostname, h);
    }

    // Pas de destructeur : hostname ne sera jamais libéré
};

Config* charger_config() {
    return new Config("serveur.example.com", 8080);
}

void traiter_requete() {
    int* buffer = new int[256];
    // Traitement simulé...
    // Oubli : pas de delete[] buffer;
}

int main() {
    Config* cfg = charger_config();

    for (int i = 0; i < 3; ++i) {
        traiter_requete();
    }

    delete cfg;  // hostname n'est pas libéré dans le destructeur (implicite)
    return 0;
}

Ce programme contient deux types de fuites distincts : les buffers alloués dans traiter_requete() qui ne sont jamais libérés, et le membre hostname de Config qui survit au delete cfg faute de destructeur approprié.

Compilation et exécution sous Memcheck :

g++ -std=c++23 -g -O0 -o fuites_multiples fuites_multiples.cpp  
valgrind --leak-check=full --show-leak-kinds=all ./fuites_multiples  

Le rapport de Memcheck se décompose en trois sections principales : les erreurs en cours d'exécution (le cas échéant), le HEAP SUMMARY et le LEAK SUMMARY.


HEAP SUMMARY : Vue globale des allocations

==54321== HEAP SUMMARY:
==54321==     in use at exit: 3,092 bytes in 4 blocks
==54321==   total heap usage: 6 allocs, 2 frees, 3,116 bytes allocated

Ce résumé fournit une photographie de l'état du heap au moment où le programme se termine :

  • in use at exit : La quantité de mémoire encore allouée. Ici, 3 092 octets répartis dans 4 blocs. C'est la somme de tout ce qui n'a pas été libéré.
  • total heap usage : Le programme a réalisé 6 allocations au total et seulement 2 libérations. Le déséquilibre entre allocs et frees est un premier indicateur immédiat de fuites.

Le calcul est cohérent : 3 allocations de buffer (3 × 256 × 4 = 3 072 octets), plus une allocation de hostname (20 octets pour "serveur.example.com" + le terminateur nul), les 2 libérations correspondent au delete cfg (qui libère la structure Config elle-même) et une allocation interne.


Rapport détaillé des fuites

Avec --leak-check=full, Memcheck affiche la pile d'appels de chaque groupe de blocs fuyants :

==54321== 3,072 bytes in 3 blocks are definitely lost in loss record 2 of 2
==54321==    at 0x4845013: operator new[](unsigned long) (vg_replace_malloc.c:652)
==54321==    by 0x1091E5: traiter_requete() (fuites_multiples.cpp:20)
==54321==    by 0x109225: main (fuites_multiples.cpp:26)
==54321==
==54321== 20 bytes in 1 blocks are definitely lost in loss record 1 of 2
==54321==    at 0x4845013: operator new[](unsigned long) (vg_replace_malloc.c:652)
==54321==    by 0x109175: Config::Config(char const*, int) (fuites_multiples.cpp:10)
==54321==    by 0x1091B5: charger_config() (fuites_multiples.cpp:17)
==54321==    by 0x109209: main (fuites_multiples.cpp:24)

Chaque bloc du rapport contient des informations essentielles :

La taille et le nombre de blocs. 3,072 bytes in 3 blocks signifie que trois allocations distinctes, totalisant 3 072 octets, partagent la même pile d'appels. Memcheck regroupe intelligemment les fuites qui proviennent du même chemin d'exécution, ce qui évite de noyer le rapport sous des centaines d'entrées identiques dans le cas d'une fuite dans une boucle.

La catégorie. Ici, les deux groupes sont definitely lost. La mémoire est irrémédiablement inaccessible.

Le loss record. loss record 2 of 2 est un identifiant séquentiel trié par taille croissante. Cela permet de retrouver rapidement les fuites les plus volumineuses en regardant les derniers records.

La pile d'appels. Chaque frame indique l'adresse, le nom de la fonction et, lorsque les symboles de débogage sont disponibles, le fichier source et le numéro de ligne. C'est la pile d'appels au moment de l'allocation, pas au moment de la fuite — Memcheck vous montre la mémoire a été allouée, car c'est là que le delete correspondant aurait dû être écrit.


LEAK SUMMARY : Bilan catégorisé

==54321== LEAK SUMMARY:
==54321==    definitely lost: 3,092 bytes in 4 blocks
==54321==    indirectly lost: 0 bytes in 0 blocks
==54321==      possibly lost: 0 bytes in 0 blocks
==54321==    still reachable: 0 bytes in 0 blocks
==54321==         suppressed: 0 bytes in 0 blocks

Ce résumé agrège toutes les fuites par catégorie (celles-ci ont été détaillées en section 30.1). La ligne suppressed indique les fuites que Valgrind a volontairement ignorées en raison de fichiers de suppressions actifs — typiquement des allocations internes de la glibc ou de la librairie standard C++ connues pour être inoffensives.


Scénarios de fuites courants

Fuite par oubli de delete dans un flux d'erreur

L'un des patterns les plus fréquents en code réel est la fuite provoquée par un retour anticipé qui contourne le delete :

#include <stdexcept>
#include <cstring>

bool valider_donnees(const int* data, size_t taille) {
    return taille > 0 && data[0] >= 0;
}

void traiter(const char* fichier) {
    int* donnees = new int[1024];

    // Simulation de lecture...
    std::memset(donnees, 0, 1024 * sizeof(int));

    if (!valider_donnees(donnees, 1024)) {
        return;  // FUITE : donnees n'est jamais libéré sur ce chemin
    }

    // Traitement normal...
    delete[] donnees;
}

Memcheck signale cette fuite comme definitely lost après l'exécution, avec une pile d'appels pointant vers l'allocation dans traiter(). Ce pattern est l'un des arguments les plus convaincants en faveur du RAII (chapitre 6.3) et de std::unique_ptr (chapitre 9.1) : un smart pointer aurait libéré la mémoire automatiquement à la sortie de la portée, quel que soit le chemin emprunté.

Fuite par cycle de std::shared_ptr

Les smart pointers ne sont pas une garantie absolue contre les fuites. Un cycle de std::shared_ptr empêche les compteurs de références d'atteindre zéro :

#include <memory>
#include <string>

struct Noeud {
    std::string nom;
    std::shared_ptr<Noeud> voisin;

    Noeud(std::string n) : nom(std::move(n)) {}
    ~Noeud() { /* jamais appelé en cas de cycle */ }
};

void creer_cycle() {
    auto a = std::make_shared<Noeud>("A");
    auto b = std::make_shared<Noeud>("B");

    a->voisin = b;
    b->voisin = a;  // Cycle : compteurs ne descendront jamais à 0
}

À la sortie de creer_cycle(), les variables locales a et b sont détruites, mais chaque Noeud est encore référencé par le voisin de l'autre. Les compteurs restent à 1 et aucun destructeur n'est appelé.

Memcheck classe ces blocs comme definitely lost et indirectly lost. L'un des blocs du cycle est marqué definitely lost (Memcheck ne trouve aucun pointeur vers son début depuis les racines), et l'autre est indirectly lost (accessible uniquement via le bloc definitely lost). Le rapport ressemble à :

==99999== LEAK SUMMARY:
==99999==    definitely lost: 64 bytes in 1 blocks
==99999==    indirectly lost: 64 bytes in 1 blocks

💡 Note : selon les versions de Valgrind et la façon dont make_shared place le bloc de contrôle et l'objet en mémoire, ces blocs peuvent parfois être classés comme possibly lost. Dans les deux cas, la fuite est réelle.

La solution est de remplacer l'un des shared_ptr du cycle par un std::weak_ptr (section 9.2.2), qui ne participe pas au comptage de références.

Fuite dans une classe sans destructeur approprié

C'est le cas de notre structure Config dans l'exemple initial. La classe alloue de la mémoire dans son constructeur mais n'implémente pas de destructeur pour la libérer. Même si l'objet Config est correctement détruit avec delete, le membre hostname reste sur le heap :

==54321== 20 bytes in 1 blocks are definitely lost in loss record 1 of 2
==54321==    at 0x4845013: operator new[](unsigned long) (vg_replace_malloc.c:652)
==54321==    by 0x109175: Config::Config(char const*, int) (fuites_multiples.cpp:10)

La pile d'appels pointe vers le constructeur, là où hostname est alloué. Memcheck ne peut pas savoir pourquoi la mémoire n'a pas été libérée — c'est au développeur de faire le lien entre cette allocation et l'absence de destructeur. En C++ moderne, ce cas ne devrait pas se présenter : hostname serait un std::string, et la question de la libération ne se poserait pas.

Fuite dans un conteneur de pointeurs bruts

Un pattern insidieux est le conteneur qui possède des pointeurs bruts sans mécanisme de libération :

#include <vector>

struct Tache {
    int id;
    Tache(int i) : id(i) {}
};

void planifier() {
    std::vector<Tache*> file;

    for (int i = 0; i < 100; ++i) {
        file.push_back(new Tache(i));
    }

    // Le vector est détruit, mais pas les objets pointés
}

Le std::vector libère sa mémoire interne (le tableau de pointeurs), mais les 100 objets Tache alloués individuellement sur le heap ne sont jamais libérés. Memcheck rapporte :

==11111== 400 bytes in 100 blocks are definitely lost in loss record 1 of 1
==11111==    at 0x4840EF0: operator new(unsigned long) (vg_replace_malloc.c:422)
==11111==    by 0x109300: planifier() (planifier.cpp:11)
==11111==    by 0x109355: main (planifier.cpp:17)

Le groupement 100 blocks avec la même pile d'appels indique clairement un pattern d'allocation en boucle sans libération correspondante. La solution moderne est d'utiliser std::vector<std::unique_ptr<Tache>> ou, mieux encore, std::vector<Tache> directement si le polymorphisme n'est pas requis.


Options avancées de détection des fuites

Filtrer par catégorie de fuite

L'option --show-leak-kinds permet de cibler les catégories de fuites affichées :

# Uniquement les fuites certaines (priorité maximale)
valgrind --leak-check=full --show-leak-kinds=definite ./mon_programme

# Fuites certaines et probables
valgrind --leak-check=full --show-leak-kinds=definite,possible ./mon_programme

# Toutes les catégories, y compris still reachable
valgrind --leak-check=full --show-leak-kinds=all ./mon_programme

En début de projet ou sur une base de code contenant de nombreuses fuites, commencer par --show-leak-kinds=definite permet de se concentrer sur les cas les plus critiques sans être submergé.

Contrôler la profondeur des piles d'appels

Par défaut, Memcheck affiche 12 niveaux de profondeur dans les piles d'appels. Pour du code très profondément imbriqué ou utilisant beaucoup de templates, cette limite peut tronquer l'information utile :

valgrind --leak-check=full --num-callers=30 ./mon_programme

La valeur maximale est 500. En pratique, 20 à 30 suffit largement pour la plupart des projets. Augmenter cette valeur accroît la consommation mémoire de Valgrind.

Suivre les descripteurs de fichiers

Memcheck peut également signaler les descripteurs de fichiers ouverts non fermés à la fin du programme :

valgrind --leak-check=full --track-fds=yes ./mon_programme

Ce n'est pas une fuite mémoire au sens strict, mais c'est une fuite de ressource tout aussi problématique dans un service de longue durée. Valgrind affiche la pile d'appels de chaque open() dont le descripteur n'a pas été fermé.

Générer des fichiers de suppression

Lorsqu'un projet utilise des librairies tierces qui génèrent des faux positifs, on peut capturer ces erreurs dans un fichier de suppression :

# Étape 1 : générer les suppressions
valgrind --leak-check=full --gen-suppressions=all ./mon_programme 2> suppressions_brutes.txt

# Étape 2 : extraire et nettoyer les blocs de suppression dans un fichier .supp

# Étape 3 : utiliser le fichier de suppressions
valgrind --leak-check=full --suppressions=./mon_projet.supp ./mon_programme

Chaque bloc de suppression décrit un pattern de pile d'appels à ignorer. Valgrind applique les suppressions par défaut de la plateforme (/usr/lib/valgrind/default.supp) en plus de celles spécifiées manuellement.


Intégration dans un workflow de développement

Exécution ciblée depuis un runner de tests

Plutôt que d'exécuter toute une suite de tests sous Valgrind (ce qui serait prohibitif en temps), une approche pragmatique est de cibler les tests les plus susceptibles d'exposer des fuites :

# Exécution d'un test unitaire spécifique sous Valgrind
valgrind --leak-check=full --error-exitcode=1 ./build/tests/test_config

L'option --error-exitcode=1 est cruciale pour l'intégration CI : elle force Valgrind à retourner un code de sortie non nul si des erreurs (dont des fuites) sont détectées. Le pipeline CI peut ainsi échouer automatiquement en cas de régression mémoire.

Intégration CMake avec CTest

CMake permet de définir des tests exécutés sous Valgrind via CTest :

find_program(VALGRIND_EXECUTABLE valgrind)

if(VALGRIND_EXECUTABLE)
    add_test(
        NAME test_config_valgrind
        COMMAND ${VALGRIND_EXECUTABLE}
            --leak-check=full
            --error-exitcode=1
            --quiet
            $<TARGET_FILE:test_config>
    )
endif()

L'option --quiet supprime la sortie standard de Valgrind lorsqu'aucune erreur n'est détectée, ce qui rend les logs de CI plus lisibles. Seuls les problèmes apparaissent.

Mode quiet vs verbose

Pour le développement interactif, la sortie complète de Valgrind est précieuse. En CI, le mode --quiet combiné à --error-exitcode=1 transforme Valgrind en un gardien silencieux qui ne se manifeste qu'en cas de problème :

# Développement : sortie complète pour investigation
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./mon_programme

# CI : silencieux sauf erreur, code de sortie non nul si problème
valgrind --leak-check=full --error-exitcode=1 --quiet ./mon_programme

Interpréter les chiffres : quand s'inquiéter ?

Tous les rapports de Memcheck ne méritent pas la même attention. Voici un guide de priorisation :

Priorité immédiate — Toute ligne definitely lost indique un bug. Même une fuite de quelques octets dans une fonction appelée en boucle peut entraîner un épuisement mémoire en production. Corrigez ces cas systématiquement.

Investigation requise — Les blocs possibly lost nécessitent un examen. Si la pile d'appels implique du code que vous maîtrisez, c'est probablement un vrai problème. Si elle pointe vers les entrailles de la librairie standard ou d'une librairie tierce, c'est souvent un faux positif.

Attention contextuelle — Les blocs indirectly lost se corrigent d'eux-mêmes lorsque la fuite racine (definitely lost) est résolue. Il est inutile de les traiter séparément.

Généralement bénin — Les blocs still reachable sont rarement problématiques. De nombreuses implémentations de la librairie standard et des librairies tierces conservent volontairement des allocations jusqu'à la fin du processus (pools de mémoire, caches de localisation, buffers d'I/O). Un programme qui n'affiche que des blocs still reachable et zéro definitely/possibly lost est considéré comme sain.

Un objectif raisonnable pour un projet professionnel est d'atteindre et de maintenir zéro bloc definitely lost et zéro bloc possibly lost dans les tests sous Valgrind.

⏭️ Lecture des rapports Valgrind