Как перестать бояться 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. Потратьте один вечер, чтобы освоить эти команды. Это время окупится, когда вы будете ловить баги за минуты вместо часов. Удачи!