Spiria logo.
François Charron
dans «  Développement desktop  »,
23 juillet 2015.

Débogage de plantage d'application avec WinDBG

Introduction Mon dernier article (Introduction à WinDBG) faisait état des différents prérequis nécessaires à une utilisation efficace du débogueur WinDBG. Afin de tirer profit au maximum de l’article ci-dessous, il importe de bien comprendre les informations qui ont été expliquées dans mon article précédent. Cet article-ci détaille de façon plus pointue l’analyse d’un type particulier de bogue : le plantage d’application (application crash).

Introduction

Mon dernier article (Introduction à WinDBG) faisait état des différents prérequis nécessaires à une utilisation efficace du débogueur WinDBG. Afin de tirer profit au maximum de l'article ci-dessous, il importe de bien comprendre les informations qui ont été expliquées dans mon article précédent.

Cet article-ci détaille de façon plus pointue l'analyse d'un type particulier de bogue : le plantage d'application (application crash).

Avant d'entrer plus profondément dans les détails de ce type de débogage, il importe de préciser en quoi consiste un plantage d'application. Un plantage est caractérisé par l'arrêt complet d'un processus à la suite d'une erreur irrécupérable. L'erreur peut provenir de différentes sources:

  • une exception lancée par une routine qui ne peut être traitée par aucun des appelants (angl. Unhandled exception),

  • une instruction illégale commandée par le programme (ex : division par zéro),

  • une tentative d'accès à de la mémoire protégée (ex : déréférencement d'un pointeur non initialisé, angl. Access violation).

D'autres types d'erreurs qui préviennent le déroulement normal d'une application ne consistant pas en un plantage d'application à proprement parler :

  • le gel d'application (angl. application hang) consiste en une suspension indéfinie du déroulement d'un programme (qui reste en vie malgré tout), causé entre autres par l'entrée dans une boucle infinie ou par une impasse (angl. deadlock) entre deux threads,

  • le gel système (angl. system hang) consiste en la non-réponse absolue du système d'exploitation entier aux interactions de l'usager, avec des causes similaires à celles du gel d'application, mais se situant dans un processus noyau (comme un pilote de périphérique),

  • le plantage système (angl. system crash) consiste en l'arrêt complet du système d'exploitation, généralement suivi d'un redémarrage automatique de celui-ci, suite à une erreur irrécupérable survenant dans un processus noyau. À une certaine époque, Windows affichait un écran bleu caractéristique à ce type d'erreur, ce qui lui a valu le surnom Blue Screen of Death (BSOD).

Chacun de ces types de problèmes nécessite une approche différente dans son analyse, et requerra une couverture particulière.

Note

Tel que mentionné dans mon article précédant, il est possible d'investiguer un bogue dans WinDBG soit de façon directe (live) ou post-mortem (crash dump). Sauf mention du contraire, les techniques détaillées ci-dessous sont applicables à ces deux techniques.

Trouver le coupable

Quand vient le temps de déboguer un plantage d'application, la première étape consiste à trouver quel thread a causé le problème. Pour se faire, il faut visuellement inspecter le call stack de chacun des threads afin de détecter certains patterns typiques.

Comme pour la plupart des fonctionnalités de WinDBG, il existe plusieurs façons d'arriver à un résultat similaire selon la technique que l'on emploie. En voici trois qui permettent de visualiser les call stacks des différents threads selon le contexte :

  1. À l'aide de l'interface graphique

    1. Afficher la fenêtre « Call Stack » soit en cliquant sur le bouton correspondant, en appuyant sur [ALT+6] ou en cliquant sur l'item « Call Stack » du menu « View ».
    2. Afficher la fenêtre « Processes and Threads » soit en cliquant sur le bouton correspondant, en appuyant sur [ALT+9] ou en cliquant sur l'item « Processes and Threads » du menu « View ».
    3. Cliquer sur le TID (Thread identifier) correspondant au thread pour lequel on souhaite voir le call stack.
  2. À l'aide de la commande k qui permet d'afficher le call stack d'un thread avec une quantité de détails paramétrables​

    • ~*kb permet de visualiser le call stack de toutes les threads du processus avec un nombre raisonnable d'informations complémentaires

  3. Avec la méta commande !uniqstack qui affiche tous les call stacks de threads différents. En d'autres mots, cette commande masque les call stacks qui sont identiques pour plus d'un thread. Ceci s'avère très pratique lorsque notre application contient un nombre important de threads en attente de commande qui vont tous se retrouver dans un même état la plupart du temps.

