Chasse aux bugs !

Malgré tout le soin qu’on peut apporter à la conception, malgré tous les warnings qu’on active pendant la compilation, même avec toutes les bonnes pratiques du monde, nous faisons des erreurs et nous créons des bugs. Il nous faut donc des outils supplémentaires pour les traquer et les éliminer.

Ce chapitre va nous faire découvrir l’usage des débogueurs.

Le principe

À l’exécution d’un programme, il se passe beaucoup de choses et très vite. Une fonction en appelle une autre, une variable est modifiée, une exception lancée, etc. Il est donc difficile, d’un simple coup d’œil, de savoir où est survenu le problème. Le principe du débogueur est donc de dérouler l’exécution du programme pas-à-pas, ce qui permet, à chaque instant, de voir où on en est et quel est l’état de nos variables et de la mémoire.

Pour ordonner au débogueur de s’arrêter à un endroit précis dans le code, on pose ce qu’on appelle un point d’arrêt, ou, en anglais, breakpoint. Chaque fois que le débogueur passe par un point d’arrêt, le programme débogué se stoppe jusqu’à nouvel ordre et toutes les informations courantes sont affichées, comme la liste des fonctions appelées jusque-là (la stack trace en anglais), la valeur de toutes les variables de la portée courante, etc.

Pour les débogueurs les plus avancés, il est également possible de modifier des données du programme, de poursuivre l’exécution à un autre endroit du code pour sauter un crash ou une exception, observer le code assembleur généré et même de déboguer des programmes à distance, par Internet notamment.

Pour faire tout ce travail, le débogueur se repose sur les symboles de débogage, qui sont présents quand on compile en debug mais souvent éliminés quand on passe en release. Donc, afin de pouvoir utiliser cet outil puissant, veillez à passer en mode Debug si vous utilisez Visual Studio, ou bien à compiler votre programme avec l’option -g, pour charger ces fameux symboles.

> g++ -std=c++17 -g test.cpp -o test.out
> clang++ -std=c++17 -g test.cpp -o test.out

Un code d'exemple

Afin d’illustrer ce chapitre, nous allons utiliser le morceau de code suivant, qui est certes basique, mais qui va nous permettre de découvrir le principe du débuggage en douceur.

#include <iostream>
#include <vector>

void fonction()
{
    std::vector<int> tableau {};

    tableau.push_back(10);
    tableau.push_back(20);

    int & a { tableau.front() };
    int & b { tableau.back() };

    std::cout << "Front : " << tableau.front() << std::endl;
    std::cout << "Back : " << tableau.back() << std::endl;

    b = 5;

    std::cout << "Front : " << tableau.front() << std::endl;
    std::cout << "Back : " << tableau.back() << std::endl;
}

int main()
{
    fonction();
    return 0;
}

Visual Studio

Le débogueur inclus avec Visual Studio se lance très simplement en faisant F5, ou en cliquant sur l’option Débogueur Windows local. Mais si vous lancez le code tel quel en debug, la console va s’ouvrir et se refermer aussitôt. Il faut donc commencer par poser des points d’arrêt. Pour ça, c’est très simple, on clique sur la colonne à gauche de celle indiquant les numéros de ligne. Comme une image est plus claire, voici un point d’arrêt posé.

Visual Studio 2019 - Debug 1.png
Nous avons posé un point d’arrêt à la ligne 8.

Interface

Une fois que le programme est lancé, l’aspect de Visual Studio change un peu. Examinons ce qu’il y a de nouveau, en commençant par la petite flèche jaune, actuellement sur la ligne 8, qui indique l’instruction à laquelle on se trouve.

On se trouve actuellement à la ligne 8.
Le curseur de debug se trouve actuellement à la ligne 8.

En bas à gauche, c’est la fenêtre des variables, grâce à laquelle on peut voir, à chaque étape, l’état des variables du programme.

Visual Studio 2019 - Debug 2.png
Fenêtre des variables avec l’affichage de la variable tableau.

Elle est divisée en trois onglets.

  • Automatique correspond à toutes les variables existantes jusqu’à la ligne où l’on se trouve. Celles situées après ne sont pas encore connues donc pas encore affichées. Pour l’instant, il n’y a que tableau.
  • Variables locales correspond à toutes les variables de la portée actuelle, existant déjà en mémoire ou non. Dans notre exemple, on retrouve donc tableau, ainsi que a et b, même si celles-ci n’ont pas encore de valeur mais juste une ligne « Impossible de lire la mémoire. »
  • Espion 1 permet de garder un œil sur une variable et d’être au courant de toutes ses modifications. On peut, par exemple, garder une trace de la taille du tableau en créant un espion tableau.size().
