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