Как перестать бояться segmentation fault и научиться находить баги за несколько минут

Когда я начинал изучать C++, GDB казался мне чем-то из области фантастики. Чёрный экран, непонятные команды, какая-то магия для настоящих программистов-гуру. Мой метод отладки выглядел примерно так:

Я запускал программу, смотрел, какое число вывелось последним, и примерно понимал, где упало. Потом добавлял еще больше cout и повторял снова.
В первое время это отлично работало, но... пока проект не вырос до нескольких десятков файлов, а баги не стали проявляться раз в 10 запусков. Тогда я понял: либо я научусь пользоваться нормальным отладчиком, либо потрачу остаток жизни на перекомпиляции и строчки с "дошло/не дошло". Оказалось, что GDB - это не магия. 90% задач решается 4-8 командами, а главный страх - просто неизвестность.
В этой статье я расскажу, как преодолеть этот страх, и на реальных примерах, как GDB превращает поиск багов из гадания в системную работу.
cout - это плохой отладчик
Конкретный пример, где cout бессилен. Представьте программу, которая падает с Segmentation fault:

Запуск:
=== Запуск программы === 1. Начало processUser Segmentation fault (core dumped)
Мы знаем, что программа дошла до "1. Начало processUser", но не дошла до "2. Имя пользователя". Это значит, что падение произошло где-то между этими двумя строками. Но где именно? Внутри user.getName()? Может, проблема в setName? Мы не знаем.
С cout нам пришлось бы:
Добавить
coutвнутрьgetNameДобавить внутрь
setNameПерекомпилировать
Запустить снова
Повторять, пока не найдём точное место
С GDB эта задача решается за 15-30 секунд.
GDB (GNU Debugger) - это программа, которая позволяет заглянуть внутрь вашего кода во время выполнения.
С ней вы можете:
Поставить программу на паузу в любой момент
Посмотреть значения всех переменных, а не только тех, которые вы догадались вывести
Пройтись по коду пошагово, наблюдая, как меняются данные
Увидеть стек вызовов - цепочку функций, которая привела к падению
В отличие от cout, GDB не требует перекомпиляции после каждого изменения. Вы просто запускаете программу под отладчиком и исследуете её в реальном времени.
Чтобы GDB мог показывать имена переменных, строки кода и другую полезную информацию, нужно скомпилировать программу с отладочными символами.
Флаги компиляции:
-g - добавляет отладочную информацию
-O0 - отключает оптимизации (иначе компилятор может переставить код, и отладка станет запутанной)
g++ -g -O0 -o myprogram myprogram.cpp
Проверить, что отладочная информация есть, можно командой file:
file myprogram
Если в выводе есть with debug_info, всё сделано правильно.
Первый запуск
Запускаем GDB:
gdb ./myprogram
Вы увидите примерно такое:
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1 ... Reading symbols from ./myprogram... (gdb)
Появилось приглашение (gdb). Теперь можно запустить программу:
(gdb) run
Программа запустится и будет работать как обычно. Если она работает нормально - GDB ничего не делает, просто ждёт. Если программа упадёт - GDB поймает это событие и покажет место падения.
Попробуйте запустить наш пример с багом:
(gdb) run Starting program: ./myprogram === Запуск программы === 1. Начало processUser Program received signal SIGSEGV, Segmentation fault. 0x00005555555551a7 in std::string::operator= (this=0x0, __str=...) at /usr/include/c++/11/bits/basic_string.h:...
GDB сам остановился на месте падения и показал, что проблема в std::string::operator=.
Самая важная команда для новичка - backtrace (или сокращённо bt). Она показывает стек вызовов - цепочку функций, которая привела к падению.
(gdb) bt #0 0x00005555555551a7 in std::string::operator= (this=0x0, __str=...) at ... #1 0x0000555555555190 in UserData::setName (this=0x7fffffffddf0, n=...) at program.cpp:8 #2 0x0000555555555225 in main () at program.cpp:27
Теперь мы видим всю картину:
#0 - место падения: внутри std::string::operator=
#1 - вызов из UserData::setName на строке 8
#2 - вызов из main на строке 27
Проблема ясна: в UserData::setName мы разыменовываем nullptr (это видно по this=0x0 в кадре #1). GDB показывает, что указатель this (указатель на объект, который вызывает метод) равен нулю.
Важно для C++: Имена функций могут выглядеть странно (например, ZNSt7_cxx1112basic_stringIcSt11char_traitsIcESaIcEEaSEOS4_). Это называется "mangled names". Чтобы сделать вывод читаемым, используйте:
(gdb) set print pretty on (gdb) set print demangle on
После этого функции будут отображаться в нормальном виде: std::string::operator=.
Остановка программы
Часто нужно остановить программу до того, как она упадёт, чтобы посмотреть, что происходит. Для этого используются точки останова (breakpoints).
Основные команды:
(gdb) break main # остановиться в начале main (gdb) break program.cpp:42 # остановиться на строке 42 (gdb) break UserData::setName # остановиться при входе в функцию (gdb) info break # посмотреть все брейкпоинты (gdb) delete 1 # удалить брейкпоинт номер 1
C++-специфика: точки останова на перегруженных функциях
Если у вас есть несколько функций с одинаковым именем, нужно указать тип параметров:
void print(int x) { ... } void print(const std::string& s) { ... }
Ставим брейкпоинты так:
(gdb) break print(int) (gdb) break print(std::string)
Продвинуто: точки останова по регулярному выражению
(gdb) rbreak ^.*::print$ # все методы print в любом классе
Когда программа остановилась на брейкпоинте, можно пройтись по коду пошагово.
Команда |
Действие |
|---|---|
|
Выполнить текущую строку, не заходя внутрь функций |
|
Выполнить текущую строку, заходя внутрь функций |
|
Выполнить до конца текущей функции и выйти |
|
Продолжить выполнение до следующего брейкпоинта |
Важное различие для C++:
Если вы выполните step на строке с вызовом функции из STL (например, std::vector::push_back), вы попадёте внутрь реализации STL - это может быть сложно для новичка. Используйте next, чтобы пропустить вызов, или finish, если случайно зашли внутрь.
std::vector<int> v = {1,2,3}; v.push_back(4); // если сделать step, попадёте внутрь STL
GDB позволяет смотреть значения переменных в любой момент.
Основные команды:
(gdb) print variable_name # показать значение переменной (gdb) print *pointer # показать значение по указателю (gdb) print vec.size() # можно вызывать методы (gdb) display variable_name # показывать значение после каждого шага (gdb) info locals # показать все локальные переменные (gdb) info args # показать аргументы текущей функции
Как GDB показывает C++-объекты:
Для std::vector:
(gdb) print myVector $1 = std::vector of length 5, capacity 8 = {1, 2, 3, 4, 5}
Для std::string:
(gdb) print myString $2 = "Hello, World!"
Если GDB говорит <optimized out>:
Это означает, что компилятор оптимизировал переменную. Чаще всего это происходит при компиляции с флагами -O2 или выше. Для отладки используйте -O0.
TUI-режим: видеть код во время отладки
Это то, что реально меняет опыт отладки, но про это почти нет статей на русском.
TUI (Text User Interface) - режим, в котором экран делится на две части: сверху вы видите исходный код, снизу - команды GDB.
Активация:
(gdb) tui enable
Полезные команды TUI:
Команда |
Действие |
|---|---|
|
Показать исходный код |
|
Показать ассемблер |
|
Показать и код, и ассемблер |
|
Показать регистры |
|
Переключить количество окон |
|
Переключить активное окно |
|
Выйти из TUI-режима |
В TUI - режиме текущая строка кода подсвечивается, и вы видите, где находитесь, не вспоминая номера строк.
Что делать, если GDB не показывает переменные
Иногда GDB не может показать значение переменной. Вот основные причины и решения:
Проблема |
Решение |
|---|---|
Переменная |
Перекомпилируйте с |
GDB не видит имена переменных |
Убедитесь, что флаг |
Не видно локальные переменные в функции |
Выполните |
Вектор пустой, но GDB показывает мусор |
В старых версиях libstdc++ используйте |
Основные команды GDB
Вот список команд, которые реально нужны новичку.
Команда |
Сокращение |
Что делает |
|---|---|---|
|
|
Запустить программу |
|
|
Поставить точку останова |
|
|
Показать стек вызовов |
|
|
Выполнить строку (не заходя в функции) |
|
|
Выполнить строку (заходя в функции) |
|
|
Выполнить до конца текущей функции |
|
|
Продолжить выполнение |
|
|
Показать значение переменной |
|
|
Показывать переменную после каждого шага |
|
|
Показать все локальные переменные |
|
|
Показать аргументы функции |
|
|
Выйти из GDB |
Заключение: что я понял
Когда я только начинал, GDB казался мне сложным и не понятным. Теперь я понимаю:
GDB страшен только до первого успешного запуска. Как только вы увидите, как
backtraceпоказывает точное место падения, страх уходит.Почти 80% задач решается 5-10 командами. Не нужно учить все возможности GDB - достаточно освоить базу.
TUI-режим делает отладку визуальной. Когда видишь код и можешь шагать по нему, отладка перестаёт быть абстракцией.
Умение отлаживать - это привычка, которая прокачивается практикой. С каждым разом вы будете находить баги быстрее и увереннее.
Если вы до сих пор пользуетесь std::cout для отладки - попробуйте GDB. Потратьте один вечер, чтобы освоить эти команды. Это время окупится, когда вы будете ловить баги за минуты вместо часов. Удачи!
Комментарии (18)

malkovsky
29.03.2026 07:43Хорошая и полезная статья.
А вы пользуетесь отладчиклм из терминала? Есть ли какие-то преимущества над плагиами в ide, для меня вот интерфейс терминала для отладки почти бесполезен и не ускоряет посравнению “отладкой принтами”

Siemargl
29.03.2026 07:43Под капотом той же vscode именно gdb и работает в текстовом режиме

malkovsky
29.03.2026 07:43Это понятно, вот только я готов использовать gdb с интерфейсом в vscode, но не готов использовать чистый gdb из терминала, вопрос в этом

Imaginarium
29.03.2026 07:43А что Вас смущает? Лично мне наоборот, даже TUI в gdb немного кондовым и тяжеловесным кажется, мешает.

SashkaCosmonaut
29.03.2026 07:43Присоединяюсь к комментарию выше. Подскажите, пожалуйста, почему Ваш выбор пал на отладчик в консоли? Почему в статье проигнорированы отладчики, встроенные в IDE?

Belarus
29.03.2026 07:43Мой метод отладки выглядел примерно так
Это называетса принтф-отладка (printf debugging).
Выше уже отметили про "перемотку времени". Представьте, што случился баг, но вы не перезапускаете програму, штобы попытатса повторить и понять баг, и потом начать принт-отладку или подключить отладчик. Вы пошагово исполняете програму назад. И так вы сразу увидите, какое неправильное значение вызывает баг, а отматывая далее вы увидите откуда вообще оно взялось.
Называют такое "Всезнающий отладчик" (omniscient debugger). И я никак не могу понять, почему он не становитса стандартом разработки.
Видимо, потому што их сложно разработать и поэтому на Винде они все платные, нужен аналог rr.
apevzner
Ох уж эта священная война между любителями отладчиков и любителями логов.
Отладчик хорош для простых случаев.
Хотел бы я посмотреть, как вы будете отлаживать отладчиком многопоточный высоконагруженный сервер, который нет, не падает под высокой нагрузкой, но начинает лажать в бизнес-логике.
Я уж не говорю о том, что на целевой платформе может просто и не быть отладчика.
Поэтому нет, отладчик не отменяет структурированных логов (и это не просто, “пишем какую-то фигню в cout”).
И ещё один момент. Когда у вас формируется опыт отладки по логам, вы учитесь структурировать код так, чтобы его поведение было понятным по логам. Это делает ваши решения более прямолинейными, что хорошо и для отладки и в целом, для структурирования кода.
HiItsYuri
Запускаешь под тред саном, нагружаешь, находишь гонку, дебажишь. Ничего сложного.
apevzner
Ну допустим, под тред саном проблема не воспроизводится. И тред сан не находит, к чему придраться.
Что дальше будем делать?
feelamee
так какой у вас аргумент против отладчика? то что сервер высоконагруженный? так себе довод.
Вы рассматривали rr - record repeat дебаггер? Можно однин раз записать то как воспроизводится ошибка, а потом дебажить идентичное исполнение, даже адреса будут такие же. Например, если где-то портятся данные, можно поставить бряк на момент когда они испорчены, поставить watch/бряк на момент записи в них и запустить reverse continue - в итоге последовательно пройтись по моментам когда туда были записаны некорректные данные.
Не считаю что логи бесполезны. Логи нужны и автор статьи этого не отрицал. Он говорил про printf-дебаггинг.
Но то что дебаггер подходит только для простых случаев… большое заблуждение
apevzner
Как бы так сказать…
Я не возражаю против отладчика, как такового.
Но есть множество ситуаций, когда отладчик, скажем так, малоприменим.
Например, достаточно сложная система с большим количеством параллельных активностей и низкой вероятностью возникновения проблем. Вы запускаете её под отладчиком раз, другой, у вас всё хорошо. А под реальной нагрузкой падает. Даже и не падает, при падении хоть есть стек. Глючит, выдаёт неправильный результат. Сломаное состояние такой системы очень трудно поймать в отладчике.
Другой пример, ваша программа прекрасно работает у вас на компьютере и проходит все тесты. А у заказчика проявляются какие-то ошибки. Приехать к заказчику с отладчиком - не вариант. В лучшем случае вы можете попробовать воспроизвести у себя конфигурацию заказчика. Только вот беда, конфигурацию вы воспроизвели, а у вас всё равно всё хорошо работает. А у него - нет. Куда тут подлезть с отладчиком?
Третий пример, ваша программа прекрасно работает в отладочной сборке. А в релизной - нет. И компилятор так всё соптимизировал, что вы даже и понимаете, куда хотите заглянуть, но отладчик не видит ваших переменных, компилятор их уоптимизировал с глаз долой. Что делать?
Четвёртый пример, ваш код исполняется на встраиваемой системе. Теоретически, там даже и отладчик есть. Только не работает по инструкции. Вы лезете разбираться, и через несколько частов выясняется, что у вас появился новый проект: запустить отладчик на вашей железке. Очевидно непростой, и с малопредсказуемыми сроками исполнения.
Скажете, это редкие случаи? Возможно. Но это зависит от вашего опыта, вашей практики. Как по мне, именно какие-то такие случаи занимают достаточное количество усилий, чтобы хоть запомниться. А те простые случаи, которые берутся с отладчиком, ну они и без него прекрасно берутся.
И вот мысль, которую я хочу подчеркнуть отдельно. Когда основной ваш поиск ошибок происходит в условиях, когда у вас нет доступа к живой системе с отладчиком в руках, вы постепенно приучаетесь структурировать свой код так, что он становится пригоден для такой отладки. Логи в нужных местах, стараетесь не писать в лог всякий шум, события в логах должны быть сводимы, цепочки принятия решений должны отслеживаться. Пользовательскую конфигурацию имеет смысл записать в лог - это экономит время по извлечению её из пользователя (и гарантирует точность). Это влияет на саму логику вашего кода, и влияет в лучшую сторону. Вам приходится заранее хорошо задумываться, что делает ваш код. А не когда уже припёрло.
По-моему, как-то вот так…
unreal_undead2
Смотрим в отладчике ассемблер )
Но соглашусь, что отладчик хорош для исследования крешей. Если просто после долгой обработки результат не сходится - логирование промежуточных данных гораздо полезнее. И аргумент с проблемами на пользовательской машине тоже правильный, и опять же логи хороши. Но при падении на локальной машине в процессе разработки отладчик экономит время.
Imaginarium
Не знаю, я прекрасно обхожусь с отладчиком при изучении непонятного поведения многопоточных тяжёлых приложений с openmp на fortran, к примеру. Ну, когда кода ~30к строк и писал не я изначально, логи (они есть) в лучшем случае направление исследования зададут, а в худшем – просто шум. Просто отслеживал интересующие переменные, даже если они очень крупные массивы, локализовал точки отказа...
У меня не было (к счастью) всех перечисленных Вами сценариев, но и свои случаи считать простыми я бы не смог даже с натяжкой. И кстати, просмотр асм кода весьма помогает, да. Оптимизаций ЦП не боюсь совершенно, всё прозрачно, разобраться можно, просто долго.
apevzner
Да вот пример из собственной практики, в гошном stdlib-е:
https://habr.com/ru/articles/906796/
С точки зрения санитайзера (race detector-а), там всё было хорошо.