Logo Spiria

Débogage de fuites de mémoire sous Windows

21 mars 2016.

Contrairement à certains langages dits managed, tels que C# et Java, où la mémoire utilisée par un programme est automatiquement libérée par un récupérateur de mémoire (plus communément appelé garbage collector) lorsqu’elle n’est plus utilisée, les langages natifs comme le C ou le C++ doivent explicitement gérer leur mémoire. Concrètement, le programmeur doit lui-même décider du moment et de la façon d’allouer de la mémoire pour les objets qu’il crée, et du moment et de la façon de libérer cette mémoire lorsque ces objets de sont plus utilisés.

Bien que les programmes managed peuvent eux aussi présenter divers bogues de mémoire, la gestion manuelle de l’allocation et la désallocation de la mémoire nécessaire aux langages natifs rend ces derniers beaucoup plus susceptibles à ce type de problèmes. Le présent article décrira différents types de problèmes de mémoire que l’on peut rencontrer dans un programme natif, ainsi qu’une des nombreuses méthodes appropriées pour les régler.

Puisque la gestion de la mémoire et les outils disponibles pour déboguer les problèmes mémoire varient d'un système d'exploitation à l'autre, cet article se concentrera sur le débogage de fuites de mémoire sous Windows.

Fonctionnement de la mémoire sous Windows

Avant de sauter à pieds joints dans les détails du débogage de problèmes de mémoire, il est de mise d’effectuer une explication vulgarisée du fonctionnement de la mémoire sous Windows.

Mémoire virtuelle

De nos jours, les systèmes d’exploitation des ordinateurs de bureau sont très complexes et permettent à des centaines d’applications de fonctionner en même temps et de se partager les ressources limitées du système. S’il en revenait à chacun de ces programmes de contrôler directement la mémoire physique de l’ordinateur, nous assisterions à un cafouillis immense qui rendrait en quelques secondes le système inutilisable. Tous les programmes passeraient leur temps à se battre entre eux pour trouver quelques petites retailles de mémoire et une application tyrannique pourrait décider de tout garder pour elle au détriment des autres.

La mémoire virtuelle, ou plus précisément l’espace d’adressage virtuel, est un mécanisme qui permet au système d’exploitation d’exposer à chaque application une région d’adresses virtuelles contiguës qu’elles peuvent utiliser à leur guise en fonction de leurs besoins, et d’ensuite lui-même attribuer un espace mémoire physique à ces adresses selon les besoins globaux de toutes les applications du système.

decorative

Lorsque certains blocs mémoire sont accédés fréquemment par un ou plusieurs programmes, le système d’exploitation leur attribuera une place dans la mémoire physique, qui est très rapide. Lorsque certains blocs sont moins souvent utilisés, ils seront plutôt relégués au fichier d’échange (swap file) qui se trouve sur un disque dur, qui est beaucoup plus lent.

Il est possible qu’en cours de route, le système d’exploitation décide de bouger un bloc mémoire d’un espace à l’autre, que ce soit au sein de la mémoire physique ou du fichier d’échange, ou entre ces deux régions. Puisque les adresses utilisées par les processus sont virtuelles, ces derniers ne verront aucun changement dans leur fonctionnement, et les objets continueront d’être situés à la même adresse (virtuelle). C’est plutôt le système d’exploitation qui sera responsable de garder la trace de l’espace mémoire attitré à chaque plage d’adresses virtuelles.

Cette gestion de la mémoire permet une utilisation optimale des ressources matérielles limitées de l’ordinateur en fonction des besoins immédiats de tous les programmes qui fonctionnent dans le système à un moment donné.

Les bogues de mémoire

Il existe divers types de problèmes de mémoire. Voici une description des deux principaux types.

Fuite de mémoire (memory leak)

Une fuite de mémoire se définit comme étant la perte d’espace mémoire causée par l’allocation de blocs de mémoire qui ne sont pas désalloués correctement lorsqu’ils ne sont plus utilisés.

Une application qui fuit verra son empreinte mémoire grossir graduellement jusqu’à sa fermeture. 

