1. Introduction▲
Lorsque nous programmons, nous créons des bogues. Il existe des méthodes pour les éviter ou pour les débusquer plus rapidement (assertions, tests unitaires, avertissement du compilateur…), mais cela ne suffit généralement pas. Vous êtes maintenant face à un bogue, vous avez un débogueur, mais vous ne savez pas comment découvrir l'origine de celui-ci. Vous êtes bloqué à relire votre code et vous n'avancez plus. Ce n'est pas grave, ce tutoriel est là pour guider vos premiers pas vers la solution.
Ce tutoriel vise à vous apprendre la réflexion à adopter lorsque vous vous retrouvez face à un bogue. En effet, les bogues sont uniques pour chaque programme et donc, au lieu de vous apprendre à résoudre des cas d'école, ce tutoriel vous apprendra à vous adapter et à analyser la situation pour localiser et supprimer tous les bogues.
1-A. Qu'est-ce que le débogage ?▲
Le débogage (de l'anglais « to debug ») indique l'action de retirer un bogue.
Un bogue, c'est lorsque vous avez programmé quelque chose, afin d'obtenir un résultat précis, mais que vous obtenez autre chose. Bien que vous ayez écrit le code pour réaliser une tâche, l'ordinateur ne le comprend pas et fait autre chose. En effet, la machine peut ne pas interpréter votre code exactement comme vous l'avez imaginé. Celle-ci est très « naïve » et effectue exactement ce qui est écrit et non ce que vous avez pu imaginer. Le fait de lui faire comprendre exactement ce que vous voulez, de lui ordonner exactement ce que vous souhaitez lui faire faire fait partie de la difficulté de la programmation. En résumé, il faut se mettre à la place de la machine.
Comme celle-ci n'est pas aussi évoluée que nous, il arrive un moment où nous ne pouvons pas juste deviner sa compréhension de notre code. On peut s'aider des fonctions d'affichage pour forcer la machine à nous indiquer la valeur des variables et une partie des opérations qu'elle effectue, mais cela ne suffit que très rarement. C'est là que le débogueur entre en jeu. Celui-ci permet de suivre exactement ce que fait la machine, de l'observer à la loupe afin de mieux comprendre son interprétation du code. Ainsi, en voyant ce qu'elle fait, vous pourrez découvrir à quel endroit la machine n'a pas agi comme vous le souhaitez et modifier le programme afin que son exécution par la machine corresponde à vos souhaits.
C'est en supprimant ces différences entre ce que vous voulez faire faire à la machine et ce qu'elle comprend de vos instructions que vous allez supprimer les bogues. Le débogueur est l'outil pour analyser, observer, vérifier le comportement de la machine lors du traitement de votre programme.
Et vous allez le voir, sa compréhension de votre programme est souvent éloignée de ce que l'on imagine.
2. Les débogueurs▲
2-A. Introduction▲
Les débogueurs sont des outils très puissants. Lorsque vous lancez votre programme au sein du débogueur, celui-ci suit le comportement de votre application et si un crash se produit, il s'arrête sur la ligne provoquant l'erreur. À partir de là, votre programme n'est pas réellement arrêté, il est en pause, mais ne pourra pas continuer. Le débogueur vous permet d'analyser son état, de voir les valeurs des variables, d'afficher la liste des fonctions appelées et ainsi de comprendre comment le programme en est arrivé là.
Si vous croyez que le débogueur ne sert qu'en cas de crash, c'est que vous ne connaissez pas toute sa puissance. En effet, vous pouvez indiquer à votre débogueur de s'arrêter à une ligne précise de l'application afin d'analyser l'état de celle-ci à cet endroit précis du code, ou même de l’exécuter ligne par ligne pour comprendre comment la machine interprète votre code.
Comme pour tout outil, il faut lire son mode d'emploi. Je vais fournir les informations de manière généraliste, afin que vous puissiez utiliser n'importe quel débogueur.
En complément, vous pouvez aussi lire les tutoriels suivants :
- tutoriel sur DDD (une interface graphique pour GDB), de Hiko Sejura ;
- tutoriel sur Microsoft Visual Studio de Laurent Gomila.
N'hésitez pas à tester les manipulations expliquées dans ce tutoriel. Il est plus simple d'apprendre tout en pratiquant.
2-B. Les débogueurs▲
Chaque EDI intègre un débogueur et cela, quel que soit le langage. Un débogueur peut être utilisable en ligne de commande ou à travers une interface graphique, mais dans tous les cas, il propose toujours les mêmes fonctionnalités.
Ainsi, ce tutoriel ne va pas nécessairement se reposer sur tel ou tel débogueur, mais vous expliquer de manière globale ce qu'un débogueur vous propose et comment l'utiliser pour comprendre le déroulement de votre programme.
2-C. Fonctionnalités▲
Un débogueur, qu'il soit en ligne de commande ou possédant une interface proposera (entre autres) les fonctionnalités suivantes :
- la possibilité de placer des points d'arrêt ;
- la possibilité de voir les valeurs des variables ;
- la possibilité d'afficher la pile d'appels ;
- la possibilité d'exécuter le programme pas à pas.
2-D. Définitions▲
2-D-1. Les points d'arrêt▲
Un point d'arrêt permet d'indiquer au débogueur de s'arrêter à une ligne spécifique dans le programme. En effet, pendant l'exécution du programme dans le débogueur, le débogueur s'arrêtera avant d'exécuter la ligne où le point d'arrêt est placé. Une fois le programme arrêté, il vous est possible d'afficher les valeurs des variables, ou de continuer l'exécution.
Généralement, un point d'arrêt est représenté par un point rouge dans la colonne à gauche du code (à côté des numéros de ligne).
2-D-2. Affichage des valeurs des variables▲
Lorsque le programme est arrêté au sein du débogueur (à l'aide d'un point d'arrêt, par exemple), il est possible d'afficher les variables utilisées par le programme.
Généralement, il suffit de laisser le curseur sur la variable, pour connaître sa valeur, ou en effectuant un clic droit sur la variable pour ajouter un moniteur (watch).
2-D-3. Pile d'appels▲
La pile d'appels est un mécanisme permettant au programme de revenir à la fonction appelante, lorsque l'exécution d'une fonction est finie. Pour cela, à l'appel d'une fonction, celle-ci est enregistrée dans la pile d'appels. Une fois que la fonction est finie, le programme doit retourner dans la fonction appelante pour continuer l'exécution du programme. Pour ce faire, le processeur regarde la dernière fonction placée dans la pile d'appels et y retourne tout en l'enlevant de la pile.
La pile d'appels peut être affichée à l'aide de votre débogueur afin de voir quelles sont les fonctions qui ont été appelées pour arriver à ce point du programme.
La pile d'appels est aussi très intéressante, car lorsqu’un crash est détecté par le débogueur, il survient généralement dans les fonctions de la bibliothèque que vous utilisez (comme la bibliothèque standard du C) et vous devez remonter la pile d'appels afin de savoir par quel chemin votre programme est passé pour arriver à ce crash.
Même si votre débogueur stoppe dans les fonctions d'une bibliothèque, il ne faut pas immédiatement rejeter la faute sur la bibliothèque. En effet, si un crash survient, cela sera sûrement dû à votre mauvaise utilisation de la fonction de la bibliothèque.
Notez qu'une bibliothèque est un code utilisé par de nombreuses personnes. Il a été testé et mis à l'épreuve, vous pouvez donc le considérer comme sain et sans bogue.
2-D-4. Exécution pas à pas (contrôle de l'exécution)▲
Une fois que le programme est mis en pause dans le débogueur, il y a plusieurs façons de continuer son exécution.
Vous pouvez demander au débogueur de continuer l'exécution et il s'arrêtera au prochain point d'arrêt ou finira l'exécution du programme.
Vous pouvez aussi demander d'exécuter la ligne actuelle et le débogueur s'arrêtera à la ligne suivante (appelée exécution pas à pas). Cela est très pratique pour vérifier que les valeurs des variables sont cohérentes et correspondent à ce que vous souhaitez.
Vous pouvez aussi demander au débogueur d'exécuter la ligne actuelle, tout en entrant dans les sous-fonctions. Le comportement est proche du cas précédent, mais permet aussi de déboguer les sous-fonctions.
Vous pouvez entrer dans les sous-fonctions des bibliothèques, mais généralement cela ne mène à rien, car soit le code n'est pas accessible, soit vous pouvez le considérer comme exempt de bogue.
Toutefois, lorsque vous avez accès à son code, cela peut être pratique pour comprendre le fonctionnement de la bibliothèque (en plus de sa documentation).
Lorsque le débogueur s'arrête à la suite d’un crash, il n'est pas possible de continuer l'exécution du programme. Si vous essayez, le programme sera complètement arrêté.
3. Utilisation d'un débogueur▲
Tous les débogueurs proposent une série de fonctionnalités communes permettant d'analyser le comportement d'un programme. Dans ce chapitre, je vais décrire comment utiliser les débogueurs les plus courants : GDB, Code::Blocks, Microsoft Visual Studio, Eclipse.
Vous pouvez utiliser un autre débogueur et vous remarquerez très rapidement que son utilisation est similaire à celle vue ici.
Vous remarquerez que j'ai aussi intégré Eclipse. Quel que soit le langage de programmation, les fonctionnalités proposées par le débogueur restent les mêmes. Finalement, que vous déboguiez un programme C/C++/Java ou autre, le procédé reste le même et la méthode est identique.
3-A. GDB, un débogueur en ligne de commande▲
GDB est une abréviation pour GNU Debugger. Il est disponible pour une multitude de systèmes.
GDB est un débogueur fonctionnant dans la console. Il est principalement utilisé sous Linux et peut être quelque peu austère pour les débutants. Toutefois, ce logiciel dépanne bon nombre de programmeurs et malgré une utilisation en ligne de commande, son efficacité n'en est pas réduite.
Nombreux sont les éditeurs de code intégrant une surcouche à GDB. En effet, Code::Blocks et Qt Creator peuvent utiliser GDB en arrière-plan pour vous proposer un débogage graphique. Sachez aussi qu'il existe DDD, une interface graphique pour GDB, sous Linux. Ce dernier peut vous permettre d'apprendre à utiliser GDB, car il indique toutes les commandes passées au débogueur.
3-B. Code::Blocks/Microsoft Visual Studio/Eclipse▲
Les logiciels Code::Blocks/Microsoft Visual Studio/Eclipse sont des éditeurs de développement intégrés (EDI) et proposent donc un débogueur intégré. Directement dans l'interface de votre code, vous accéderez aux fonctionnalités du débogueur, après avoir lancé le programme dans celui-ci (généralement à l'aide du menu : Debug/Start ou l'icône en forme de curseur de lecture : ).
3-C. Débogage▲
3-C-1. Découverte des fonctionnalités▲
3-C-1-A. Commandes et icônes▲
Voici les commandes et boutons disponibles dans chacun des débogueurs :
Action |
GDB
|
Code::Blocks |
Microsoft Visual Studio
|
Microsoft Visual Studio
|
Eclipse |
Qt Creator |
Démarrer ou continuer l'exécution du code jusqu'à la fin, ou le prochain point d'arrêt |
run/continue |
|
|
|
|
|
Exécuter une ligne (sans entrer dans la fonction) |
next |
|
|
|
|
|
Exécuter une ligne (en entrant dans la fonction) |
step |
|
|
|
|
|
Continuer le programme jusqu'à sortir de la fonction actuelle |
finish |
|
|
|
|
|
Stopper le débogage (et le programme débogué) |
stop |
|
|
|
|
|
Pour afficher la pile d'appels, les moniteurs… |
backtrace |
|
|
|||
Placer un point d'arrêt |
break fichier:numéro_de_ligne |
Un clic à côté du numéro de la ligne permet de placer un point d'arrêt (cela rajoute un petit cercle pour indiquer la présence du point d'arrêt). |
Les EDI proposent des dispositions de fenêtres propres au débogage (intégrant les fenêtres des moniteurs, de pile d'appels et autres fenêtres) qui sont sauvegardées à la fin du débogage.
3-C-1-B. Point d'arrêts▲
Les points d'arrêts sont une arme redoutable face aux bogues. Ceux-ci permettent d'arrêter le programme n'importe où. Il suffit de spécifier la ligne sur laquelle le débogueur doit s'arrêter et il le fera juste avant l'exécution de celle-ci.
Action |
GDB
|
Code::Blocks |
Microsoft Visual Studio
|
Microsoft Visual Studio
|
Eclipse |
Qt Creator |
Placer un point d'arrêt |
break fichier:numéro_de_ligne |
Un clic à côté du numéro de la ligne permet de placer un point d'arrêt (cela rajoute un petit cercle pour indiquer la présence du point d'arrêt) |
Une fois que le programme a atteint le point d'arrêt, le débogueur le met en pause. À partir de là, vous pouvez soit reprendre l'exécution du programme, soit afficher les valeurs des variables ou de la liste d'appels.
3-C-1-C. Contrôle de l'exécution▲
Les commandes suivantes permettent de contrôler le code exécuté. En effet, lorsque votre programme s'est arrêté à un point d'arrêt, vous avez plusieurs choix pour avancer dans le programme :
Action |
GDB
|
Code::Blocks |
Microsoft Visual Studio
|
Microsoft Visual Studio
|
Eclipse |
Qt Creator |
Exécuter une ligne (sans entrer dans la fonction) |
next |
|
|
|
|
|
Exécuter une ligne (entrer dans la fonction) |
step |
|
|
|
|
|
Continuer le programme jusqu'à sortir de la fonction actuelle |
finish |
|
|
|
|
|
Voici où chaque commande emmènera le programme après son exécution. Le programme est actuellement arrêté par un point d'arrêt sur la ligne 10 (indiquée par la flèche) :
En détail, il faut comprendre que :
- next exécutera la fonction bar(), comme si cela était une unique instruction (« Bonjour monde » est affiché) ;
- step exécutera l'appel de la fonction bar() et s'arrêtera avant le printf() (« Bonjour monde » n'est pas affiché) ;
- finish sortira de la fonction foo() en exécutant le reste de la fonction (donc, la fonction bar()). Le débogueur s'arrêtera avant le return 0 (« Bonjour monde » est affiché et le programme ne quitte pas le main).
Avec ces commandes, vous pouvez précisément contrôler l'avancement du programme et ainsi vérifier chacun des calculs effectués par celui-ci.
3-C-1-D. Affichage de valeurs▲
Notre programme est en pause dans le débogueur. Celui-ci peut afficher le contenu de chacune des variables accessibles à l'endroit où le programme est.
Action |
GDB
|
Code::Blocks |
Microsoft Visual Studio
|
Microsoft Visual Studio
|
Eclipse |
Qt Creator |
Afficher le contenu d'une variable |
print nom_de_la_variable |
Souvent, il suffit de laisser le curseur quelques instants sur la variable. Sinon, vous pouvez faire un clic droit et cliquer sur « ajouter moniteur »/« ajouter à la fenêtre des observateurs » (« Add Watch »/« Watch… »). |
Seules les variables actuellement visibles par le programme peuvent être affichées. En effet, la règle de la portée des variables s'applique, car les variables hors de portée n'existent pas et donc ne peuvent pas être affichées.
3-C-1-E. Pile d'appels▲
La pile d'appels correspond à la liste des fonctions qui ont été appelées pour arriver à un point précis du programme. Pour faire simple, c'est le chemin qu'a pris le programme.
Action |
GDB
|
Code::Blocks |
Microsoft Visual Studio |
Microsoft Visual Studio 2013 |
Eclipse |
Qt Creator |
Afficher la pile d'appels |
backtrace |
|
|
En anglais la pile d'appels est appelée « backtrace » ou encore « call stack ».
Vous pouvez vous déplacer dans la liste d'appels (et afficher le code d'appel à telle ou telle fonction) en cliquant sur les lignes affichées ou avec les commandes GDB up/down (pour remonter ou redescendre dans la pile).
Généralement, la pile d'appels affiche le nom des fonctions appelées (avec leurs arguments et, si possible, la valeur de ces arguments), l'adresse mémoire de l'appel, le nom du fichier et la ligne où l'appel a lieu.
De plus, il arrive souvent que les appels indiqués présentent des fonctions d'une bibliothèque, vous devez vous concentrer sur l'appel le plus profond de votre code. Dans l'exemple ci-dessus, raise/abord/__libc_message/malloc_printerr/__GI___libc_free sont des fonctions de la bibliothèque standard (appelées par free()) et ne sont pas intéressantes. La ligne qui nous intéresse donc est celle du main(), car c'est l'origine du crash dans notre code.
Il faut comprendre que la bibliothèque standard ou les autres bibliothèques ne sont pas boguées. Elles crashent suite à une mauvaise utilisation effectuée par le programmeur. Dans l'exemple ci-dessus, free() crashe, car le pointeur passé à free() est invalide.
3-C-2. Utilisation du débogueur▲
Nous avons vu les fonctionnalités de base du débogueur et nous savons maintenant comment les activer. Tout cela est bien beau, mais il faut désormais comprendre ce qu'est le débogage et l'attitude qu'il faut avoir face à un bogue.
3-C-2-A. Qu'est-ce qu'un bogue ?▲
Généralement, un bogue est un comportement inattendu d'un programme. Par exemple, vous souhaitez que votre programme fasse une opération précise : une addition. Suivant les entrées que vous lui donnez, vous espérez avoir le résultat qui suit les règles mathématiques. Toute déviation de ce résultat sera considérée comme un bogue. En effet, vous ne voulez pas que le programme réponde 3 lorsque vous lui donnez en entrée 1 et 1.
Mais ce n'est pas tout. Certains bogues provoquent un crash du programme, ce qui fait qu'ils sont très visibles et aussi très gênants. Il existe d'autres bogues, totalement invisibles, qui ne crasheront pas le programme, et qui ne donneront pas nécessairement de mauvais résultats. En effet, cela peut être simplement une mauvaise interaction avec le système, ou encore une occupation en mémoire jugée trop importante.
En résumé, un bogue est un comportement non voulu du programme.
3-C-2-B. Comment corriger un bogue ?▲
Pour corriger un bogue, il faut comprendre la raison de sa présence. Tant que vous ne comprenez pas son origine et pourquoi il se produit, vous serez incapable de mettre en place une correction totalement efficace.
Pour comprendre la raison d'un bogue, vous avez plusieurs fois :
- relu le code et vous voyez une erreur dans celui-ci. Cela ne fonctionne que pour les petites erreurs « évidentes » ou grâce à l'expérience, mais sera inefficace dans les cas où des structures de données ou des algorithmes un peu plus évolués sont intégrés au code ;
- ajouté des cout/print pour afficher la valeur des variables. Dans un sens, c'est ce que fait le débogueur. Certes, c'est rapide. Malheureusement, le fait d'ajouter un appel de fonction peut faire disparaître la conséquence du bogue (mais pas le bogue en lui-même) ;
- essayé de déduire l'erreur, à l'aide d'une multitude de variables d'entrées. Si le bogue a un comportement « logique » et « régulier », alors vous pourrez le deviner suivant les différents résultats du programme. Toutefois, c'est inefficace dans un cas où le bogue est lié à la mémoire ;
- il ne reste plus que le débogueur. Celui-ci vous permettra d'analyser aussi bien les valeurs des variables que la mémoire, mais aussi de suivre le programme pas à pas et de valider son comportement.
Une fois que vous avez trouvé un bogue, il suffit de le corriger dans le code source. Si vous avez compris ce qui se passait et pourquoi le bogue apparaissait, la correction vous sera évidente.
Lors d'un crash, le débogueur aura un avantage indéniable. En effet, le débogueur s'arrête à la ligne provoquant le crash. C'est bien plus efficace que de placer une multitude de print/cout afin de voir le dernier qui s'affiche. D'autant plus que les fonctions d'affichage sont mises en tampon et peuvent ne pas être affichées immédiatement (et donc ne jamais s'afficher lors d'un crash).
3-C-2-C. Débusquer un bogue avec le débogueur : un jeu d'enquêteur▲
Supprimer un bogue est très semblable à une enquête policière. Vous lancez votre programme et celui-ci produit un bogue. Vos premiers indices sont le mauvais comportement du programme. Pourquoi produit-il ces mauvais résultats ?
- Un crash ? Pourquoi le programme crashe, ou plutôt, où le programme crashe ? En lançant le programme dans un débogueur, ce dernier s'arrêtera à la ligne du crash. Parfait pour connaître où le programme crashe. Si le débogueur s'arrête dans les fonctions d'une bibliothèque, regardez la pile d'appels pour voir quelle est la dernière fonction de votre programme qui a été appelée. Bien souvent, ce crash est dû a un pointeur invalide (ou nul) ou de mauvaises valeurs de variables passées à une fonction.
- Un mauvais résultat ? Réfléchissez à la façon dont vous produisez ces résultats. Quelles sont les fonctions à l'origine de ce résultat. Placez un point d'arrêt au début des fonctions suspectées et vérifiez que chacune d'elles fait correctement son travail. Pour cela, n'hésitez pas à exécuter les fonctions pas à pas pour vérifier que les valeurs des variables restent correctes (en les affichant).
- Une condition ne s'exécute pas alors que, dans un tel cas, elle le devrait. Placez un point d'arrêt devant la condition. Vérifiez les valeurs des variables dans le test, en les affichant.
N'hésitez pas à utiliser le débogueur et à analyser le comportement de votre programme avec celui-ci. En effet, la vision que vous avez de votre code peut être très loin de l'utilisation que l'ordinateur en fera. Très souvent, il ne fait pas ce que vous pensiez.
4. Cas pratique▲
Nous allons maintenant étudier et comprendre plusieurs cas d'erreur courants afin que vous sachiez quoi faire lorsque vous allez avoir des bogues. Malheureusement, malgré ma volonté de vouloir faire un article générique, mes exemples seront dépendants d'un langage (ici, le C). Toutefois, la façon de penser pour retrouver une erreur est la même pour tous les langages.
4-A. Double affichage d'un menu▲
Le code suivant contient un bogue. Il affiche deux fois le menu, sans raison !
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
unsigned
short
menu
(
)
{
char
saisie[100
];
unsigned
short
choix;
while
(
1
)
{
printf
(
"
\t
****************** Bienvenue Au Grand Bazar ******************
\n\n\n
"
);
printf
(
"
0 :Quitter
\n
"
);
printf
(
"
\n
Votre choix :
"
);
fflush
(
stdout);
fgets
(
saisie,100
,stdin);
if
(
sscanf
(
saisie, "
%hu
"
, &
choix) ==
1
&&
choix ==
0
) break
;
printf
(
"
Choix incorrect - Recommencez !!!
\n
"
);
}
return
choix;
}
int
main
(
)
{
//Mise en place d'un systeme de mot de passe
int
motpasse =
77
;
int
code;
do
{
printf
(
"
veuillez saisir le code :
"
);
scanf
(
"
%d
"
,&
code);
if
(
code==
motpasse)
{
menu
(
);
}
else
{
printf
(
"
Erreur de code !!!
"
);
}
}
while
(
code !=
motpasse);
return
0
;
}
Si vous l'exécutez et que vous entrez 77 (le code valide), vous obtenez :
veuillez saisir le code : 77
****************** Bienvenue Au Grand Bazar ******************
0 :Quitter
Votre choix : Choix incorrect - Recommencez !!!
****************** Bienvenue Au Grand Bazar ******************
0 :Quitter
Votre choix :
Pourquoi ce code affiche-t-il deux fois le menu ? Le débogueur va être là pour mettre en évidence un comportement ne semblant pas avoir été prévu.
Ce que nous voyons, c'est un double affichage du menu. Mettons donc un point d'arrêt là où commence l'affichage du menu : à la ligne 9, while(1). Si nous lançons le programme (dans le débogueur), il va nous demander le mot de passe. Si nous mettons un mot de passe faux, il boucle. Si nous mettons un mot de passe juste, il va s'arrêter sur le point d'arrêt. Jusque-là, nous pouvons présumer que le code pour le mot de passe est juste et sans bogue.
Nous sommes donc à la ligne 9. Le menu n'est pas encore affiché. Passons les lignes 10 à 16, avec la commande step. Comme nous exécutons le programme ligne par ligne, nous voyons les lignes s'afficher à chaque step effectué. Enfin, nous arrivons à la ligne 16. Lorsque cette ligne va s'exécuter, elle doit demander à l'utilisateur d'entrer quelque chose. Soit, exécutons la ligne avec step. Celle-ci passe à la ligne suivante, sans même attendre que nous rentrions quoi que ce soit ! Si nous continuons le programme, on voit bien qu'il affiche une seconde fois le menu et que fgets() se bloque comme attendu.
fgets(), dans notre cas, lit sur l'entrée standard. Habituellement, la fonction se bloque en attendant que l'utilisateur rentre son choix. fgets() provenant de la bibliothèque standard n'est pas boguée. Mais alors, que contient donc la variable saisie, variable utilisée pour récupérer ce que fgets() a lu ?
Le débogueur peut nous le dire. Affichons donc cette variable :
saisie "\n\000\377\367..."
Chez vous, il peut y avoir d'autres choses qui suivent, mais les deux premiers caractères de la chaine de caractères saisie sont toujours les mêmes : \n et \000. \n indique un saut de ligne. \000 c'est équivalent à \0 ou, plus précisément, la fin d'une chaine de caractères.
La question est donc : d'où vient ce \n ? Simplement, il vient de la saisie du mot de passe faite précédemment. En effet, le scanf() pour le mot de passe ne lit que le nombre et pas le \n qui est inséré par l'appui sur la touche entrée. Du coup, le « \n » reste dans le tampon d'entrée et est par la suite récupéré par le fgets(). La fonction pense que c'est une entrée utilisateur et donc continue le programme. Et comme le choix est invalide, le programme affichera une seconde fois le menu.
Pour corriger le problème maintenant repéré, il suffit de vider le tampon d'entrée.
Ici, le débogueur nous a permis de mieux comprendre comment le programme fonctionnait. Grâce à l'exécution du programme pas à pas, nous avons pu voir un comportement inattendu (le fgets qui ne bloque pas), mais aussi nous avons pu comprendre la raison (affichage de la variable saisie). Une fois le bogue débusqué, il suffit de le corriger et tout va mieux.
Source du problème : https://www.developpez.net/forums/d1470116/c-cpp/c/debuter/j-ai-probleme-menu-s-affiche-double/
5. Erreur de segmentation (segmentation fault)▲
L'erreur de segmentation est une erreur très répandue, mais loin d'être difficile à corriger.
Tout d'abord, il faut comprendre que l'erreur de segmentation signifie que votre programme a été arrêté par le système d'exploitation suite à une opération invalide. L'opération invalide la plus courante est un accès mémoire interdit. En effet, votre programme se voit attribuer une partie de la mémoire. Si vous tentez un accès (lecture ou écriture) en dehors de la zone que le système vous a attribuée, celui-ci va vous stopper. Malheureusement, ce n'est pas toujours vrai. Cela est même « aléatoire » (comprendre : non prévisible, car dépend de l'agencement actuel de la mémoire, donc de l'utilisation passée de la mémoire par le reste du système).
Lorsque vous avez une erreur de segmentation, la première chose à faire est de voir si l'erreur est reproductible. Une erreur reproductible est plus facile à étudier. De plus, si vous lancez le programme défectueux dans un débogueur, ce dernier s'arrêtera en vous affichant la ligne fautive. Une fois la ligne identifiée, il suffit généralement d'afficher les valeurs des variables en jeu dans cette ligne afin de connaître la cause de l'erreur. Ensuite, il ne reste plus qu'à comprendre comment le programme en est venu à affecter cette valeur à cette variable. Cela peut se faire par une simple relecture du code, ou en utilisant le débogage pas à pas.
Prenons quelques exemples pour illustrer.
5-A. Pointeur non initialisé▲
Soit, le code d'exemple suivant :
#include <stdio.h>
typedef
struct
MaStruct
{
int
foo;
int
bar;
}
MaStruct;
int
main
(
)
{
MaStruct*
pStruct1;
MaStruct*
pStruct2 =
NULL
;
pStruct1->
foo =
10
;
pStruct2->
bar =
5
;
return
0
;
}
Les lignes pStruct1->foo et pStruct2->bar vont provoquer une erreur de segmentation. Plus précisément, le problème est que l'on essaie d'accéder à un membre d'une structure, mais cette structure n'a pas été définie en mémoire (pas d'allocation de mémoire associée).
Un débogueur s'arrêtera directement sur la ligne en question. Si on regarde le contenu des variables, pStruct1 aura une valeur aléatoire et pStruct2, car elle est définie à la déclaration, vaudra NULL.
Sur certains systèmes (ou avec certains compilateurs), pStruct1 peut, lui aussi, être initialisé à NULL. Mais, comme cela n'est pas un comportement généralisé, il vaut mieux déclarer tout pointeur à NULL.
Comme une variable qui n'est pas initialisée peut avoir une valeur aléatoire, il est vivement conseillé de lui donner une valeur à la déclaration. Cela vous évitera bien des erreurs. Notamment, il est facile de savoir l'état d'un pointeur (valide ou pas) si celui-ci est à NULL ou pas. Chose qui ne serait pas possible si vous ne définissiez pas vos pointeurs à NULL lors de la déclaration.
Sachez que si vous utilisez un compilateur correctement configuré (ayant les avertissements activés au niveau maximal : options « -Wall -Wextra » pour GCC), le compilateur vous retournera un message indiquant que vous utilisez pStruct1 alors que celui-ci n'a pas été initialisé.
Si vous corrigez l'avertissement du compilateur, vous corrigez un bogue, c'est pour cela qu'il est fortement conseillé d'activer les avertissements et d'en prendre compte.
Un code corrigeant les erreurs pourrait être le suivant :
#include <stdio.h>
typedef
struct
MaStruct
{
int
foo;
int
bar;
}
MaStruct;
int
main
(
)
{
MaStruct*
pStruct1 =
malloc
(
sizeof
(
MaStruct));
MaStruct*
pStruct2 =
NULL
;
if
(
pStruct1 !=
NULL
)
{
pStruct1->
foo =
10
;
}
if
(
pStruct2 !=
NULL
)
{
pStruct2->
bar =
5
;
}
free
(
pStruct1);
return
0
;
}
Cette fois, j'ai alloué de la mémoire pour pStruct1. Pour pStruct2, je corrige l'erreur en vérifiant si mon pointeur est à NULL ou non (s'il est valide ou non).
Notez que je vérifie aussi la validité de pStruct1, car celui-ci pourrait valoir NULL si malloc() ne trouve pas assez de mémoire disponible pour allouer ma structure.
5-B. Pointeur non initialisé, le retour▲
Un autre cas de pointeur non initialisé est le suivant :
#include <stdio.h>
typedef
struct
MaStruct
{
int
foo;
int
bar;
}
MaStruct;
void
maFonction
(
MaStruct*
pStruct)
{
pStruct->
foo =
100
;
}
int
main
(
)
{
MaStruct*
pStruct1 =
NULL
;
maFonction
(
pStruct1);
return
0
;
}
Le crash se produira dans la fonction maFonction(). Le problème est que la variable passée à votre fonction n'est pas nécessairement gérée par vous. Imaginons que vous travaillez à plusieurs et que par malheur un de vos collègues passe un pointeur NULL à votre fonction, le crash n'est pas totalement de votre faute.
Toutefois, il l'est un peu et vous pouvez faire en sorte de faire mieux. En effet, vous pouvez toujours vérifier si le pointeur passé est NULL. Pour cela, il existe les assertions implémentées dans la fonction assert(). Il faut savoir que la fonction assert() est une fonction qui ne sera exécutée que si le programme est compilé en « debug ». La fonction accepte comme argument un test. Si celui-ci correspond à zéro (ou faux) alors la fonction arrête le programme et affiche une ligne explicite de l'endroit où il s'est arrêté. Sinon, le programme continue tranquillement.
Voici un code corrigeant l'erreur, tout en utilisant l'assertion :
#include <stdio.h>
#include <assert.h>
typedef
struct
MaStruct
{
int
foo;
int
bar;
}
MaStruct;
void
maFonction
(
MaStruct*
pStruct)
{
assert
(
pStruct);
pStruct->
foo =
100
;
}
int
main
(
)
{
MaStruct struct1;
maFonction
(&
struct1);
return
0
;
}
Comme expliqué, j'ai rajouté un assert() afin de m'assurer que le pointeur passé à ma fonction n'est pas NULL.
La correction consiste à définir la structure statiquement (afin d'éviter de gérer la mémoire liée au pointeur) et de passer un pointeur sur cette structure, grâce au « & » à la fonction.
La plupart des bogues arrivent à cause des pointeurs. Il est donc préférable de les éviter, comme montré dans cet exemple.
L'assertion est certes un mécanisme fort, mais il ne faut pas l'utiliser pour, par exemple, vérifier des cas d'erreurs (retours de fonctions). En effet, assert() n'étant présent que si le programme est compilé en « debug », lors de la publication de votre programme, les assertions seront enlevées par le compilateur. Il ne remplace donc pas les tests que vous faites, mais vous protège juste des erreurs de programmation.
5-C. Double free▲
Un autre cas de crash survient lorsque l'on essaie de supprimer deux fois le même espace mémoire :
#include <stdio.h>
#include <stdlib.h>
int
main
(
)
{
int
*
pTab =
malloc
(
sizeof
(
int
) *
10
);
pTab[5
] =
42
;
free
(
pTab);
free
(
pTab);
return
0
;
}
Le deuxième free() causera un crash.
Pour corriger le problème, je propose le code suivant :
#include <stdio.h>
#include <stdlib.h>
int
main
(
)
{
int
*
pTab =
malloc
(
sizeof
(
int
) *
10
);
pTab[5
] =
42
;
free
(
pTab); pTab =
NULL
;
free
(
pTab);
return
0
;
}
Ce code fonctionne, car un free() d'un pointeur NULL ne fait rien. Ainsi, le conseil est donc de remettre le pointeur à la valeur NULL une fois que la mémoire est libérée. D'une part, cela permet de savoir si le pointeur est valide ou non et cela peut permettre d'éviter des erreurs.
6. Fuite de mémoire▲
Les fuites de mémoire font parties des problèmes spécifiques aux langages qui laissent une gestion partielle ou totale de la mémoire au programmeur. Le C et C++ sont les premiers programmes touchés par les fuites de mémoire.
En elle-même, la fuite de mémoire n'altère pas directement le comportement de votre programme. Toutefois, si votre programme consomme toujours plus de mémoire, sans se stabiliser, il est certain qu'un jour vous allez atteindre les limites de la machine et finir par crasher. Ainsi, les fuites sont une plaie pour tout programme qui ne doit jamais être arrêté et continuer pour des années (cas des programmes dans les systèmes embarqués, comme ceux pour les satellites).
Concrètement, qu'est-ce qu'une fuite de mémoire ? La fuite consiste en un pointeur (donc une zone mémoire) qui n'est plus accessible (la variable pointeur a été remplacée ou perdue).
Sous forme de code, ce qui suit a une fuite de mémoire :
#include <stdio.h>
#include <stdlib.h>
int
main
(
)
{
int
*
pInt =
malloc
(
sizeof
(
int
));
pInt =
NULL
; // Fuite
// Car nous n'avons plus la possibilité de faire un free
// de l'espace mémoire retourné par le malloc
return
0
;
}
Ainsi, une fuite de mémoire apparaît à chaque fois qu'un malloc() n'a pas de free() correspondant.
Dans un grand programme, les fuites de mémoire peuvent devenir impossibles à trouver, il est donc nécessaire d'utiliser un programme pour nous aider. Je parle ici, de valgrind ou de Dr. Memory.
6-A. Valgrind▲
Valgrind n'est malheureusement disponible que sous Linux. Son comportement est particulier, car il va exécuter le programme tout en vérifiant tous les accès mémoire. Dès qu'un de ces accès est invalide, il affiche une erreur. Lorsqu'il a fini, il va faire un résumé des allocations/désallocations du programme et indiquer le nombre d'octets perdus (les fuites).
L'utilisation de Valgrind est simple :
valgrind --leak-check=full --show-reachable=yes monProgramme
Pour connaître le récapitulatif des fuites de mémoire, il suffit de lire la dernière partie du rapport :
LEAK SUMMARY:
==19814== definitely lost: 92 bytes in 4 blocks
==19814== indirectly lost: 25,664 bytes in 11 blocks
==19814== possibly lost: 8,176 bytes in 1 blocks
==19814== still reachable: 120,614 bytes in 1,348 blocks
==19814== suppressed: 0 bytes in 0 blocks
- definitely lost : indique la mémoire totalement perdue. Une fuite de mémoire est à corriger ;
- indirectly lost : indique la mémoire perdue dans les structures basées sur des pointeurs (par exemple, si une racine d'un arbre est définitivement perdue, les enfants sont indirectement perdus). Si vous corrigez une fuite définitivement perdue, il y a de fortes chances que les fuites indirectement perdues partent aussi ;
- possibly lost : indique une perte de mémoire dont Valgrind n'est pas totalement sûr (utilisation étrange des pointeurs) ;
- still reachable : indique une fuite dont vous avez toujours accès aux pointeurs (donc facilement corrigible) ;
- suppressed : indique les fuites de mémoire qui ont été supprimées.
Les erreurs (fuites de mémoire, accès en dehors de la mémoire allouée) sont représentées de la façon suivante :
==19814== 25,544 (56 direct, 25,488 indirect) bytes in 1 blocks are definitely lost in loss record 169 of 170
==19814== at 0x4C27CC1: operator new(unsigned long) (vg_replace_malloc.c:261)
==19814== by 0x407C03: NE::SDL_Engine::initAPI() (SDL_Engine.cpp:65)
==19814== by 0x4042D7: NE::NEngine::init() (NEngine.cpp:43)
==19814== by 0x444C92: main (main.cpp:49)
Comme vous pouvez le remarquer, les renseignements donnés sont précieux. On voit facilement l'origine de l'erreur.
Lors de l'utilisation de Valgrind avec un programme Qt, celui-ci rapporte de nombreux problèmes liés aux classes de Qt. Afin que ces messages faux positifs soient cachés, il est nécessaire d'utiliser l'intégration de Valgrind dans Qt Creator.
Afin que Valgrind puisse faire son travail correctement, le programme doit contenir les informations de débogage.
6-B. Dr. Memory▲
Dr. Memory est disponible aussi bien sous Linux que sous Windows. Son utilisation et son fonctionnement sont très proches de ceux de Valgrind.
Pour le lancer, il suffit d'écrire dans une invite de commandes :
drmemory -show_reachable monProgramme
ou encore de glisser votre exécutable sur l’icône de Dr. Memory.
Une fois l'exécution terminée, Dr. Memory affiche son verdict. À la fin de celui-ci, vous trouverez un récapitulatif des fuites de mémoire :
ERRORS FOUND:
0 unique, 0 total unaddressable access(es)
0 unique, 0 total uninitialized access(es)
2 unique, 2 total invalid heap argument(s)
0 unique, 0 total GDI usage error(s)
0 unique, 0 total handle leak(s)
0 unique, 0 total warning(s)
1 unique, 1 total, 40 byte(s) of leak(s)
0 unique, 0 total, 0 byte(s) of possible leak(s)
0 unique, 0 total, 0 byte(s) of still-reachable allocation(s)
- unaddressable access : indique un accès (lecture ou écriture) à de la mémoire non allouée par votre programme ;
- uninitialized access : indique la lecture d’une donnée qui a été allouée, mais pour laquelle aucune valeur ne lui a été définie ;
- invalid heap argument : indique les pointeurs passés aux fonctions liées à la mémoire (free(), realloc()…), mais qui ne pointent sur aucune zone mémoire valide ;
- GDI usage error : erreurs liées à l'utilisation de la bibliothèque GDI (Windows) ;
- handle leak : les identifiants de ressources (handles) qui sont toujours ouverts à la fin de l'exécution du programme ;
- leak : indique les fuites de mémoire ;
- possible leak : indique les fuites de mémoire liées aux pointeurs qui pointent sur le milieu d'un segment mémoire et non sur le début ;
- still-reachable : indique de la mémoire qui est toujours accessible.
Dans le rapport, chaque erreur est détaillée :
Error #3: LEAK 40 direct bytes 0x02050f90-0x02050fb8 + 0 indirect bytes
# 0 replace_operator_new_array [d:\drmemory_package\common\alloc_replace.c:2642]
# 1 main [d:\developpement\sfml\sfml 2.2\sfml_2_2_template_vs2013\src\main.cpp:9]
Comme vous pouvez le remarquer, les renseignements donnés sont précieux. On voit facilement l'origine de l'erreur.
Afin que Dr. Memory puisse faire son travail correctement, le programme doit contenir les informations de débogage.
7. Explications supplémentaires▲
Quel que soit le langage que vous utilisez, il arrive toujours un moment où vous allez produire une erreur de segmentation (segmentation fault, ou seg-fault). Même si dans certains langages (Java, C#…) il est plus difficile de les produire, les erreurs restent possibles.
7-A. Les erreurs de segmentation en Java▲
Précédemment, nous avons vu les erreurs de segmentation. En Java, elles existent aussi, mais sont représentées par des exceptions. Reprenons nos cas d'erreurs de segmentation :
- une variable pointeur n'est pas initialisée et donc, pointe « n'importe où ». Ce cas n'est pas possible en Java ;
- vous accédez au onzième élément, alors que votre tableau ne contient que dix éléments. Dans un tel cas, l'exception IndexOutOfBoundsException sera levée ;
- une variable pointeur est à NULL. Dans un tel cas, l'exception NullPointerException sera levée.
7-B. Mon bogue n'est pas reproductible dans le débogueur !▲
Il arrive qu'un bogue ne puisse pas être reproduit lorsque vous lancez votre programme dans le débogueur. Voici quelques pistes pour ce phénomène :
- vous n'avez pas initialisé une variable. La compilation avec les options de débogage fait que le compilateur initialise les variables à zéro, même si vous ne le spécifiez pas. Le débogueur peut agir de même. Il est donc important de toujours initialiser les variables pour éviter de tomber dans ce cas ! ;
- vous avez une corruption de la mémoire (accès hors d'un tableau, modification de la mémoire involontaire…) Il arrive que les débogueurs soient un peu plus laxistes et laissent le programme continuer. Dans ce cas, utilisez un analyseur mémoireFuite de mémoire, pour vérifier que le programme ne fait pas n'importe quoi ;
- vous utilisez des threads et vous avez une race condition. L'exécution du programme étant plus lente dans le débogueur (même si cela n'est pas perceptible), la race condition ne se produit pas. Dans un tel cas, les débogueurs peuvent avoir des options supplémentaires pour mieux gérer les threads. Une relecture du code, une réflexion sur l'algorithme et l'utilisation de mécanisme de synchronisation entre threads permettront de supprimer le bogue.
8. Conseils supplémentaires▲
Tout le monde fait des bogues. C'est lié à la différence de réflexion et de compréhension du programme entre l'être humain et la machine. Toutefois, depuis le temps, les développeurs ont mis en place de nombreuses techniques pour repérer les bogues au plus tôt. Nous citerons :
- les avertissements du compilateur : le compilateur est capable de vérifier le code et de pointer quelques soucis dans son écriture. Alors, n'hésitez pas, activez tous les avertissements que le compilateur peut vous fournir ;
- les assertions : les assertions sont des mécanismes qui peuvent permettre de vérifier que l'état du programme est bien celui attendu. Attention, ces mécanismes ne sont activés que lorsque le programme est compilé en « debug » ;
- les analyseurs statiques : ils viennent en complément du compilateur et se spécialisent dans la recherche des soucis en analysant le code ;
- les tests unitaires : ce sont des tests que vous allez programmer vous-même, permettant de vérifier le bon fonctionnement de vos fonctions. Ainsi, vous pourrez valider votre code au plus tôt ;
- les bonnes pratiques de programmation. De nombreuses pratiques existent pour éviter des bogues courants. C'est ce que l'on appellera : écrire du code propre. L'une de celles-ci est de toujours donner une valeur à vos variables. Vous pouvez en découvrir d'autres dans cet article.
Chaque langage propose ses propres outils adaptés. N'hésitez donc pas à vous renseigner sur le langage de programmation que vous utilisez.
9. Annexe : GDB▲
9-A. Démarrage de GDB▲
Ouvrez un terminal dans votre répertoire de travail pour votre application.
Tapez
gdb
Maintenant GDB est lancé. Celui-ci propose un environnement de travail dans lequel vous pouvez taper des commandes. Votre premier réflexe est de taper :
help
Auquel le programme répond :
List of classes of commands:
aliases -- Aliases of other commands
breakpoints -- Making program stop at certain points
data -- Examining data
files -- Specifying and examining files
internals -- Maintenance commands
obscure -- Obscure features
running -- Running the program
stack -- Examining the stack
status -- Status inquiries
support -- Support facilities
tracepoints -- Tracing of program execution without stopping the program
user-defined -- User-defined commands
Type "help" followed by a class name for a list of commands in that class.
Type "help all" for the list of all commands.
Type "help" followed by command name for full documentation.
Type "apropos word" to search for commands related to "word".
Command name abbreviations are allowed if unambiguous.
Vous savez accéder à l'aide. Généralement, il suffit de taper « help nom_de_la_commande » pour avoir une description de celle-ci sur la manière de l'utiliser.
Pour arrêter sur GDB, il suffit de taper :
quit
Par la suite, j'indiquerai les commandes à taper dans le terminal avec le caractère « $ » (qui n'est pas à recopier). Pour les commandes pour GDB, j'utiliserai « (gdb) ».
Pour un premier essai, ce n'est pas mal, mais ce qui nous intéresse, c'est de charger un programme à déboguer dans GDB. Il existe deux méthodes, une directement en lançant GDB :
$ gdb mon_programme
L'autre dans l'environnement de GDB :
(gdb) exec-file mon_programme
Si tout se passe bien, GDB affiche :
Reading symbols from mon_programme...done.
Mais il se peut qu'il affiche :
Reading symbols from mon_programme...(no debugging symbols found)...done.
Indiquant que vous n'avez pas compilé les programmes avec les informations de débogage. Dans ce cas, il suffit de recompiler le programme avec l'option permettant l'inclusion de ces informations. Si vous utilisez un EDI, bien souvent, il suffit de compiler avec la cible « debug ». Sinon, avec GCC il faut préciser l'option « -g ».
9-B. Lancement du programme dans GDB▲
Nous savons charger notre programme dans GDB, donc nous pouvons commencer à travailler dessus.
La première chose à faire, c'est de lancer le programme :
(gdb) run
Si vous souhaitez passer des paramètres supplémentaires à votre programme, vous pouvez très bien le faire juste après la commande run :
(gdb) run 42 deuxieme_parametre
Pour un programme normalement qui ne crashe pas, GDB affiche :
Starting program: mon_programme
Hello World
Program exited normally.
Le « Hello World » étant ce que mon programme a affiché de lui-même. À ce point, le programme est arrêté, donc il ne vous sera pas possible de vérifier les valeurs des variables, ou de faire une exécution pas à pas.
Par contre, pour un programme qui produit une erreur de segmentation, GDB affichera des messages similaires à ceux-ci :
Program received signal SIGABRT, Aborted.
0x00007ffff7a8da75 in *__GI_raise (sig=<value optimised out>)
at ../nptl/sysdeps/unix/sysv/linux/raise.c:64
64 ../nptl/sysdeps/unix/sysv/linux/raise.c: Aucun fichier ou dossier de ce type.
in ../nptl/sysdeps/unix/sysv/linux/raise.c
On y apprend que mon programme a reçu un signal « SIGABRT » venant du système. En effet, mon programme effectuant une opération invalide se fait immédiatement éjecter du système. C'est ce même signal qui fait que le programme se ferme sans prévenir, mais l'avantage du débogueur, c'est que le signal est reçu par GDB et fait en sorte de laisser votre application en vie. En plus, il indique d'où provient l'erreur et, comme nous allons le voir par la suite, nous pouvons remonter à la ligne provoquant toute cette zizanie. Pour l'instant, la seule information lisible, c'est que le signal vient de raise.c, mais avant de soupçonner un bogue dans le noyau du système ou dans une quelconque bibliothèque, il serait judicieux de vérifier notre programme.
Pour information, le programme qui a effectué ce crash est le suivant :
#include <stdio.h>
#include <stdlib.h>
int
main
(
)
{
int
*
tab =
malloc
(
sizeof
(
int
)*
10
);
unsigned
int
i =
0
;
for
(
i =
0
; i <
11
; i++
)
{
tab[i] =
i;
}
free
(
tab);
return
0
;
}
L'interprétation des commandes par GDB est évoluée. Par exemple, si vous lancez une fois votre programme avec la commande « run arg1 arg2 », un appel à la commande « run » sera interprété comme rappel de la commande « run arg1 arg2 ». De même que pour les commandes longues, telles que « backtrace », il existe le raccourci « bt ».