Exemple d'espion. Quand le tableau sera agrandi ou rétréci, la valeur de l'espion sera automatiquement modifiée.
Exemple d'espion. Quand le tableau sera agrandi ou rétréci, la valeur de l'espion sera automatiquement modifiée.

En bas à droite, une autre fenêtre permet de voir, entre autres, la pile des appels, c’est-à-dire quelle fonction appelle quelle autre fonction. Ici, il n’y en a que deux, donc ce n’est pas étonnant de voir main appeler fonction. Mais quand le code se complexifie, il est très utile de voir par quelle fonction on passe et laquelle déclenche le bug.

Fenêtre de la pile des appels.
Fenêtre de la pile des appels.

Pas-à-pas

Que diriez-vous maintenant de passer à l’instruction suivante ? Pour cela, on dispose de plusieurs choix possibles, selon ce qu’on décide de faire.

Visual Studio 2019 - Debug 3.png
Interface avec les différentes actions possibles.
  • Le bouton Visual Studio Bouton Continuer.png permet d’exécuter le code jusqu’au prochain point d’arrêt. Comme ici il n’y en a qu’un seul, cela signifie jusqu’à la fin du programme.
  • Le carré rouge Visual Studio Bouton Stop.png arrête le débogage. Avec le clavier, on fait Maj+F5.
  • La flèche cerclée Visual Studio Bouton Redémarrer.png redémarre le débogage. Avec le clavier, on fait Ctrl+Maj+F5.
  • Le pas-à-pas principal Visual Studio Bouton PaP Principal.png permet de passer à l’instruction suivante, mais sans aller plus loin dans la pile d’appel. Cela signifie que si l’instruction à exécuter est un appel de fonction, le pas-à-pas principal n’ira pas voir dedans. Avec le clavier, c’est F10.
  • Le pas-à-pas détaillé Visual Studio Bouton PaP Détaillé.png permet d’examiner en détails les instructions. Ainsi, si vous passez sur une fonction, le pas-à-pas détaillé ira dans cette fonction examiner ses instructions. Avec le clavier, c’est F11.

En cliquant sur le pas-à-pas principal, nous voyons la flèche jaune se déplacer d’une ligne et passer à l’instruction suivante. Dans la fenêtre des variables locales, tableau s’est mis à jour et sa taille est passée à 1, tout comme l’espion tableau.size(). On a donc bien exécuté une instruction push_back sur le tableau.

Variable espionne bien mise à jour après exécution de la première instruction, qui ajoute un élément au tableau.
Variable espionne bien mise à jour après exécution de la première instruction, qui ajoute un élément au tableau.

L’image ci-dessous vous montre l’état des variables quand je fais un pas-à-pas principal jusqu’à la fin de la fonction. On voit bien que a et b existent maintenant. On en note également deux autres, dues à la manipulation du flux de sortie, qu’on peut ignorer parce qu’elles ne nous intéressent pas.

Variables après le pas-à-pas principal jusqu'à la fin de la fonction.
Variables après le pas-à-pas principal jusqu'à la fin de la fonction.

Point d’arrêt conditionnel

Faire un pas-à-pas classique, c’est bien, mais quand on doit dérouler une boucle de 100 éléments et qu’on veut la valeur à la 95ème itération, c’est embêtant. Sauf qu’on dispose d’un moyen pratique qui consiste à dire au débogueur de s’arrêter quand une condition particulière est vérifiée.

#include <iostream>
#include <vector>

void fonction()
{
    for (int i { 0 }; i < 100; ++i)
    {
        std::cout << i * 2 << std::endl;
    }
}

int main()
{
    fonction();
    return 0;
}

Prenons un code simple comme celui ci-dessus et posons un point d’arrêt sur la ligne 7. En faisant un clic-droit sur le point d’arrêt, puis cliquez sur Conditions…. Vous allez voir un cadre apparaitre, dans lequel on peut décider d’une condition à respecter pour que le débogueur s’arrête sur le point d’arrêt désigné.

Visual Studio 2019 - Debug 4.png
Point d’arrêt conditionnel, uniquement quand i vaudra 95.

