Skip to content

Latest commit

 

History

History
629 lines (466 loc) · 22.8 KB

File metadata and controls

629 lines (466 loc) · 22.8 KB

🔝 Retour au Sommaire

29.1.2 — Inspection : print, display, watch

Section 29.1 : GDB — Commandes essentielles et breakpoints · Chapitre 29


Introduction

Naviguer dans un programme (section 29.1.1) vous permet de contrôler où et quand le programme s'arrête. Mais l'arrêter ne suffit pas — encore faut-il comprendre ce qui se passe à cet instant. C'est le rôle des commandes d'inspection.

L'inspection dans GDB répond à trois questions distinctes :

  • Quelle est la valeur de X en ce moment ?print
  • Comment X évolue-t-il au fil de l'exécution ?display
  • Quand est-ce que X change ?watch

Ces trois commandes correspondent à trois modes d'observation. print est une photographie instantanée. display est une caméra de surveillance qui se déclenche à chaque arrêt. watch est une alarme qui sonne quand une valeur est modifiée. Chacune a son usage, et un débogueur efficace sait jongler entre les trois.


print — Afficher une valeur à un instant donné

Usage fondamental

La commande print (abrégée p) évalue une expression et en affiche le résultat :

(gdb) break parse_config
(gdb) run database.conf
Breakpoint 1, parse_config (filename="database.conf") at config_parser.cpp:16