Une fois la liste de call stacks obtenue, il faut maintenant identifier quel thread est responsable du problème. Dépendamment des circonstances entourant le bogue, un call stack de plantage peut varier passablement d'un bogue à l'autre. Voici plusieurs exemples communs de patterns possibles.

Exception non traitée

KERNELBASE!RaiseException             <--- Information importante
VCRUNTIME140D!CxxThrowException       <--- Information importante
crashTest!testExceptionNonTraitee
crashTest!fonction1
crashTest!wmain
crashTest!invoke_main
crashTest!__scrt_common_main_seh
crashTest!__scrt_common_main
crashTest!wmainCRTStartup
KERNEL32!BaseThreadInitThunk
ntdll!RtlUserThreadStart

Comme on peut le voir dans cet exemple, la fonction main de notre programme a appelé fonction1(), qui a ensuite appelé testExceptionNonTraitee(), laquelle a lancé une exception à l'aide du mot-clé C++ throw (CxxThrowException), ce qui a finalement abouti en l'appel du noyau du système d'exploitation (RaiseException). L'absence d'un couple try-catch couvrant les appels de fonction résultant au lancement de l'exception a causé l'arrêt du programme.

Exception camouflée par une boîte de dialogue

(call stack simplifié pour raison de lisibilité)

USER32!NtUserWaitMessage
USER32!DialogBox2                 <--- Information importante
USER32!InternalDialogBox
USER32!SoftModalMessageBox
USER32!MessageBoxWorker
USER32!MessageBoxTimeoutW
USER32!MessageBoxW                <--- Information importante
[...]
ntdll!RtlRaiseException
KERNELBASE!RaiseException
VCRUNTIME140D!_CxxThrowException
crashTest2!CcrashTest2Dlg::OnBnClickedButton1
[...]
crashTest2!wWinMainCRTStartup
KERNEL32!BaseThreadInitThunk
ntdll!RtlUserThreadStart

Si on s'attache à une application qui affiche déjà un dialogue du système d'exploitation nous indiquant qu'il y a eu plantage, ou si on reçoit un crash dump d'une application recueilli au moment où le dialogue était visible, il se peut que l'exception soit ensevelie sous une liste impressionnante d'appels de fonctions menant à l'affichage de la boîte de dialogue. Il est aussi possible qu'une combinaison entre ce pattern et le pattern d'exception dont la cause est cachée (pattern suivant) contribue à camoufler la source du problème. Il est facile, particulièrement lorsque l'on doit parcourir des centaines de threads différents, de passer par dessus ce genre de call stack en déduisant à tort qu'il s'agit d'un simple thread d'interface usager. Or, lorsque l'on débogue ce que l'on sait être un plantage d'application, la présence d'appels de fonctions relatifs aux boîtes de dialogue (MessageBoxW, DialogBox2, …) devrait nous mettre la puce à l'oreille. En inspectant le call stack de plus près, on peut détecter la présence d'un throw émanent du code par les appels à CxxThrowException et RaiseException. Une autre fonction indiquant parfois le lancement d'une exception est kiUserExceptionDispatcher..

Exception dont la cause est cachée

ntdll!NtWaitForMultipleObjects
KERNELBASE!WaitForMultipleObjectsEx
KERNEL32!WerpReportFaultInternal
KERNEL32!WerpReportFault
KERNELBASE!UnhandledExceptionFilter
ntdll!RtlUserThreadStart$filt$0
ntdll!_C_specific_handler
ntdll!RtlpExecuteHandlerForException
ntdll!RtlDispatchException
ntdll!KiUserExceptionDispatch
WARNING: Stack unwind information not available. Following frames may be wrong.
[...]

Pour diverses raisons dépassant le cadre de cet article, il arrive parfois, particulièrement lorsque l'on performe l'analyse d'un crash dump d'un plantage d'application, qu'il soit facile d'identifier quel thread est responsable du plantage, mais que la cause sous-jacente du bogue soit cachée par un call stack incomplet. Cela peut par exemple ce produire si on utilise une librairie externe dont on n'a pas les symboles de débogage. Il est parfois possible de retrouver la partie de call stack manquante en utilisant le contexte de l'exception qui est sauvegardé sur le stack au moment où cette dernière est lancée.

