GDB (GNU Debugger) — самый популярный отладчик для UNIX-подобных и ряда других систем. Он работает с компилируемыми императивными языками: C, C++, Objective C, Fortran, Ada, Rust и другими.
Ричард Столлман написал его в 1986 году как часть системы GNU. Первые релизы появились около 1988 года. С тех пор отладчик был полностью переписан.
GDB позволяет заглянуть внутрь работающей программы или разобрать, что она делала в момент сбоя. Основных операций четыре:
Отлаживаемое приложение может работать на этом же компьютере, на удалённой машине или в эмуляторе.
Возьмём программу calcapp, которая рекурсивно считает факториал: calc(n, a) возвращает a при n == 0, иначе вызывает calc(n-1, n*a).
gdb ./calcapp
(gdb) list # показать исходный код
(gdb) break main # точка останова на main
(gdb) info breakpoints # список точек останова
(gdb) r # запуск (run)
(gdb) n # следующая строка (next)
(gdb) print number # значение переменной
(gdb) bt # стек вызовов (backtrace)
GDB сообщает, что символы прочитаны из ./calcapp, и ставит точку останова в main на строке 7. После запуска программа останавливается там. Команда n выполняет одну строку исходника, print number показывает значение (здесь 5), а bt выводит стек вызовов.
GDB состоит из трёх частей.
Symbol side работает с символьной информацией: именами и типами функций и переменных, номерами строк, использованием регистров. Она берёт эти данные из исполняемого файла, разбирает выражения, находит адрес по номеру строки и показывает исходный код. Это всё, что можно делать без запуска программы.
Target side управляет целевой системой: запускает и останавливает программу, читает и меняет память и регистры, перехватывает сигналы. Детали зависят от системы. В большинстве Unix-систем для этого есть системный вызов ptrace, который позволяет одному процессу читать и записывать состояние другого. Target side в основном выполняет вызовы ptrace и интерпретирует результаты. При кросс-отладке встраиваемой системы она обменивается пакетами по сети.
User interface бывает трёх видов:
Target side и symbol side отчасти независимы. Можно читать код и смотреть типы переменных, не запуская программу. Можно и отлаживать на уровне машинного кода, когда символов нет. Независимость неполная, исключения есть.
Отладочная информация нужна, чтобы работать с программой на уровне исходного кода, а не адресов. Она позволяет:
Чтобы получить отладочную информацию, программу компилируют с флагом -g:
gcc -g optimized.c
Символы — основа работы GDB: информация о переменных, функциях, типах. Из них строится таблица символов, которая связывает адрес с тем, что лежит в памяти по этому адресу. Символы могут храниться внутри исполняемого файла или отдельно от него. Каждая локальная переменная, функция, именованный тип и перечисление — отдельный символ. У крупного C++-приложения (браузер, офисный пакет) их миллионы, а таблица символов может занимать несколько гигабайт.
Форматы отладочной информации:
Для удалённой отладки на устройстве запускают gdbserver. Он содержит всю target side, поэтому эту сторону называют Target. Клиент (Host) содержит symbol side и user interface. Подключаются командой target remote, а обмен идёт по удалённому последовательному протоколу GDB (Remote Serial Protocol).
Такая схема удобна для встраиваемых устройств. Исходники и символы остаются на хост-машине, а на устройстве нужен только лёгкий сервер. Отладку можно вести и через JTAG.
Когда программа аварийно завершается, память и состояние процесса на этот момент можно сохранить в файл — coredump. Это копия адресного пространства упавшего приложения в формате ELF. Типичная причина — ошибка сегментации из-за обращения к недопустимой памяти: использование после освобождения, переполнение буфера, разыменование нулевого указателя.
Coredump особенно полезен при сбоях, которые происходят нерегулярно и которые трудно воспроизвести: можно разобрать, что случилось, уже после падения. Дамп можно создать и вручную из GDB.
Пример работы:
ulimit -c unlimited # разрешить создание coredump
gdb -c core fooapp # открыть дамп вместе с исполняемым файлом
(gdb) print var1
$1 = (int *) 0x0 # нулевой указатель
(gdb) bt
#0 0x00019f14 in signal_init () at nano.c:1192
#1 0x0001b264 in main (argc=1, argv=0xbee9de64) at fooapp.c:1268
Здесь видно, что падение произошло в signal_init(), а переменная var1 содержала нулевой указатель. Стек вызовов показывает, откуда эта функция была вызвана.
Санитайзеры встраиваются в программу на этапе компиляции (gcc, llvm) и проверяют её во время работы. По сравнению с valgrind они намного быстрее, но требуют исходного кода.
| Санитайзер | Что находит | Замедление |
|---|---|---|
| ASan (Address) | использование после освобождения (heap, stack, global), переполнение буферов в C/C++ | примерно в 2 раза |
| TSan (Thread) | гонки данных | в 5–15 раз |
| MSan (Memory) | использование неинициализированной памяти | примерно в 3 раза |
| UBSan (Undefined Behavior) | неопределённое поведение | не указано |
Для использования AddressSanitizer программу компилируют и линкуют с флагом -fsanitize=address. Флаг -g нужен, чтобы в отчёте были строки исходного кода. Пример Makefile:
hello: hello.o
gcc hello.o -o hello -fsanitize=address -static-libasan
hello.o: hello.c
gcc -c hello.c -fsanitize=address -g
Программа выделяет malloc(100), копирует туда строку и завершается без free. LeakSanitizer сообщает detected memory leaks, указывает прямую утечку в 100 байт в одном объекте и стек места выделения с файлом и строкой.
Программа вызывает free(buf), а потом снова печатает buf. ASan выдаёт heap-use-after-free и в одном отчёте показывает три вещи:
Также сообщается, что адрес находится внутри 100-байтового блока, который уже освобождён.
Программа выделяет malloc(strlen("Hello world!")), то есть 12 байт, но strcpy записывает 13: строка плюс завершающий нулевой символ. ASan выдаёт heap-buffer-overflow: запись 13 байт, адрес находится сразу за концом 12-байтового блока. Отчёт указывает и место выделения.
Ошибка вызвана тем, что strlen не учитывает завершающий \0. Правильно выделять strlen(...) + 1.