Si on lance le débogage, on voit bien que le code s’exécute jusqu’au moment où i vaut 95. Là, le programme est stoppé et le déboguer attend nos instructions. Pour preuve, regardez les variables locales.

Notre variable locale i vaut bien 95.
Notre variable locale i vaut bien 95.

Aller plus loin

Tout présenter m’est impossible, surtout pour un outil qui mériterait un cours à lui tout seul. Je vous encourage à jeter un regard sur les tutoriels de la documentation officielle Microsoft. Apprendre à bien maîtriser le débogueur de Visual Studio ne vous servira pas qu’en C++, mais également en C# ou en Python, si vous continuez à utiliser cet environnement pour développer avec ces langages.

Qt Creator

Le principe du débogage est assez semblable avec Qt Creator. Cet environnement se base sur l’outil libre gdb, qui s’utilise normalement en ligne de commande.

Si vous lancez le code tel quel en debug, la console va s’ouvrir et se refermer aussitôt. Il faut donc commencer par poser des points d’arrêt, ce qui se fait en posant un point d’arrêt en cliquant sur la colonne à gauche de celle des numéros de lignes. On lance ensuite le débogage avec F5.

Interface

Commençons par examiner l’interface de débogage, pour le même code que ci-dessus. On trouve une petite flèche jaune qui indique la ligne sur laquelle on se trouve.

Vue de Qt Creator pendant un débogage.
Vue de Qt Creator pendant un débogage.

En haut à droite, on a d’abord la liste des variables locales et les paramètres. On retrouve bien tableau qui est vide ; a et b contiennent des valeurs aléatoires, car elles sont encore non-initialisées.

Les variables locales.
Les variables locales.

Juste en dessous se trouve la fenêtre des expressions. On peut ici écrire n’importe quelle expression C++ valide, ce qui permet de toujours garder un œil sur la valeur d’une variable en particulier, comme ici la taille du tableau.

Une expression évaluant la taille du tableau, automatiquement mise à jour quand le tableau est modifié.
Une expression évaluant la taille du tableau, automatiquement mise à jour quand le tableau est modifié.

Enfin, plus bas, en dessous du code, on trouve la pile des appels, c’est-à-dire quelle fonction appelle quelle autre fonction. Également, on trouve la liste de tous les points d’arrêt, ce qui est plutôt utile quand on en a beaucoup.

Liste des appels et des points d'arrêt.
Liste des appels et des points d'arrêt.

Pas-à-pas

Maintenant, il serait de bon ton de continuer le débogage de notre application. Faisons donc du pas-à-pas.

Les différentes options de pas-à-pas.
Les différentes options de pas-à-pas.
  • Le bouton Continuer Qt Creator Bouton Continuer.png permet de continuer l’exécution jusqu’au prochain point d’arrêt. Avec le clavier, on fait F5.
  • Le carré rouge Qt Creator Bouton Arrêt.png arrête le débogage.
  • Le bouton Qt Creator Bouton PaP Principal.png permet le pas-à-pas principal, c’est à dire se rendre à la prochaine instruction sans explorer la pile d’appel. Cela signifie que si l’instruction à exécuter est un appel de fonction, le pas-à-pas principal n’ira pas voir dedans. Avec le clavier, c’est F10.
  • Le bouton Qt Creator Bouton PaP Détaillé.png permet le pas-à-pas détaillé, c’est à dire se rendre à la prochaine instruction en explorant la pile d’appel. Ainsi, si vous passez sur une fonction, le pas-à-pas détaillé ira dans cette fonction examiner ses instructions. Avec le clavier, c’est F11.
  • Le bouton Qt Creator Bouton Redémarrage.png redémarre la session de débogage.

En cliquant sur le pas-à-pas principal, on voit bien la flèche jaune se déplacer à l’instruction suivante, ajoutant ainsi une valeur au tableau, ce qui met également l’expression évaluée tableau.size() à jour.

L'expression a bien été mise à jour.
L'expression a bien été mise à jour.

Si on poursuit le pas-à-pas principal jusqu’à la fin de la fonction, on voit que toutes nos variables sont mises à jour et ont maintenant des valeurs cohérentes.

Les valeurs quand le pas-à-pas atteint la fin de la fonction.
Les valeurs quand le pas-à-pas atteint la fin de la fonction.

Point d’arrêt conditionnel

