Как перестать бояться 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 нам пришлось бы:

  1. Добавить cout внутрь getName

  2. Добавить внутрь setName

  3. Перекомпилировать

  4. Запустить снова

  5. Повторять, пока не найдём точное место

С 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 в любом классе

Когда программа остановилась на брейкпоинте, можно пройтись по коду пошагово.

Команда

Действие

next (или n)

Выполнить текущую строку, не заходя внутрь функций

step (или s)

Выполнить текущую строку, заходя внутрь функций

finish

Выполнить до конца текущей функции и выйти

continue (или c)

Продолжить выполнение до следующего брейкпоинта

Важное различие для 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:

Команда

Действие

layout src

Показать исходный код

layout asm

Показать ассемблер

layout split

Показать и код, и ассемблер

layout regs

Показать регистры

Ctrl + X, 2

Переключить количество окон

Ctrl + X, o

Переключить активное окно

Ctrl + X, a

Выйти из TUI-режима

В TUI - режиме текущая строка кода подсвечивается, и вы видите, где находитесь, не вспоминая номера строк.

Что делать, если GDB не показывает переменные

Иногда GDB не может показать значение переменной. Вот основные причины и решения:

Проблема

Решение

Переменная <optimized out>

Перекомпилируйте с -O0 вместо -O2

GDB не видит имена переменных

Убедитесь, что флаг -g присутствует при компиляции

Не видно локальные переменные в функции

Выполните info locals после того, как программа вошла в функцию

Вектор пустой, но GDB показывает мусор

В старых версиях libstdc++ используйте print vec._M_impl._M_start для просмотра указателя на данные

Основные команды GDB

Вот список команд, которые реально нужны новичку.

Команда

Сокращение

Что делает

run

r

Запустить программу

break

b

Поставить точку останова

backtrace

bt

Показать стек вызовов

next

n

Выполнить строку (не заходя в функции)

step

s

Выполнить строку (заходя в функции)

finish

fin

Выполнить до конца текущей функции

continue

c

Продолжить выполнение

print

p

Показать значение переменной

display

disp

Показывать переменную после каждого шага

info locals

i locals

Показать все локальные переменные

info args

i args

Показать аргументы функции

quit

q

Выйти из GDB

Заключение: что я понял

Когда я только начинал, GDB казался мне сложным и не понятным. Теперь я понимаю:

  1. GDB страшен только до первого успешного запуска. Как только вы увидите, как backtrace показывает точное место падения, страх уходит.

  2. Почти 80% задач решается 5-10 командами. Не нужно учить все возможности GDB - достаточно освоить базу.

  3. TUI-режим делает отладку визуальной. Когда видишь код и можешь шагать по нему, отладка перестаёт быть абстракцией.

  4. Умение отлаживать - это привычка, которая прокачивается практикой. С каждым разом вы будете находить баги быстрее и увереннее.

Если вы до сих пор пользуетесь std::cout для отладки - попробуйте GDB. Потратьте один вечер, чтобы освоить эти команды. Это время окупится, когда вы будете ловить баги за минуты вместо часов. Удачи!

