Débogger un segfault : la méthode complète, du core dumped à la ligne fautive
Un segfault, c'est un SIGSEGV : le noyau tue ton processus parce qu'il a lu ou écrit dans une page mémoire qui ne lui appartient pas. Le message Segmentation fault (core dumped) te donne le fait, jamais l'endroit. Débugger un segfault, c'est donc un travail de reconstitution : reproduire le crash, recompiler avec les bonnes options, puis remonter la pile jusqu'à la ligne fautive.
Cinq gestes couvrent 9 cas sur 10 :
Recompiler avec
-g -O0- jamais-O2pendant une session de debug.Lancer gdb :
run,bt full,frame N,info locals.Passer sous valgrind (
--track-origins=yes) quand le crash est intermittent.Instrumenter avec
-fsanitize=address,undefinedpour voir la corruption au moment où elle se produit.Ouvrir le core dump avec
coredumpctl gdbsi le crash est déjà passé.
Si tu n'as que ton terminal et une erreur de segmentation sous les yeux, la séquence ci-dessous est copiable telle quelle.
La méthode en 5 minutes
# 1. Build de debug - -O2 ment sur l'emplacement réel du bug
g++ -std=c++20 -g -O0 -fno-omit-frame-pointer app.cpp -o app
# 2. Crash reproductible : gdb direct
gdb -q ./app
(gdb) run
(gdb) bt full # gdb backtrace complet + variables locales
(gdb) frame 2 # remonte sur TA frame
(gdb) info locals
(gdb) p *ptr
# 3. Version non interactive (parfaite en CI)
gdb -q -batch -ex run -ex 'bt full' -ex quit ./app
# 4. Intermittent : sanitizers d'abord, valgrind ensuite
g++ -std=c++20 -g -O1 -fsanitize=address,undefined app.cpp -o app-asan && ./app-asan
valgrind --track-origins=yes --leak-check=full ./app
# 5. Crash déjà survenu, aucune reproduction possible : core dump
coredumpctl list
coredumpctl gdb app
(gdb) bt full
La règle à retenir : sanitizers en dev, gdb pour la pile, valgrind pour l'origine. Ces outils se complètent, ils ne s'opposent pas.
Arbre de décision : pars du symptôme, pas de l'outil
Crash à chaque lancement → déterministe.
gdb ./app→run→bt full. Tu as la ligne en moins de deux minutes, ne cherche pas plus loin.Crash aléatoire, seulement sous charge → suspecte un use-after-free.
-fsanitize=addresspuisvalgrind --track-origins=yes.Crash uniquement en Release (
-O2/-O3) → comportement indéfini que l'optimiseur réordonne. Teste aussi-Og, puis relance ASan + UBSan sur la configuration Release exacte.Crash uniquement en multi-thread → ThreadSanitizer (
-fsanitize=thread) avant tout autre outil, puisgdbavecthread apply all bt full.Crash dans du Qt → vérifie l'ownership des
QObject, les connexions cross-thread et lesdeleteLater. Section dédiée plus bas.Crash en production, pas de reproduction → active les core dumps (
coredumpctl) ou enregistre unrr recordpour rejouer à l'identique.
Le parcours pas-à-pas
1. Reproduire de façon déterministe
Règle zéro : sans reproduction fiable, chaque outil te coûte une heure. Note le scénario exact (entrée, threads, ordre d'initialisation, version de la lib externe), pas « ça plante parfois ». Un segfault qui apparaît « une fois sur quarante » est presque toujours lié à une condition de course ou à un pointeur qui survit à son objet.
Si tu ne peux pas reproduire en local, enregistre une session avec rr record ./app : le replay est déterministe et te donne un bt stable, même sur une race.
2. Compiler en -g -O0 (et pourquoi l'optimisation te ment)
g++ -std=c++20 -g -O0 -fno-omit-frame-pointer -Wall -Wextra app.cpp -o app
-g émet les symboles de debug, sans quoi gdb t'affiche des adresses nues (GCC, Debugging Options). -O0 désactive les optimisations : les variables restent lisibles et les frames ne sont pas fusionnées. -g fonctionne avec -O, mais à partir de -O2 l'inlining fait disparaître des frames entières de la pile - tu débogues alors du code réordonné qui n'existe plus tel quel.
Pour le cycle édition-compilation-debug quotidien, GCC recommande -Og : il conserve assez d'information de debug tout en compilant plus vite que la réalité ne le justifie en -O0. Le vrai binaire fautif, lui, tu le reproduis avec ses options de production et les sanitizers.
3. gdb : obtenir un gdb backtrace exploitable
(gdb) set pagination off
(gdb) set print pretty on
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x00005555555551f4 in Config::load (this=0x0, path="conf.yml") at config.cpp:42
42 std::string host = this->host_; // this == 0x0
(gdb) bt full
#0 Config::load (this=0x0) at config.cpp:42
#1 0x00005555555553a1 in main () at main.cpp:12
Trois réflexes de lecture (manuel GDB officiel) :
bt fullplutôt quebt: tu veux les arguments et les locales, pas seulement les noms de fonctions.frame Npuisinfo locals,info args,p *ptr: remonte au premier frame qui vient de ton code, pas delibcou delibstdc++.info registersetx/i $pcquand la pile est corrompue : tu inspectes l'instruction fautive à la main.
Pour du C++ avec exceptions, catch throw et catch catch t'arrêtent sur le throw d'origine au lieu du terminate(). Et sur un SIGABRT après un std::terminate, bt sur le dernier frame utile suffit souvent.
4. valgrind : tracer l'origine de la corruption
gdb te dit où ça casse ; valgrind te dit d'où vient la mémoire corrompue. C'est la différence qui compte quand un free() plante dans libc alors que le bug est dans ton code, trois fonctions plus tôt.
valgrind --tool=memcheck \
--track-origins=yes \
--leak-check=full \
--num-callers=30 \
./app
La sortie type à lire de bas en haut :
Invalid write of size 8
at 0x401234: Parser::parse (parser.cpp:88)
Address 0x5a1b2c0 is 0 bytes after a block of size 64 alloc'd
at 0x483B7F3: operator new[](unsigned long)
by 0x4011A0: Parser::parse (parser.cpp:81)
« 0 bytes after a block » = écriture juste après la fin de l'allocation. Tu remontes à la ligne 81 et tu as ton off-by-one. Valgrind ralentit le programme d'un facteur 20 à 50 et ne détecte que ce qui s'exécute réellement : fais-le tourner sur ta suite de tests, pas sur un cas isolé (Memcheck manual).
5. AddressSanitizer et UBSan : le filet en dev
ASan est un détecteur de mémoire compilé dans le binaire. Il attrape les débordements heap/stack/global, le use-after-free, le double free et (en mode runtime) le use-after-return.
clang++ -std=c++20 -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined app.cpp -o app-asan
ASAN_OPTIONS=detect_stack_use_after_return=1 ./app-asan
Le -O1 est volontaire : la documentation Clang d'AddressSanitizer recommande -O1 -g -fno-omit-frame-pointer pour de meilleures perfs et des traces plus lisibles. La sortie te donne la pile complète du point d'accès et celle de l'allocation :
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 0x... in Widget::refresh() widget.cpp:57
freed by thread T0 here:
#0 0x... in operator delete(void*)
#1 0x... in Controller::onTimeout() controller.cpp:31
Ajoute -fsanitize=undefined -fno-sanitize-recover=all pour qu'UBSan arrête le programme sur la première UB au lieu de se contenter d'un avertissement. GCC embarque ces sanitizers depuis la 4.8, Clang depuis la 3.1 : aucun prétexte pour ne pas les activer en CI.
6. Analyser un core dump
Quand le crash a eu lieu hier, en prod, et que le processus est déjà mort, le core dump est ton seul témoin.
ulimit -c unlimited # dans le shell courant
cat /proc/sys/kernel/core_pattern # |/usr/lib/systemd/systemd-coredump ...
coredumpctl list
coredumpctl info app
coredumpctl gdb app # charge le core dans gdb, directement
coredumpctl dump app -o /tmp/app.core # export pour analyse hors machine
Sur un système systemd, systemd-coredump capture les cores automatiquement et coredumpctl les expose via les sous-commandes list, info, dump et debug (coredumpctl(1)). Attention : le core n'est exploitable qu'avec le binaire exact (même build, mêmes symboles). Archive tes binaires de release avec leurs symboles, sinon tu analyses des adresses orphelines.
Segfaults multi-thread : là où les outils classiques échouent
Un crash dans un contexte multithread est rarement là où gdb pointe. Tu tombes dans malloc ou dans memcpy, pas dans la ligne fautive. Deux outils changent la donne :
clang++ -std=c++20 -g -O1 -fsanitize=thread app.cpp -o app-tsan && ./app-tsan
gdb ./app -ex 'thread apply all bt full' -ex quit
ThreadSanitizer (
-fsanitize=thread) détecte les data races à l'exécution et te donne les deux piles concurrentes, avec les lignes exactes des deux accès.thread apply all bt fulldans gdb : tu vois ce que font tous les threads au moment du crash, pas seulement celui qui a reçu le signal.Helgrind (
valgrind --tool=helgrind) quand TSan n'est pas dispo (compilateur trop ancien, binaire non recompilable).
Dans gdb, set scheduler-locking on gèle les autres threads pendant que tu avances pas à pas, et info threads te donne l'identifiant de chacun. Pour une race non reproductible, rr record reste l'arme ultime : tu enregistres une fois, et tu peux exécuter le replay en marche arrière (reverse-continue) jusqu'à l'écriture fautive.
Cas Qt : signaux/slots, pointeurs QObject et Q_ASSERT
Le C++ Qt ajoute ses propres pièges par-dessus le C++ standard. Les trois qui génèrent le plus de segfaults :
Connexion directe entre threads. Par défaut, Qt::AutoConnection choisit QueuedConnection quand les objets vivent dans des threads différents. Si tu forces Qt::DirectConnection, le slot s'exécute dans le thread de l'émetteur et touche des données du récepteur sans synchronisation - race garantie.
// Fautif : slot exécuté dans le thread du worker
connect(worker, &Worker::ready, this, &Controller::onReady, Qt::DirectConnection);
// Correct : laisse AutoConnection, ou sois explicite
connect(worker, &Worker::ready, this, &Controller::onReady, Qt::QueuedConnection);
Pointeur QObject suspendu. Qt déconnecte automatiquement les connexions quand le récepteur est détruit, mais pas quand tu gardes une référence crue sur un objet détruit ailleurs. Un delete suivi d'un deleteLater() sur le même objet, ou un objet détruit depuis le mauvais thread, produit un use-after-free classique.
// Fautif
QTimer* timer = new QTimer(this);
delete timer;
// ... un slot appelle encore timer->isActive()
// Correct : référence faible + destruction différée
QPointer<QTimer> timer = new QTimer(this);
timer->deleteLater(); // jamais delete direct sur un QObject cross-thread
if (timer) timer->stop(); // QPointer passe à nullptr tout seul
Q_ASSERT muet en Release. Q_ASSERT est compilé hors du binaire dès que QT_NO_DEBUG est défini - donc dans toutes tes releases. Le contrôle qui devait protéger ton accès tableau disparaît exactement là où tu en as besoin.
// Fautif : disparaît en Release
Q_ASSERT(index >= 0 && index < size);
data[index] = value;
// Correct : message explicite au debug, garde réelle en release
Q_ASSERT_X(index >= 0 && index < size, "Model::set", "index hors bornes");
if (index < 0 || index >= size) { qWarning() << "index invalide" << index; return; }
data[index] = value;
Complète avec QT_FATAL_WARNINGS=1 en CI : chaque qWarning() devient fatal et te sort un bt exploitable au lieu de laisser filer l'incohérence. Q_CHECK_PTR reste utile pour tracer un new qui renvoie nullptr.
Les 7 causes racines les plus fréquentes
1. Pointeur NULL déréférencé
// Fautif
Config* cfg = loadConfig(path); // renvoie nullptr si le fichier est absent
std::string host = cfg->host; // SIGSEGV sur this == 0x0
// Correct
Config* cfg = loadConfig(path);
if (!cfg) { logError(path); return; }
std::string host = cfg->host;
En gdb, this == 0x0 dans la frame est la signature immédiate. Passe par std::optional ou une référence pour rendre le cas impossible à compiler.
2. Pointeur non initialisé (sauvage)
// Fautif
int* buffer;
buffer[0] = 42; // adresse aléatoire issue de la pile
// Correct
std::vector<int> buffer(128, 0);
buffer[0] = 42;
-Wall -Wextra et -Wmaybe-uninitialized attrapent la majorité des cas, mais pas ceux qui traversent un memcpy. ASan et valgrind, eux, les voient systématiquement.
3. Use-after-free et double free
// Fautif
Widget* w = new Widget();
delete w;
w->refresh(); // lecture d'un bloc libéré
// Correct
auto w = std::make_unique<Widget>();
w->refresh();
Symptôme typique : le crash apparaît dans free() ou operator delete d'un objet sans rapport. C'est valgrind ou ASan qui remonte à la première libération.
4. Débordement de tampon
// Fautif
char buf[16];
strcpy(buf, userInput); // écrit au-delà de 15 octets
// Correct
std::string buf = userInput;
// ou, si tu gardes du C :
snprintf(buf, sizeof(buf), "%s", userInput);
Un off-by-one de 8 octets peut ne planter que plusieurs milliers d'allocations plus tard. C'est pour ça que gdb seul échoue souvent et qu'ASan gagne.
5. Débordement de pile
// Fautif : pas de cas de base
int fib(int n) { return fib(n - 1) + fib(n - 2); }
// Correct
int fib(int n) { return (n < 2) ? n : fib(n - 1) + fib(n - 2); }
Ici le signal est SIGSEGV aussi, mais l'adresse de faute est proche de la pile et bt affiche des milliers de frames identiques. ulimit -s et -Wframe-larger-than= te donnent de la marge de diagnostic.
6. Format printf incohérent
// Fautif : %s sur un entier long long → UB, souvent SIGSEGV
long long id = 42;
printf("%s\n", id);
// Correct
printf("%lld\n", id);
std::cout << id << '\n';
Active -Wformat -Wformat-security, et sur Clang/GCC mets __attribute__((format(printf, 1, 2))) sur tes propres fonctions variadiques.
7. Mutation de conteneur pendant l'itération
// Fautif : push_back peut réallouer, it est invalidé
std::vector<int> v = {1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 2) v.push_back(4);
}
// Correct : index, ou collecte puis insertion
for (std::size_t i = 0; i < v.size(); ++i) {
if (v[i] == 2) { v.push_back(4); break; }
}
Variante multithread : deux threads qui écrivent dans le même std::vector sans verrou. Même symptôme, autre cause - et là, c'est TSan qu'il faut sortir.
L'outillage : lequel sortir, quand
| Outil | Ce qu'il attrape | Coût | Quand l'utiliser |
|---|---|---|---|
| gdb | pile, SIGSEGV, SIGABRT, frames | nul | toujours, en premier |
| valgrind memcheck | accès illégaux, uninitialized, fuites | 20-50× | crash intermittent, sanitizer indisponible |
| valgrind helgrind | races, ordre de verrous | très lent | suspicion de race sans TSan |
| AddressSanitizer | UAF, débordements heap/stack/global | ~2× | dev + CI, systématiquement |
| UBSan | UB arithmétique, casts, décalages | quasi nul | dev + CI, systématiquement |
| TSan | data races | 5-15× | tout code multi-thread |
| coredumpctl | post-mortem sur crash prod | nul | crash déjà survenu |
| rr | replay déterministe | ~1,2× à l'enregistrement | race non reproductible |
Deux RETEX terrain
Mars 2024 - poste de supervision Qt 5.15, client aéronautique. Symptôme : Segmentation fault (core dumped) environ un lancement sur quarante, uniquement après deux à trois heures d'usage. gdb renvoyait une pile dans malloc, inutilisable. ASan, rien. Ce qui a débloqué : valgrind --track-origins=yes, qui a craché Invalid read of size 8 sur un QObject déjà détruit. Deux slots appelaient deleteLater() sur le même objet. Temps total : 5 h, dont 3 h juste à reproduire le scénario. Correctif : QPointer + garde d'état, 12 lignes.
Novembre 2023 - moteur de traitement d'images C++17, 8 threads de décodage. Crash en production toutes les 6 à 12 heures, jamais en debug. Le mode debug ajoutait une synchronisation qui masquait la course. TSan a pointé une écriture non atomique : un std::atomic<bool> relu via une conversion implicite en bool, donc sans les garanties d'ordre attendues. Correction en 40 minutes, après 2 h d'instrumentation. Le bug vivait dans le code depuis sept mois.
Le point commun des deux : le crash n'était pas là où le programme mourait. D'où la discipline « un outil par question » - gdb pour la pile, valgrind et ASan pour l'origine, TSan pour la concurrence.
Checklist finale avant de dire « c'est corrigé »
Reproduction décrite en une phrase, exécutable par un collègue.
Binaire recompilé en
-g -O0(ou-Og), pas en-O2.bt fulllu, première frame applicative identifiée.Adresse fautive mappée à une allocation (valgrind ou ASan).
UBSan actif sur les tests unitaires, sans avertissement résiduel.
TSan passé sur tous les chemins multi-thread.
Fuites écartées avec
--leak-check=full.Crash rejoué sur la configuration Release exacte.
Test de non-régression ajouté au dépôt.
Correctif qui traite la cause, pas un
if (ptr)défensif qui masque le symptôme.
FAQ
Que signifie exactement « Segmentation fault (core dumped) » ? Le noyau a envoyé SIGSEGV à ton processus parce qu'il a accédé à une page mémoire sans droit d'accès. « core dumped » veut dire qu'un fichier core a été écrit (selon ulimit -c). Ce n'est pas une erreur de programmation en soi, juste la conséquence : la cause est ton accès mémoire.
Comment obtenir la ligne exacte qui provoque une erreur de segmentation ? Recompile avec -g -O0, lance gdb ./app, tape run, puis bt full. La première frame qui pointe vers ton code donne fonction et numéro de ligne. Si la pile est illisible, ouvre le core avec coredumpctl gdb.
valgrind ou AddressSanitizer : lequel utiliser en premier ? ASan en premier : environ 2× plus lent seulement, il s'intègre en CI et détecte les erreurs d'allocation avec les deux piles (accès + allocation). Valgrind prend le relais quand tu ne peux pas recompiler, ou pour tracer l'origine d'une valeur non initialisée avec --track-origins=yes.
Pourquoi mon programme segfault uniquement en Release (-O2) ? Tu as un comportement indéfini que l'optimiseur exploite : lecture d'une variable non initialisée, débordement signé, aliasing. Rejoue la configuration Release exacte avec -fsanitize=address,undefined, et compare avec une compilation -Og pour isoler l'optimisation fautive.
À quoi sert le core dump si je n'ai pas gdb sur la machine de production ? À rien sur place - mais tu l'exportes. coredumpctl dump app -o /tmp/app.core, tu rapatries le fichier avec le binaire exact (mêmes symboles), et tu l'analyses sur ton poste : gdb ./app /tmp/app.core puis bt full.