Avec Qt Creator, on peut tout à fait poser une condition sur un point d’arrêt, si l’on veut qu’il s’exécute à une condition précise. Dans le cas d’une boucle, on peut ainsi s’épargner de faire du pas-à-pas et aller directement à une valeur qui nous intéresse. Reprenons le code de la partie précédente et faisons le test.

#include <iostream>
#include <vector>

void fonction()
{
    for (int i { 0 }; i < 100; ++i)
    {
        std::cout << i * 2 << std::endl;
    }
}

int main()
{
    fonction();
    return 0;
}

Pour poser une condition, il suffit de faire un clic-droit sur un point d’arrêt et de choisir Edit breakpoint…. Une fenêtre apparait. Dans la section Advanced, la ligne Condition permet de définir la condition à respecter pour que le débogueur s’arrête sur ce point d’arrêt. On peut ainsi demander à attendre que i vaille 95.

Fenêtre de paramétrage d'un point d'arrêt.
Fenêtre de paramétrage d'un point d'arrêt.

Lancez le débogage et vous verrez que le programme s’exécutera sans interruption jusqu’à ce que i atteigne la valeur 95, auquel cas le débogueur attendra nos instructions pour continuer.

Aller plus loin

Comme il m’est impossible d’être exhaustif et que vous commencez à être des habitués de la documentation, je vous renvoie vers la documentation Qt officielle. Celle-ci est en anglais mais vous permettra d’aller plus loin dans votre apprentissage.

En ligne de commande avec gdb

Nous avons dit, dans la partie précédente, que Qt Creator se base sur gdb pour offrir une expérience de débogage. Ce dernier s’utilise avec de nombreuses interfaces graphiques différentes, mais aussi en ligne de commande. Alors rouvrons notre shell et plongeons-nous à la découverte de gdb.

Pour lancer le débogage d’un programme, il suffit de taper gdb suivi du nom de ce programme. Celui-ci doit avoir été compilé avec les symboles de débogage. L’option -quiet évite l’affichage de toutes les informations légales et de licence.

> gdb -quiet programme.out
Reading symbols from programme.out...done.
(gdb)

Poser un point d’arrêt

Poser un point d’arrêt avec gdb est faisable de trois façons différentes, selon ce qu’on veut faire.

  • On indique un endroit précis, comme un numéro de ligne, une fonction, un fichier, etc. C’est un point d’arrêt classique (break point).
  • Lors de la modification ou de la lecture d’une variable. C’est ce qu’on appelle un point de surveillance (watch point).
  • Lors d’un certain évènement particulier, comme le lancement ou le rattrapage d’une exception, entre autres. C’est ce qu’on appelle un point d’interception (catch point).

Dans notre cas, contentons-nous de poser un point d’arrêt à une ligne précise, la numéro 8 du code suivant. Cela se fait en utilisant la commande break et en indiquant le numéro de la ligne voulue.

(gdb) break 8
Breakpoint 1 at 0xd0e: file main.cpp, line 8.

Maintenant, lançons l’exécution du programme avec la commande run.

(gdb) run
Starting program: /home/informaticienzero/Documents/programme.out

Breakpoint 1, fonction () at main.cpp:8
8           tableau.push_back(10);

Le débogueur s’est bien arrêté à la ligne 8, comme demandé. Il nous affiche en prime la fonction dans laquelle on se trouve, ainsi que le fichier, puis la ligne de code qui se trouve à la position demandée.

Dans le cas où l’on souhaite poser un point d’arrêt dans un autre fichier, il suffit de préciser celui-ci avant le numéro de la ligne, séparés par deux-point :.

(gdb) break autre.cpp:5
Breakpoint 2 at 0xd0e: file autre.cpp, line 5.

Supprimer des points d’arrêt

Quand un point d’arrêt n’est plus utile, on peut le supprimer. On a pour cela plusieurs possibilités. Soit on se souvient de la position du point d’arrêt, auquel cas on le supprime avec la commande clear position, soit on se souvient de son numéro et on peut le supprimer avec delete numéro.

Et comme se souvenir du numéro est compliqué, on peut tout à fait retrouver la liste des points d’arrêt avec la commande info breakpoints.

(gdb) info breakpoints
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000000d0e in test() at autre.cpp:5
2       breakpoint     keep y   0x0000000000000d9e in fonction() at main.cpp:6

Désactiver des points d’arrêt