Комментарии (18)


  1. apevzner
    29.03.2026 07:43

    Ох уж эта священная война между любителями отладчиков и любителями логов.

    Отладчик хорош для простых случаев.

    Хотел бы я посмотреть, как вы будете отлаживать отладчиком многопоточный высоконагруженный сервер, который нет, не падает под высокой нагрузкой, но начинает лажать в бизнес-логике.

    Я уж не говорю о том, что на целевой платформе может просто и не быть отладчика.

    Поэтому нет, отладчик не отменяет структурированных логов (и это не просто, “пишем какую-то фигню в cout”).

    И ещё один момент. Когда у вас формируется опыт отладки по логам, вы учитесь структурировать код так, чтобы его поведение было понятным по логам. Это делает ваши решения более прямолинейными, что хорошо и для отладки и в целом, для структурирования кода.


    1. HiItsYuri
      29.03.2026 07:43

      Запускаешь под тред саном, нагружаешь, находишь гонку, дебажишь. Ничего сложного.


      1. apevzner
        29.03.2026 07:43

        Ну допустим, под тред саном проблема не воспроизводится. И тред сан не находит, к чему придраться.

        Что дальше будем делать?


        1. feelamee
          29.03.2026 07:43

          так какой у вас аргумент против отладчика? то что сервер высоконагруженный? так себе довод.

          Вы рассматривали rr - record repeat дебаггер? Можно однин раз записать то как воспроизводится ошибка, а потом дебажить идентичное исполнение, даже адреса будут такие же. Например, если где-то портятся данные, можно поставить бряк на момент когда они испорчены, поставить watch/бряк на момент записи в них и запустить reverse continue - в итоге последовательно пройтись по моментам когда туда были записаны некорректные данные.

          Не считаю что логи бесполезны. Логи нужны и автор статьи этого не отрицал. Он говорил про printf-дебаггинг.

          Но то что дебаггер подходит только для простых случаев… большое заблуждение


          1. apevzner
            29.03.2026 07:43

            Как бы так сказать…

            Я не возражаю против отладчика, как такового.

            Но есть множество ситуаций, когда отладчик, скажем так, малоприменим.

            Например, достаточно сложная система с большим количеством параллельных активностей и низкой вероятностью возникновения проблем. Вы запускаете её под отладчиком раз, другой, у вас всё хорошо. А под реальной нагрузкой падает. Даже и не падает, при падении хоть есть стек. Глючит, выдаёт неправильный результат. Сломаное состояние такой системы очень трудно поймать в отладчике.

            Другой пример, ваша программа прекрасно работает у вас на компьютере и проходит все тесты. А у заказчика проявляются какие-то ошибки. Приехать к заказчику с отладчиком - не вариант. В лучшем случае вы можете попробовать воспроизвести у себя конфигурацию заказчика. Только вот беда, конфигурацию вы воспроизвели, а у вас всё равно всё хорошо работает. А у него - нет. Куда тут подлезть с отладчиком?

            Третий пример, ваша программа прекрасно работает в отладочной сборке. А в релизной - нет. И компилятор так всё соптимизировал, что вы даже и понимаете, куда хотите заглянуть, но отладчик не видит ваших переменных, компилятор их уоптимизировал с глаз долой. Что делать?

            Четвёртый пример, ваш код исполняется на встраиваемой системе. Теоретически, там даже и отладчик есть. Только не работает по инструкции. Вы лезете разбираться, и через несколько частов выясняется, что у вас появился новый проект: запустить отладчик на вашей железке. Очевидно непростой, и с малопредсказуемыми сроками исполнения.

            Скажете, это редкие случаи? Возможно. Но это зависит от вашего опыта, вашей практики. Как по мне, именно какие-то такие случаи занимают достаточное количество усилий, чтобы хоть запомниться. А те простые случаи, которые берутся с отладчиком, ну они и без него прекрасно берутся.

            И вот мысль, которую я хочу подчеркнуть отдельно. Когда основной ваш поиск ошибок происходит в условиях, когда у вас нет доступа к живой системе с отладчиком в руках, вы постепенно приучаетесь структурировать свой код так, что он становится пригоден для такой отладки. Логи в нужных местах, стараетесь не писать в лог всякий шум, события в логах должны быть сводимы, цепочки принятия решений должны отслеживаться. Пользовательскую конфигурацию имеет смысл записать в лог - это экономит время по извлечению её из пользователя (и гарантирует точность). Это влияет на саму логику вашего кода, и влияет в лучшую сторону. Вам приходится заранее хорошо задумываться, что делает ваш код. А не когда уже припёрло.

            По-моему, как-то вот так…


            1. unreal_undead2
              29.03.2026 07:43

              И компилятор так всё соптимизировал, что вы даже и понимаете, куда хотите заглянуть, но отладчик не видит ваших переменных, компилятор их уоптимизировал с глаз долой. Что делать?

              Смотрим в отладчике ассемблер )

              Но соглашусь, что отладчик хорош для исследования крешей. Если просто после долгой обработки результат не сходится - логирование промежуточных данных гораздо полезнее. И аргумент с проблемами на пользовательской машине тоже правильный, и опять же логи хороши. Но при падении на локальной машине в процессе разработки отладчик экономит время.


            1. Imaginarium
              29.03.2026 07:43

              Не знаю, я прекрасно обхожусь с отладчиком при изучении непонятного поведения многопоточных тяжёлых приложений с openmp на fortran, к примеру. Ну, когда кода ~30к строк и писал не я изначально, логи (они есть) в лучшем случае направление исследования зададут, а в худшем – просто шум. Просто отслеживал интересующие переменные, даже если они очень крупные массивы, локализовал точки отказа...

              У меня не было (к счастью) всех перечисленных Вами сценариев, но и свои случаи считать простыми я бы не смог даже с натяжкой. И кстати, просмотр асм кода весьма помогает, да. Оптимизаций ЦП не боюсь совершенно, всё прозрачно, разобраться можно, просто долго.


      1. apevzner
        29.03.2026 07:43

        Да вот пример из собственной практики, в гошном stdlib-е:

        https://habr.com/ru/articles/906796/

        С точки зрения санитайзера (race detector-а), там всё было хорошо.


  1. boov
    29.03.2026 07:43

    Следующий этап это постмортем анализ, а далее техника записи состояния программы с воспроизведением проблемы:

    Time Travel Debug - под windows.

    RR - под linux.

    Но может быть тяжеловесно по размеру дампа.


    1. Belarus
      29.03.2026 07:43

      Time Travel Debug - под windows

      Не бесплатный.


  1. malkovsky
    29.03.2026 07:43

    Хорошая и полезная статья.

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


    1. Siemargl
      29.03.2026 07:43

      Под капотом той же vscode именно gdb и работает в текстовом режиме


      1. malkovsky
        29.03.2026 07:43

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


        1. Imaginarium
          29.03.2026 07:43

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


  1. SashkaCosmonaut
    29.03.2026 07:43

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


    1. Urub
      29.03.2026 07:43

      в ide вызывается тотже gdb, см. например qtcreator


      1. malkovsky
        29.03.2026 07:43

        Это верно, но статья была про то как использовать gdb из консоли, а не в qtcreator.


  1. Belarus
    29.03.2026 07:43

    Мой метод отладки выглядел примерно так

    Это называетса принтф-отладка (printf debugging).

    Выше уже отметили про "перемотку времени". Представьте, што случился баг, но вы не перезапускаете програму, штобы попытатса повторить и понять баг, и потом начать принт-отладку или подключить отладчик. Вы пошагово исполняете програму назад. И так вы сразу увидите, какое неправильное значение вызывает баг, а отматывая далее вы увидите откуда вообще оно взялось.

    Называют такое "Всезнающий отладчик" (omniscient debugger). И я никак не могу понять, почему он не становитса стандартом разработки.

    Видимо, потому што их сложно разработать и поэтому на Винде они все платные, нужен аналог rr.