Sur un système d’exploitation 32 bits, où l’espace de mémoire qu’un processus peut utiliser est limité à environ 3 gigaoctets, une fuite non colmatée peut faire gonfler l’utilisation mémoire d’une application jusqu’à ce qu’elle ne puisse plus en allouer davantage. Cela résulte généralement en un crash d’application. 

Même si les applications 64 bits n’ont pas la même limitation d’espace mémoire, une fuite de mémoire peut là aussi causer des problèmes non négligeables. Principalement, le système d’exploitation verra sa réserve de mémoire physique diminuer graduellement jusqu’à l’épuisement, ce qui forcera une plus grande utilisation du fichier d’échange sur le disque dur, ce qui participera à un ralentissement global de toutes les applications du système.

Corruption de mémoire (memory corruption)

La corruption de mémoire peut arriver dans plusieurs situations, mais peut être généralisée comme étant un programme qui écrit à un emplacement mémoire erroné. Cela peut se produire parce que le programme n’a pas alloué assez de mémoire pour ses besoins ou bien parce qu’un objet a été libéré avant que toutes les parties du programme n’aient terminé de l’utiliser. 

Selon le cas, les conséquences d’une corruption de mémoire sont particulièrement insidieuses. Dans certains cas les plus simples (et les plus souhaitables pour le développeur), l’écriture même à un endroit erroné se produira dans une zone mémoire protégée. Si c'est le cas, un crash d’application de type violation d’accès (access violation) se produira au moment de l’écriture et il sera alors plus facile d’identifier la cause de l’erreur.

Dans bien des cas cependant, l’écriture se fera avec succès et la zone mémoire en question se trouvera corrompue (elle contiendra des données erronées). Plus tard (parfois même beaucoup plus tard), le programme essaiera de lire ce qui se trouve à cet espace mémoire, et puisque l’information qui s’y trouve sera erronée, le comportement de l’application sera indéfini. Tout dépendant l'interprétation par le programme de la zone mémoire corrompue, l’accès peut occasionner un crash, un mauvais calcul, ou parfois n’avoir aucune conséquence grave. Le pire est que d’un lancement de l’application à l’autre, le bris peut avoir lieu à divers moments et à divers endroits dans le code, sans aucune logique apparente. C’est ce qui rend le débogage de tels problèmes particulièrement difficile.

Identification des fuites de mémoire avec UMDH

Bien qu’il existe une multitude de logiciels spécialisés dans l’analyse des fuites de mémoire, un des outils les plus utiles et simples à utiliser est fourni gratuitement par Microsoft avec ses Debugging Tools for Windows (voir mon article Introduction à WinDBG pour plus de détails).

Contrairement à bien des alternatives avec des interfaces usager très détaillées, UMDH est un simple logiciel en ligne de commande qui ne fait que deux choses, mais qui les fait très bien.

  • Il génère un cliché instantané de toutes les allocations mémoire d’une application.
  • Il compare deux clichés instantanés pour identifier la source des fuites.

Préparation

L’usage de UMDH nécessite une petite préparation. Il faut préalablement configurer Windows pour capturer les call stacks d’allocation pour l’application ciblée, et pour assurer un accès aux symboles de débogage nécessaires pour faire correspondre ces call stacks aux noms de fonctions du programme et l’emplacement dans les fichiers source des divers points d’intérêt.

Activer la capture de call stacks

La façon la plus simple d’activer la capture de call stacks pour une application donnée est avec le logiciel Global Flags, aussi fournie avec les Debugging Tools for Windows. Dans Global Flags, il suffit d’aller dans le 3e onglet Image File, d’inscrire le nom de l’application (ie: exemple.exe) dans le champ Image:, d’appuyer sur la touche [TAB], et de cocher la case Create user mode stack trace database.

Cette configuration sera inscrite dans le fichier registres de Windows, ce qui la rendra permanente, jusqu’à ce qu’elle soit désactivée. Pour désactiver cette option de débogage, il suffit de répéter les mêmes étapes et de décocher la case en question.

Configurer l’accès aux symboles

Pour assurer l’accès aux symboles de débogage, il faut définir une variable d’environnement nommée _NT_SYMBOL_PATH, et dont la valeur doit être construite comme dans l’exemple suivant:

c:\emplacement\des\fichiers\pdb\de\lapplication;srv*c:\dossier\temporaire*http://msdl.microsoft.com/download/symbols