Pour ce faire, il faut utiliser une série de commandes qui vont nous donner pas à pas l'information nécessaire.

  1. S'assurer que le bon thread est actif. Pour ce faire, il suffit d'afficher le call stack du thread courant et de s'assurer (par exemple avec la commande kb) qu'il contient bien des appels aux diverses fonctions relatives au traitement des exceptions (KiUserExceptionDispatch, RtlDispatchException, RaiseException, …). Si ce n'est pas le cas, il suffit d'afficher la fenêtre « Processes and Threads » en cliquant son bouton, en appuyant [ALT+9] ou en cliquant « Processes and Threads » dans le menu « View », et de cliquer sur le TID du thread désiré.

  2. Une fois dans le bon thread, la méta commande !teb affiche le « Thread Environment Block » de la thread. Cette structure de données décrit différents paramètres du thread, telles que les limites dans l'espace d'adresses mémoires de son stack. Voici de quoi a l'air le résultat de la commande !teb :

TEB at 00007ff7b5b7e000
ExceptionList: 0000000000000000
StackBase: 0000002419d50000            <--- Information importante
StackLimit: 0000002419d4d000           <--- Information importante
SubSystemTib: 0000000000000000
FiberData: 0000000000001e00
ArbitraryUserPointer: 0000000000000000
Self: 00007ff7b5b7e000
EnvironmentPointer: 0000000000000000
ClientId: 000000000000f6d4 . 0000000000010270
RpcHandle: 0000000000000000
Tls Storage: 00007ff7b5b7e058
PEB Address: 00007ff7b5b7c000
LastErrorValue: 0
LastStatusValue: c00000bb
Count Owned Locks: 0
HardErrorMode: 0

Les membres qui nous intéressent en ce moment sont « StackLimit » et « StackBase », qui correspondent tous deux aux adresses limites de la zone mémoire occupée par le stack du thread courant.

  1. La commande dps (ou dds si on débogue une application 32-bit) affiche ensuite le contenu entier du stack du thread courant. Le résultat est généralement très long et ressemble à ceci :

0:000> dps 2419d4d000 2419d50000
[...]
00000024`19d4fee8 00007ff7`b64a208e crashTest!__scrt_common_main+0xe [f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl @ 309]
00000024`19d4fef0 00000000`00000000
00000024`19d4fef8 00000000`00000000
00000024`19d4ff00 00000000`00000000
00000024`19d4ff08 00000000`00000000
00000024`19d4ff10 00000000`00000000
00000024`19d4ff18 00007ff7`b64a2359 crashTest!wmainCRTStartup+0x9 [f:\dd\vctools\crt\vcstartup\src\startup\exe_wmain.cpp @ 17]
00000024`19d4ff20 00000000`00000000
00000024`19d4ff28 00000000`00000000
00000024`19d4ff30 00000000`00000000
00000024`19d4ff38 00000000`00000000
00000024`19d4ff40 00000000`00000000
00000024`19d4ff48 00007fff`a44813d2 KERNEL32!BaseThreadInitThunk+0x22
00000024`19d4ff50 00000000`00000000
00000024`19d4ff58 00000000`00000000
00000024`19d4ff60 00000000`00000000
00000024`19d4ff68 00000000`00000000
00000024`19d4ff70 00007ff7`b64a100f crashTest!ILT+10(wmainCRTStartup)
00000024`19d4ff78 00007fff`a6e15444 ntdll!RtlUserThreadStart+0x34
00000024`19d4ff80 00007fff`a44813b0 KERNEL32!BaseThreadInitThunk
00000024`19d4ff88 00000000`00000000
00000024`19d4ff90 00000000`00000000
00000024`19d4ff98 00000000`00000000
00000024`19d4ffa0 00000000`00000000
00000024`19d4ffa8 00007fff`a4441ad0 KERNELBASE!UnhandledExceptionFilter   <--- Information importante
00000024`19d4ffb0 00000024`19d4ea80                                       <--- Information importante
00000024`19d4ffb8 00000024`19d4ea80
00000024`19d4ffc0 00000000`00000000
00000024`19d4ffc8 00000000`00000000
00000024`19d4ffd0 00000000`00000000
00000024`19d4ffd8 00000000`00000000
00000024`19d4ffe0 00000000`00000000
00000024`19d4ffe8 00000000`00000000
00000024`19d4fff0 00000000`00000000
00000024`19d4fff8 00000000`00000000
00000024`19d50000 00000020`78746341                                       <--- Début du stack

Heureusement, les données qui nous intéressent sont généralement placées tout en bas du stack. En effectuant soit une recherche visuelle ou encore textuelle (avec [CTRL+F]) dans le résultat de la commande dps, il suffit de trouver la fonction UnhandledExceptionFilter dans le stack.