Parfois, on ne veut pas s’arrêter à un point d’arrêt donné, mais on ne veut pas non plus le supprimer car on en a besoin plus tard. Il suffit de le désactiver avec la commande disable numéro. Une fois qu’on en aura besoin, il suffira de le réactiver avec enable numéro.

Afficher l’état d’une variable

Si on regardait l’état de notre tableau ? Celui-ci est bien évidemment vide, mais demandons quand même à gdb de nous l’afficher. Il faut pour cela utiliser la commande print avec le nom de la variable voulue. Celle-ci doit exister à ce moment-là.

(gdb) print tableau
$1 = std::vector of length 0, capacity 0
(gdb) print a
$2 = (int &) @0x7ffffffede90: 0
(gdb) print x
No symbol "x" in current context.

Notre référence a existe, mais n’a pas encore de valeur, d’où le nombre aléatoire que vous verrez. Quant à x, le symbole n’existe pas car il n’y a aucune variable pourtant ce nom.

Pas-à-pas

Bon, tout ça c’est bien, mais comment on passe à la suite ? Comment exécuter les instructions qui suivent quand on est à un point d’arrêt ? Nous avons deux choix, permettant de faire du pas-à-pas détaillé, en explorant la pile des appels, avec step, ou bien de passer d’instruction en instruction sans explorer le code des fonctions appelées avec next.

La sortie suivante montre que, quand je décide de faire step sur le point d’arrêt, gdb commence à me montrer en détail ce qui se passe et explore la fonction std::vector::push_back. Si je me contente de faire next, alors je passe simplement à la ligne d’après, soit le deuxième push_back.

(gdb) step
std::vector<int, std::allocator<int> >::push_back (this=0x7ffffffede90, __x=@0x7ffffffede7c: 10) at /usr/include/c++/7/bits/stl_vector.h:954
954           { emplace_back(std::move(__x)); }
(gdb) next
fonction () at main.cpp:9
9           tableau.push_back(20);

Chacune de ces commandes peut recevoir un entier en paramètre, indiquant de combien d’étapes on souhaite avancer. Ainsi, si je suis à la ligne 9 et que je fais next 3, j’avancerai de trois instructions. On saute les lignes 10 et 13 qui sont vides, on saute deux instructions aux lignes 11 et 12 et on arrive donc à la troisième après la ligne 9, qui se trouve ligne 14.

fonction () at main.cpp:9
9           tableau.push_back(20);
(gdb) next 3
14          std::cout << "Front : " << tableau.front() << std::endl;

Conditions

Dans le cas où l’on voudrait s’arrêter sur un point d’arrêt uniquement lors d’une certaine condition, c’est possible en rajoutant cette condition après break. Si je veux, dans le code suivant, m’arrêter quand i vaut 95, je n’ai qu’à taper la commande ci-après.

#include <iostream>
#include <vector>

void fonction()
{
    for (int i { 0 }; i < 100; ++i)
    {
        std::cout << i * 2 << std::endl;
    }
}

int main()
{
    fonction();
    return 0;
}
> gdb -quiet programme.out
Reading symbols from programme.out...done.
(gdb) break 8 if i == 95
Breakpoint 1 at 0x89f: file main.cpp, line 8.
(gdb) run
Starting program: /home/informaticienzero/Documents/programme.out
0
2
// Des nombres.
186
188

Breakpoint 1, fonction () at main.cpp:8
8               std::cout << i * 2 << std::endl;

Aller plus loin

Ce programme est vraiment complet et doté de nombreuses possibilités. Je vous encourage à en apprendre plus de votre côté, que vous vouliez l’utiliser en ligne de commande ou à travers l’interface graphique de votre choix. Vous pouvez commencer par ce tutoriel en français, qui vous montrera d’autres commandes possibles.


En résumé

  • Le débogueur est un précieux allié qui peut nous aider à traquer les bugs vicieux cachés dans nos codes.
  • En posant des points d’arrêt, on demande au débogueur d’arrêter l’exécution du programme à une ligne précise.
  • Certains points d’arrêt ne s’activeront que si une condition que nous avons définie devient vraie.
  • On peut afficher la pile d’appel, pour voir la liste des fonctions appelées avant d’arriver à une ligne quelconque.
  • On peut voir, à tout moment, l’état des variables et même définir nos propres expressions, ou espions, qui seront automatiquement mis à jour à la moindre modification.
  • Il ne faut pas oublier de compiler le programmer avec ses symboles de débogage.