Dans cet exemple, c:\emplacement\des\fichiers\pdb\de\lapplication se veut le ou les chemins vers les fichiers PDBs de l’application à déboguer, et srv*c:\dossier\[…]/download/symbols se veut l’emplacement du serveur de fichiers symboles publics de Windows et ses composantes. c:\dossier\temporaire peut être n'importe quel dossier sur l'ordinateur; les fichiers symboles de Windows téléchargés du serveur seront copiés dans ce dossier pour un accès plus rapide lors des utilisations ultérieures.

Fonctionnement

Une fois la préparation effectuée, l’utilisation d’UMDH s’effectue en plusieurs étapes simples:

1) Démarrer l’application ciblée jusqu’au moment où l’utilisation mémoire est connue (stable)

2) Prendre un cliché instantané des allocations mémoire avec UMDH

La capture de cliché d’allocations mémoire avec UMDH s’effectue comme suit:

umdh -p:PID [-f:fichierDeSortie.txt]

On peut trouver le PID (Process ID) de notre application à l’aide du gestionnaire de tâches de Windows. Consultez l’aide en ligne de Windows pour connaître la procédure exacte pour votre version de Windows.

3) Effectuer une ou plusieurs fois l’opération qui produit une fuite de mémoire

Il est préférable de répéter l’opération de notre programme qui produit une fuite de mémoire à plusieurs reprises, puisque les résultats de notre analyse seront classés par ordre décroissant de taille mémoire. Plus une fuite est reproduite souvent, plus haut elle se retrouvera dans la liste et plus elle sera facile à identifier.

4) Prendre un second cliché instantané des allocations mémoire avec UMDH

Il est important de prendre ce second cliché à un moment où l'on s'attendrait que la quantité de mémoire utilisée soit la même qu'à l’étape 2. Par exemple, si notre application crée 10 objets lorsque l’on appuie sur un bouton dans le but d’afficher un dialogue, on peut s’attendre à ce que ces 10 objets soient libérés lorsque le dialogue se ferme. En prenant le premier cliché avant d'appuyer sur le bouton et le deuxième une fois le dialogue fermé, on peut confirmer si c’est bien le cas, ou non.

5) Utiliser UMDH pour comparer les deux clichés

Pour comparer nos deux clichés, il suffit d’utiliser UMDH d’une manière un peu différente d’aux étapes 2 et 4:

umdh [-l] fichier1.txt fichier2.txt > fichierComparatif.txt

Ici, l’option -l permet d’afficher le nom des fichiers source et les numéros de lignes dans les call stacks lors de l’analyse. La redirection vers un fichier texte ( > fichierComparatif.txt ) est facultative, mais fortement suggérée, vu le volume habituel de données générées par UMDH.

Il est à noter que l’opération de comparaison de clichés par UMDH peut s’avérer plus ou moins longue (de quelques secondes à plusieurs minutes ou dizaines de minutes) dépendamment la durée entre les clichés et de la sévérité des fuites de mémoire.

Le résultat de l’opération d’analyse donnera une liste de tous les call stacks où de la mémoire a été allouée et non désallouée dans le moment entre la prise du premier et du second clichés mémoire.

Exemple:

Voici un simple programme C++ qui fuit de la mémoire:

#include <iostream>
using namespace std;

int main(int argc, char *argv[])
{
	cout << "Prendre un cliché, puis appuyer sur ENTRÉE" << endl;
	cin.ignore();

	// Allocation de mémoire
	int* mille = new int[1000];
	int* dixmille = new int[10000];

    // Cette mémoire devrait être libérée comme ceci pour éviter une fuite:
    // delete[] mille;
    // delete[] dixmille;

	cout << "Prendre le second cliché, puis appuyer sur ENTRÉE" << endl;
	cin.ignore();
}</iostream>

Le résultat d’une analyse UMDH de ce programme produit le résultat suivant:

+    9c64 (  9c64 -     0)      1 allocs    BackTrace267DF9C
+       1 (     1 -     0)    BackTrace267DF9C    allocations

    ntdll!RtlpCallInterceptRoutine+26
    ntdll!RtlpAllocateHeapInternal+4D95B
    ntdll!RtlAllocateHeap+28
    ucrtbased!_toupper+248
    ucrtbased!_toupper+56
    ucrtbased!_malloc_dbg+1A
    ucrtbased!malloc+14
    leak!operator new+D (f:\dd\vctools\crt\vcstartup\src\heap\new_scalar.cpp, 19)
    leak!operator new[]+C (f:\dd\vctools\crt\vcstartup\src\heap\new_array.cpp, 15)
    leak!main+8C (d:\users\françois\documents\visual studio 2015\projects\leak\leak\main.cpp, 11)
    leak!invoke_main+1E (f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl, 74)
    leak!__scrt_common_main_seh+15A (f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl, 264)
    leak!__scrt_common_main+D (f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl, 309)
    leak!mainCRTStartup+8 (f:\dd\vctools\crt\vcstartup\src\startup\exe_main.cpp, 17)
    KERNEL32!BaseThreadInitThunk+24
    ntdll!__RtlUserThreadStart+2F
    ntdll!_RtlUserThreadStart+1B

+     fc4 (   fc4 -     0)      1 allocs    BackTrace267DF08
+       1 (     1 -     0)    BackTrace267DF08    allocations

    ntdll!RtlpCallInterceptRoutine+26
    ntdll!RtlpAllocateHeapInternal+4D95B
    ntdll!RtlAllocateHeap+28
    ucrtbased!_toupper+248
    ucrtbased!_toupper+56
    ucrtbased!_malloc_dbg+1A
    ucrtbased!malloc+14
    leak!operator new+D (f:\dd\vctools\crt\vcstartup\src\heap\new_scalar.cpp, 19)
    leak!operator new[]+C (f:\dd\vctools\crt\vcstartup\src\heap\new_array.cpp, 15)
    leak!main+70 (d:\users\françois\documents\visual studio 2015\projects\leak\leak\main.cpp, 10)
    leak!invoke_main+1E (f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl, 74)
    leak!__scrt_common_main_seh+15A (f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl, 264)
    leak!__scrt_common_main+D (f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl, 309)
    leak!mainCRTStartup+8 (f:\dd\vctools\crt\vcstartup\src\startup\exe_main.cpp, 17)
    KERNEL32!BaseThreadInitThunk+24
    ntdll!__RtlUserThreadStart+2F
    ntdll!_RtlUserThreadStart+1B

(Résultat tronqué pour faciliter la lecture)

En partant du haut des résultats, on peut voir deux call stacks, tous deux remontant aux lignes 11 et 10 du fichier main.cpp de notre programme. Pour chaque call stack, les deux lignes supérieures commençant par un + désignent 

  • La différence en octets alloués à cet endroit dans le code entre le premier et le deuxième cliché
  • La différence en nombre d'allocations à cet endroit dans le code entre le premier et le deuxième cliché

Si on regarde le premier call stack, nous mesurons une fuite de 9c64 (lire en hexadécimal, ce qui fait 40 036) octets, alloués en une seule allocation. Sachant que nous avons alloué à cet endroit 10 000 éléments de type int, et qu'un élément de type int sur Windows dans une application compilée en 32 bits (ce qui est le cas pour cet exemple) a une taille de 4 octets, on peut conclure que l'outil a mesuré une fuite de l'équivalent de 10 009 éléments.

On peut attribuer les 36 octets de trop à différents facteurs qui n'affectent en rien l'utilité de cette méthode pour trouver et régler une fuite de mémoire. À titre informatif, cette mémoire additionnelle consiste en un entête que la version de Windows que j'utilise ajoute au-devant de chaque allocation de mémoire à des fins de gestion. Il est possible que différentes versions de Windows donnent des résultats différents à ce niveau.

Le deuxième call stack nous informe tel qu'attendu que nous avons une fuite de FC4 (4036) octets. Encore une fois, nous avons un surplus de 36 octets, ce qui confirme l'hypothèse de l'entête d'allocation.

Une fois le site des fuites identifié, le plus dur est fait. Il ne reste plus qu'à effectuer une inspection du code pour comprendre comment correctement libérer la mémoire une fois qu'elle deviendra inutilisée.

Bon débogage !