La colonne de gauche étant l'adresse de chaque valeur dans le stack et la colonne de droite le contenu de l'adresse mémoire, il faut prendre note de la valeur se situant tout juste en dessous de la ligne où l'on trouve UnhandledExceptionFilter. Ceci correspond au paramètre passé à la fonction lors de son appel. Une recherche rapide sur Internet nous permettra de constater que le type du paramètre passé à UnhandledExceptionFilter est EXCEPTION_POINTERS.

  1. Afin de voir le contenu détaillé de cette structure de données, utilisons la commande dt -b EXCEPTION_POINTERS pour forcer le débogueur à interpréter le paramètre comme étant l'adresse d'une instance d'EXCEPTION_POINTERS :

0:000> dt -b EXCEPTION_POINTERS 2419d4ea80
crashTest!EXCEPTION_POINTERS
+0x000 ExceptionRecord : 0x00000024`19d4f6b0     <--- Enregistrement de l'exception
+0x008 ContextRecord : 0x00000024`19d4f1c0       <--- Contexte de l'exception
  1. La commande .exr affiche des informations détaillées à propos de l'exception :

0:000> .exr 0x00000024`19d4f6b0
ExceptionAddress: 00007ff7b64a1929 (crashTest!testA::setMember+0x0000000000000039)
ExceptionCode: c0000005 (Access violation)
ExceptionFlags: 00000000
NumberParameters: 2
Parameter[0]: 0000000000000001
Parameter[1]: ffffffffffffffff
Attempt to write to address ffffffffffffffff

On peut voir ici que l'exception a été lancée à partir de l'application crashTest dans la méthode setMember de la classe testA. Le résultat de la commande permet aussi de constater que l'erreur est une tentative d'écriture illégale à de la mémoire protégée à l'adresse 0xffffffffffffffff (access violation).

  1. Finalement, la commande .cxr nous permet de charger le contexte qui était actif au moment où l'exception a été lancée et ainsi d'inspecter le call stack à ce moment de l'exécution :

0:000> .cxr 24`19d4f1c0
rax=ffffffffffffffff rbx=00007ff7b64a100f rcx=0000000002a62b1c
rdx=0000000002a62b1c rsi=00007ff7b5b7c000 rdi=0000002419d4f998
rip=00007ff7b64a1929 rsp=0000002419d4f8d0 rbp=0000002419d4f8d0
r8=0000002419e0c820 r9=0000000000000000 r10=0000000000000000
r11=0000002419d4fc60 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na pe nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010202
crashTest!testA::setMember+0x39:
00007ff7`b64a1929 8908 mov dword ptr [rax],ecx ds:ffffffff`ffffffff=????????

En plus d'afficher ces informations pour le moins cryptiques, WinDBG affiche automatiquement le call stack actif au moment où l'exception a été lancée dans la fenêtre « Call Stack ». D'autre part, s'il est capable de trouver l'information de fichier source ainsi que l'endroit où se trouve le fichier source lui-même, WinDBG s'occupera de charger le contenu de celui-ci dans une nouvelle fenêtre. Le call stack affiché est maintenant :

crashTest!testA::setMember
crashTest!testExceptionNonTraitee
crashTest!fonction2
crashTest!fonction1
crashTest!wmain
crashTest!invoke_main
crashTest!__scrt_common_main_seh
crashTest!__scrt_common_main
crashTest!wmainCRTStartup
KERNEL32!BaseThreadInitThunk
ntdll!RtlUserThreadStart

et une fenêtre de code source nous affiche maintenant l'endroit précis où l'exception s'est produite :

void setMember(int val) { m_iVal = val; }

Un coup d'oeil à la fenêtre « Locals » nous indique aussi que la valeur du pointeur this est de 0xffffffffffffffff, ce qui concorde avec la cause de l'exception déterminée au point 5.

Peu importe le pattern, une fois la cause du bogue identifiée, il faut maintenant penser à un correctif à apporter au code pour éviter que cela ne se reproduise. Le débogueur ne peut malheureusement pas vous aider pour cette partie du travail.

Conclusion

Comme vous avez pu le constater, une séance de débogage avec WinDBG n'est pas une mince affaire. Pour les cas relativement simples, il peut être sage d'essayer tout d'abord son débogueur habituel avant de faire le saut vers WinDBG.

Cependant, pour des cas plus complexes comme le pattern d'exception dont la cause est cachée, il est possible d'aller chercher de l'information beaucoup plus détaillée avec WinDBG qu'il n'est possible de le faire avec Visual Studio.

Partager l’article :