(gdb) next
(gdb) next
(gdb) next
22          while (std::getline(file, line)) {
(gdb) next
23              ++line_num;
(gdb) print line
$1 = "# database.conf"
(gdb) print line_num
$2 = 0
(gdb) next
(gdb) print line_num
$3 = 1

Chaque résultat est numéroté ($1, $2, $3). Ces numéros sont des variables d'historique : vous pouvez les réutiliser dans des expressions ultérieures.

(gdb) print $3 + 10
$4 = 11

Expressions complexes

print ne se limite pas aux variables simples. Il évalue des expressions C++ arbitraires :

(gdb) print line.size()
$5 = 16
(gdb) print line.substr(0, 5)
$6 = "# dat"
(gdb) print line[0]
$7 = 35 '#'
(gdb) print entries.size()
$8 = 0
(gdb) print filename.c_str()
$9 = 0x7fffffffe1c0 "database.conf"

Vous pouvez appeler des méthodes, accéder à des éléments par index, utiliser des opérateurs — toute expression que le compilateur accepterait à ce point du code est évaluable par GDB.

Quelques expressions particulièrement utiles au quotidien :

(gdb) print *ptr                    # Déréférencement d'un pointeur
(gdb) print ptr->member             # Accès via pointeur
(gdb) print static_cast<int>(ch)    # Cast explicite
(gdb) print (double)count / total   # Cast C-style (accepté par GDB)
(gdb) print sizeof(ConfigEntry)     # Taille d'un type
(gdb) print &variable               # Adresse mémoire d'une variable

Attention aux effets de bord

print évalue réellement l'expression, y compris les appels de fonctions. Cela signifie que print peut avoir des effets de bord :

(gdb) print entries.push_back({"test", "value", 99})

Cette commande modifie réellement le vecteur entries dans le programme en cours d'exécution. Le push_back s'exécute, l'élément est ajouté. C'est parfois utile (pour tester une hypothèse sans recompiler), mais c'est un piège si vous ne vous y attendez pas.

En règle générale : print avec des accesseurs const (size(), empty(), c_str(), at()) est sans risque. print avec des méthodes non-const (push_back, erase, clear) modifie l'état du programme.

Formats d'affichage

Par défaut, print affiche les valeurs dans le format naturel du type. Vous pouvez forcer un format différent avec un suffixe :

(gdb) print line_num
$10 = 3
(gdb) print/x line_num          # Hexadécimal
$11 = 0x3
(gdb) print/o line_num          # Octal
$12 = 03
(gdb) print/t line_num          # Binaire
$13 = 11
(gdb) print/c 65                # Caractère
$14 = 65 'A'
(gdb) print/f 1078523331        # Flottant (réinterprète les bits)
$15 = 3.14000010

Les formats les plus utilisés :

Suffixe Format Cas d'usage typique
/x Hexadécimal Adresses, flags, masques de bits
/t Binaire Manipulation de bits, bitfields
/c Caractère Valeurs ASCII, octets individuels
/d Décimal signé Forcer l'affichage signé
/u Décimal non signé Forcer l'affichage non signé
/f Flottant Réinterpréter un entier comme flottant
/a Adresse Afficher une adresse avec le symbole le plus proche
/s Chaîne Interpréter un pointeur comme chaîne C

Inspecter la mémoire brute avec x

La commande x (examine) affiche le contenu de la mémoire à une adresse donnée. C'est le niveau en dessous de print — vous ne voyez plus des variables C++, mais des octets en mémoire :

(gdb) print &line_num
$16 = (int *) 0x7fffffffddd4
(gdb) x/4xb 0x7fffffffddd4     # 4 octets en hexadécimal
0x7fffffffddd4: 0x03    0x00    0x00    0x00

La syntaxe est x/NFU <adresse>, où :

  • N = nombre d'unités à afficher
  • F = format (x hexa, d décimal, c char, s string, i instruction)
  • U = taille d'une unité (b byte, h halfword/2 octets, w word/4 octets, g giant/8 octets)

Exemples courants :

(gdb) x/16xb ptr              # 16 octets en hexa à partir de ptr
(gdb) x/4xw ptr               # 4 mots de 32 bits en hexa
(gdb) x/s str.c_str()         # Affiche comme chaîne C
(gdb) x/10i $pc               # 10 instructions assembleur à partir du PC

x est indispensable pour diagnostiquer des corruptions mémoire, inspecter des buffers binaires, ou comprendre l'agencement réel des données en mémoire. Pour le débogage logique quotidien, print suffit dans la grande majorité des cas.


info — Inspecter l'état global

La famille de commandes info donne accès à des vues d'ensemble plutôt qu'à des valeurs individuelles :

info locals — Variables locales de la fonction courante

(gdb) info locals
entries = std::vector of length 2, capacity 4 = {
  {key = "db_host", value = "localhost", line_number = 2},
  {key = "db_port", value = "5432", line_number = 3}
}
line = "db_name=production"  
line_num = 4  
pos = 7  
entry = {key = "", value = "", line_number = 0}  

C'est souvent la première commande à taper quand vous arrivez sur un breakpoint. Elle donne une vue complète de l'état de la fonction sans avoir à deviner quelles variables existent. Notez que entry apparaît avec des valeurs vides — elle a été déclarée mais pas encore remplie à ce point de l'exécution.

info args — Arguments de la fonction courante

(gdb) info args
filename = "database.conf"

Montre les paramètres passés à la fonction. Combiné avec backtrace, cela vous permet de reconstruire la chaîne complète des appels et de leurs arguments.

info registers — Registres du processeur

(gdb) info registers
rax            0x5                 5  
rbx            0x7fffffffe3a8     140737488348072  
rcx            0x0                 0  
rdx            0x555555556020     93824992247840  
rsp            0x7fffffffdda0     0x7fffffffdda0  
rbp            0x7fffffffde90     0x7fffffffde90  
rip            0x5555555553a2     0x5555555553a2 <parse_config+258>  
...

Rarement nécessaire en débogage C++ courant, mais essentiel quand vous descendez au niveau assembleur ou quand vous analysez un crash sans symboles de débogage.

Autres commandes info utiles

(gdb) info threads            # Liste des threads actifs
(gdb) info sharedlibrary      # Bibliothèques partagées chargées
(gdb) info signals            # Configuration de la gestion des signaux
(gdb) info frame              # Détails du stack frame courant

display — Surveillance automatique à chaque arrêt

Le problème que display résout

Avec print, vous voyez la valeur d'une variable à un instant donné. Mais en débogage, vous voulez souvent suivre l'évolution d'une variable au fil de l'exécution. Sans display, ça donne :

(gdb) next
(gdb) print line_num
$17 = 1
(gdb) next
(gdb) next
(gdb) print line_num
$18 = 1
(gdb) next
(gdb) next
(gdb) next
(gdb) print line_num          # Oublié de print pendant 3 steps...
$19 = 2

Vous devez retaper print manuellement après chaque commande de navigation. C'est fastidieux, et surtout vous risquez de rater un changement si vous oubliez un print.

La solution : display

La commande display enregistre une expression qui sera automatiquement affichée à chaque fois que le programme s'arrête — que ce soit sur un breakpoint, après un next, un step, ou un continue :

(gdb) display line_num
1: line_num = 1
(gdb) display line
2: line = "# database.conf"
(gdb) display entries.size()
3: entries.size() = 0
(gdb) next
23              ++line_num;
1: line_num = 1
2: line = "# database.conf"
3: entries.size() = 0
(gdb) next
22          while (std::getline(file, line)) {
1: line_num = 2        ← line_num a changé
2: line = "# database.conf"
3: entries.size() = 0
(gdb) next
23              ++line_num;
1: line_num = 2
2: line = "db_host=localhost"    ← line a changé (nouvelle itération)
3: entries.size() = 0

Chaque display reçoit un numéro (1, 2, 3). À chaque arrêt, GDB affiche automatiquement toutes les expressions enregistrées avec leurs valeurs actuelles. Vous voyez immédiatement quand une valeur change, sans rien retaper.

display avec format

Comme print, display accepte un format :

(gdb) display/x flags
4: /x flags = 0xff
(gdb) display/t mask
5: /t mask = 10110011

Gérer les displays

(gdb) info display            # Liste tous les displays actifs
Auto-display expressions now in effect:  
Num Enb Expression  
1:   y  line_num
2:   y  line
3:   y  entries.size()

(gdb) undisplay 2             # Supprime le display 2
(gdb) disable display 3       # Désactive le display 3 (sans le supprimer)
(gdb) enable display 3        # Réactive le display 3

Quand utiliser display vs print

La distinction est simple :

  • print quand vous voulez voir une valeur une fois, ponctuellement. Typiquement pour explorer un état inconnu : "quelle est la valeur de ce pointeur ?" ou "que contient ce vecteur ?"
  • display quand vous voulez suivre l'évolution d'une variable sur plusieurs pas d'exécution. Typiquement dans une boucle : vous display les variables de contrôle et les accumulateurs, puis vous avancez avec next en observant leur progression.

Le pattern classique pour déboguer une boucle :

(gdb) break config_parser.cpp:22       # Entrée de la boucle while
(gdb) run database.conf
(gdb) display line_num
(gdb) display line
(gdb) display entries.size()
(gdb) next                              # Puis Entrée, Entrée, Entrée...

Vous voyez les trois valeurs évoluer à chaque itération sans rien retaper. Quand le comportement diverge de vos attentes, vous savez exactement à quelle itération et pourquoi.


watch — Détection de changements de valeur

Le problème que watch résout

Certains bugs se manifestent par une valeur qui change de manière inattendue, et vous ne savez pas quel code en est responsable. Vous savez que count devrait valoir 5, mais à un moment il passe à 0. Avec des centaines de lignes de code qui pourraient potentiellement modifier count, poser des breakpoints manuels partout serait impraticable.

La solution : les watchpoints

Un watchpoint (posé avec la commande watch) surveille une expression et arrête le programme dès que sa valeur change :

(gdb) start database.conf
(gdb) watch line_num
Hardware watchpoint 2: line_num
(gdb) continue
Continuing.

Hardware watchpoint 2: line_num

Old value = 0  
New value = 1  
0x000055555555530a in parse_config (filename="database.conf")
    at config_parser.cpp:23
23              ++line_num;

GDB vous montre l'ancienne valeur, la nouvelle valeur, et la ligne exacte qui a provoqué le changement. Vous n'avez pas eu besoin de deviner où line_num était modifié — GDB l'a détecté pour vous.

Si vous continuez :

(gdb) continue
Hardware watchpoint 2: line_num

Old value = 1  
New value = 2  
0x000055555555530a in parse_config (filename="database.conf")
    at config_parser.cpp:23
23              ++line_num;

Chaque modification déclenche un arrêt. C'est la technique la plus efficace pour traquer les modifications inattendues.

Watchpoints hardware vs software

Quand GDB affiche Hardware watchpoint, il utilise les registres de débogage du processeur pour surveiller l'adresse mémoire. C'est efficace — le programme tourne à vitesse quasiment normale. Mais le nombre de watchpoints hardware est limité par l'architecture (typiquement 4 sur x86_64).

Si vous dépassez cette limite, GDB bascule automatiquement en mode software : il exécute le programme instruction par instruction et vérifie la valeur après chaque instruction. C'est fonctionnel mais extrêmement lent — un programme qui s'exécute normalement en une seconde peut prendre plusieurs minutes.

(gdb) watch variable_a
Hardware watchpoint 3: variable_a
(gdb) watch variable_b
Hardware watchpoint 4: variable_b
(gdb) watch variable_c
Hardware watchpoint 5: variable_c
(gdb) watch variable_d
Hardware watchpoint 6: variable_d
(gdb) watch variable_e             # 5e watchpoint → software
Watchpoint 7: variable_e           # Pas de "Hardware" → sera très lent

En pratique, limitez-vous à 1-3 watchpoints simultanés. Si vous avez besoin d'en surveiller plus, posez-les séquentiellement : surveillez les premiers, identifiez ceux qui changent comme prévu, supprimez-les, puis ajoutez les suivants.

rwatch et awatch — Variantes de surveillance

GDB propose deux variantes de watch :

(gdb) watch line_num           # S'arrête quand line_num est MODIFIÉ (écriture)
(gdb) rwatch line_num          # S'arrête quand line_num est LU (lecture)
(gdb) awatch line_num          # S'arrête quand line_num est LU OU MODIFIÉ

rwatch est utile quand vous cherchez quel code utilise une variable particulière — par exemple, pour comprendre tous les endroits qui lisent une configuration donnée. awatch combine les deux et vous donne une vision complète de tous les accès à une variable.

Ces variantes nécessitent un support hardware et ne sont pas disponibles sur toutes les plateformes. Sur x86_64 Linux, elles fonctionnent sans problème.

Watchpoints sur des expressions

Les watchpoints ne sont pas limités aux variables simples :

(gdb) watch entries.size()              # Quand le vecteur change de taille
(gdb) watch *(int*)0x7fffffffddd4      # Quand la mémoire à cette adresse change
(gdb) watch line_num > 5               # Quand l'expression devient vraie

Le watchpoint sur une expression comme entries.size() est évalué en mode software (GDB ne peut pas surveiller le résultat d'un appel de fonction via hardware). Il sera donc plus lent qu'un watchpoint sur une variable simple.

Portée et durée de vie des watchpoints

Un watchpoint sur une variable locale est automatiquement supprimé quand la variable sort de sa portée :

(gdb) break parse_config
(gdb) run database.conf
(gdb) watch line_num
Hardware watchpoint 2: line_num
(gdb) continue
[... plusieurs arrêts ...]
(gdb) continue
Watchpoint 2 deleted because the program has left the block
    in which its expression is valid.

Quand parse_config retourne, line_num n'existe plus — le watchpoint est supprimé. C'est un comportement sensé : surveiller une adresse mémoire sur la pile après que la fonction a retourné n'a pas de sens, car cette mémoire sera réutilisée par d'autres fonctions.

Pour surveiller une variable globale ou un membre de classe alloué sur le heap, le watchpoint persiste tant que la mémoire reste valide.


Inspecter des conteneurs STL

Grâce aux pretty-printers (configurés dans ~/.gdbinit, voir section 29.1), les conteneurs de la bibliothèque standard s'affichent de manière lisible :

(gdb) print entries
$20 = std::vector of length 5, capacity 8 = {
  {key = "db_host", value = "localhost", line_number = 2},
  {key = "db_port", value = "5432", line_number = 3},
  {key = "db_name", value = "production", line_number = 4},
  {key = "timeout", value = "30", line_number = 6},
  {key = "max_connections", value = "100", line_number = 7}
}

Accéder à un élément spécifique

(gdb) print entries[0]
$21 = {key = "db_host", value = "localhost", line_number = 2}
(gdb) print entries[0].key
$22 = "db_host"
(gdb) print entries[2].value.size()
$23 = 10

L'opérateur [] fonctionne comme en C++. Pour un std::map, la syntaxe d'accès par clé ne fonctionne pas directement dans GDB — vous parcourez les éléments via le pretty-printer.

Inspecter un std::map

(gdb) print my_map
$24 = std::map with 3 elements = {
  ["host"] = "localhost",
  ["port"] = "5432",
  ["name"] = "production"
}

Inspecter un std::optional

(gdb) print opt_value
$25 = std::optional<int> = {42}        # Contient une valeur
(gdb) print empty_opt
$26 = std::optional<int> [no contained value]   # Vide

Inspecter un smart pointer

(gdb) print unique
$27 = std::unique_ptr<Config> = {
  get() = 0x55555556b2a0
}
(gdb) print *unique
$28 = {host = "localhost", port = 5432, name = "production"}

Le déréférencement (*unique) affiche l'objet pointé — exactement comme avec un pointeur brut.

Quand les pretty-printers ne suffisent pas

Certains conteneurs ou types personnalisés ne disposent pas de pretty-printers. Dans ce cas, vous voyez la représentation interne brute. Pour un std::vector, sans pretty-printer :

(gdb) print entries._M_impl._M_start[0]
$29 = {key = "db_host", value = "localhost", line_number = 2}
(gdb) print entries._M_impl._M_finish - entries._M_impl._M_start
$30 = 5                    # Nombre d'éléments (équivalent de size())

Ce n'est pas élégant, mais c'est utile en dépannage — par exemple sur un serveur de production où les pretty-printers ne sont pas installés.


Modifier des valeurs en cours d'exécution

GDB permet de modifier l'état du programme pendant qu'il est en pause. C'est un outil puissant pour tester des hypothèses sans recompiler :

set variable — Modifier une variable

(gdb) print line_num
$31 = 3
(gdb) set variable line_num = 10
(gdb) print line_num
$32 = 10
(gdb) continue            # Le programme reprend avec line_num = 10

print avec affectation

Une syntaxe alternative, plus concise :

(gdb) print line_num = 10
$33 = 10

L'expression d'affectation est évaluée (la variable est modifiée) et la nouvelle valeur est affichée.

Cas d'usage typique

Vous soupçonnez qu'un bug survient quand timeout dépasse une certaine valeur :

(gdb) break config_parser.cpp:34
(gdb) run database.conf
(gdb) set variable entry.value = "99999"    # Force une valeur extrême
(gdb) continue                               # Observe le comportement

Plutôt que de modifier le fichier de configuration, recompiler et relancer, vous injectez directement la valeur dans le programme en cours d'exécution. Si le bug se manifeste, vous avez votre confirmation. Sinon, vous testez une autre hypothèse — le tout en quelques secondes.


call — Appeler une fonction manuellement

La commande call exécute une fonction du programme depuis le prompt GDB :

(gdb) call find_value(entries, "db_host")
$34 = "localhost"
(gdb) call printf("Test: %d\n", line_num)
Test: 3
$35 = 9

La différence avec print <expression contenant un appel> est subtile : call ne suppose rien sur le type de retour et est légèrement plus explicite dans son intention. En pratique, les deux sont interchangeables pour appeler des fonctions.

call est particulièrement utile pour déclencher des fonctions de diagnostic que vous avez écrites dans votre programme :

// Fonction de debug compilée dans le programme
void dump_entries(const std::vector<ConfigEntry>& entries) {
    for (const auto& e : entries) {
        std::cerr << "[" << e.line_number << "] " 
                  << e.key << "=" << e.value << "\n";
    }
}
(gdb) call dump_entries(entries)
[2] db_host=localhost
[3] db_port=5432
[4] db_name=production

Écrire de telles fonctions de dump dans votre code (même derrière un #ifdef DEBUG) est une pratique qui se combine naturellement avec GDB. Elles vous donnent des vues formatées et métier sur vos structures de données, bien au-delà de ce que les pretty-printers génériques proposent.


Récapitulatif des commandes

Commande Abrégé Effet
print <expr> p Affiche la valeur d'une expression (une fois)
print/<fmt> <expr> p/<fmt> Affiche avec un format spécifique (/x, /t, /c…)
x/NFU <addr> Examine la mémoire brute à une adresse
info locals Affiche toutes les variables locales
info args Affiche les arguments de la fonction
info registers i r Affiche les registres CPU
display <expr> Affiche automatiquement l'expression à chaque arrêt
display/<fmt> <expr> Display avec format spécifique
undisplay <n> Supprime un display
info display Liste les displays actifs
watch <expr> Arrête le programme quand l'expression change (écriture)
rwatch <expr> Arrête quand l'expression est lue
awatch <expr> Arrête quand l'expression est lue ou écrite
set variable <var> = <val> set var Modifie une variable en cours d'exécution
call <func(args)> Appelle une fonction du programme

Stratégie d'inspection : quand utiliser quoi

"J'arrive sur un breakpoint et je veux comprendre l'état courant."info locals pour la vue d'ensemble, puis print sur les variables spécifiques qui vous intéressent.

"Je débogue une boucle et je veux suivre la progression."display sur les variables de contrôle et les accumulateurs, puis next répété (touche Entrée).

"Une variable est corrompue et je ne sais pas où ça se passe."watch sur la variable. GDB s'arrêtera sur la ligne exacte qui la modifie.

"Je veux tester une hypothèse rapidement."set variable pour injecter une valeur, continue pour observer le résultat.

"Je veux comprendre l'agencement mémoire ou diagnostiquer une corruption."x pour examiner la mémoire brute octet par octet autour de la zone suspecte.

"J'ai un doute sur le type réel d'un objet (polymorphisme)."print avec set print object on (configuré dans ~/.gdbinit) pour voir le type dynamique, puis print *ptr pour inspecter l'objet complet.


À retenir : print est votre loupe — vous l'utilisez pour examiner un point précis. display est votre tableau de bord — il vous montre en permanence les indicateurs clés. watch est votre alarme — il vous réveille quand quelque chose change sans que vous vous y attendiez. Un débogueur efficace utilise les trois dans la même session.

⏭️ Breakpoints conditionnels