26 KiB
D3, часть 3. Отладка: gdb, core dump, санитайзеры (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь tasks/09_gdb. Задача
дня — не переписать падающую программу с нуля, а найти дефекты отладчиком и починить
минимально.
1. Что нужно для отладки: -g -O0, DWARF
Флаг -g встраивает в бинарник отладочную информацию в формате DWARF — соответствие
машинных адресов номерам строк исходника, именам переменных и их типам. Без -g gdb видит
только адреса и ассемблер: bt покажет голые адреса вместо имён функций и номеров строк.
-O0 отключает оптимизации компилятора и обязателен вместе с -g для комфортной отладки:
оптимизатор переставляет и удаляет инструкции, инлайнит функции и переиспользует регистры под
разные переменные, из-за чего отладочная информация перестаёт однозначно соответствовать
исходному коду — строки «прыгают», переменные показывают не то значение. Типичная ошибка —
попытаться отладить прод-бинарник, собранный с -O2: получится рассинхронизация строк и
пропущенные шаги.
Факты для карточек
- base | Какие два флага компиляции нужны для комфортной отладки в gdb? —
-g -O0 - base | Как называется формат отладочной информации, который встраивает
-g? — DWARF - core | Почему
-O2мешает отладке даже при наличии-g? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, отладочная информация перестаёт однозначно совпадать с исходником
Почему дальше: раз бинарник собран правильно, следующий шаг — реально запустить его под отладчиком и разобраться с базовыми командами.
2. Запуск и базовые команды
gdb ./prog запускает отладчик с указанным бинарником, run args внутри gdb передаёт
управление процессу с аргументами командной строки, останавливаясь на точках останова или
при сигнале.
bt— печатает стек вызовов (backtrace): цепочку кадров от текущей функции доmain.frame N— переключает контекстprint/listна конкретный кадр этого стека.info locals/info args— печатают локальные переменные и аргументы текущего кадра.print x(илиp x) — печатает текущее значение переменной по её типу из отладочной информации.x/16xb ptr— команда examine memory: печатает 16 (N) единиц в формате hex (x), единица — байт (b), начиная с адресаptr; форматx/NFU addr.
Факты для карточек
- base | Какая команда печатает стек вызовов в gdb? —
bt - base | Что означает
x/16xb ptr? — 16 байт в hex начиная с адресаptr(форматx/NFU addr) - core | Чем
frame Nотличается отbt? —btпечатает весь стек,frame Nпереключает текущий контекстprint/info localsна конкретный кадр из этого стека
Почему дальше: чтобы остановиться в нужном месте, а не просто дойти до конца программы, нужны точки останова.
3. Точки останова
break file:line или break func ставит точку останова — адрес, на котором выполнение
приостанавливается при каждом попадании. tbreak — то же самое, но точка автоматически
удаляется после первого срабатывания, удобно для одноразовой остановки без ручной очистки.
watch var — точка наблюдения (watchpoint): останавливает выполнение при каждом изменении
значения переменной, а не в конкретной строке кода. rwatch var — остановка при чтении,
awatch var — при чтении или записи (access). Механизм — аппаратные регистры отладки
процессора: на x86 их 4 (DR0–DR3), поэтому одновременно можно держать лишь ограниченное
число аппаратных watchpoint; gdb использует их, если может, иначе откатывается на медленный
программный watchpoint (построчное выполнение с проверкой значения после каждой инструкции).
watch незаменим, когда переменная меняется как будто сама по себе из другого места кода —
без него пришлось бы вручную расставлять точки останова по подозрению.
Факты для карточек
- base | Чем
tbreakотличается отbreak? —tbreakудаляется автоматически после первого срабатывания - core | Чем
rwatchотличается отwatch? —watchреагирует на изменение значения,rwatch— на чтение переменной - deep | Сколько аппаратных регистров отладки на x86 ограничивают число одновременных аппаратных watchpoint? — 4 (
DR0–DR3)
Почему дальше: точки останова дают место остановки, но дальше нужно управлять именно ходом выполнения — построчно или до конца функции.
4. Степание: next/step/finish/until
next— выполняет текущую строку целиком, включая вызовы функций внутри неё, не заходя внутрь них.step— заходит внутрь вызываемой функции, если для неё есть отладочная информация.finish— выполняет до возврата из текущей функции, печатает возвращаемое значение.until(без аргумента) — продолжает выполнение до строки с номером больше текущей в текущем кадре, удобно чтобы выйти из цикла, не проходя его пошагово итерацию за итерацией.
Если бы step всегда заходил внутрь, отладка кода с вызовами библиотечных функций без
отладочной информации была бы мучительной — next даёт способ пропустить неинтересную
функцию, не теряя контроль над остальным ходом программы.
Факты для карточек
- base | Чем
nextотличается отstep? —nextне заходит внутрь вызываемых функций,stepзаходит - core | Что делает
finish? — выполняет до возврата из текущей функции и печатает возвращаемое значение - core | Зачем нужен
untilвнутри цикла? — продолжить до строки с номером больше текущей, не проходя цикл пошагово
Почему дальше: все эти команды одинаково работают и при разборе уже случившегося падения — после срабатывания сигнала или по сохранённому снимку памяти (core dump).
5. Разбор падения: core dump, ulimit -c, bt по кадрам
Первый путь — запустить программу прямо под gdb и дождаться сигнала (обычно SIGSEGV, код
139 = 128+11): gdb сам остановится на инструкции, вызвавшей сбой, bt покажет полный стек
вызовов, а print значений указателей и переменных в нужных кадрах обычно сразу показывает,
например, что указатель равен nullptr или мусорному значению.
Второй путь — по core dump: файл-снимок памяти процесса, который ядро ОС сохраняет в
момент сигнала, приводящего к аварийному завершению, — все сегменты адресного пространства
(стек, куча, регистры процессора, список загруженных библиотек). Открывают его отдельно:
gdb prog core — и получают тот же bt/print, но постфактум, без необходимости
воспроизводить падение заново. Core dump — единственный способ разобрать баг, который
воспроизводится редко или только под нагрузкой в проде, куда заранее интерактивный отладчик
не прицепишь.
По умолчанию система часто отключает сохранение core-файлов — перед тем как ждать падение,
выставляют ulimit -c unlimited. Типичная ошибка — забыть это сделать и потерять
единственный шанс поймать редкий баг; вторая типичная ошибка — пересобрать бинарник (даже
без изменения логики) перед анализом core — адреса не совпадут с записанными в core, и gdb
покажет несогласованный стек вместо реального.
Факты для карточек
- base | Каким кодом завершается процесс при SIGSEGV? — 139 (128+11)
- base | Какой командой разрешить сохранение core-файлов перед ожиданием падения? —
ulimit -c unlimited - core | Как открыть core dump вместе с бинарником в gdb? —
gdb prog core - core | Почему пересборка бинарника перед анализом core ломает разбор? — адреса в новом бинарнике не совпадают с адресами, записанными в core, gdb покажет несогласованный стек
Почему дальше: падение может быть не одиночным потоком — если программа многопоточная, нужно отдельно смотреть, какой именно поток и в каком состоянии упал или завис.
6. Отладка многопоточности
info threads показывает все потоки процесса и их текущее состояние (на какой строке/в
какой функции остановлен каждый). thread N переключает текущий контекст отладчика (для
bt, frame, print) на поток с номером N.
Это основной способ диагностировать зависший дедлок: программа не падает и не пишет ничего в
лог, просто стоит — info threads и просмотр стека (bt) каждого потока по очереди
показывают, какой поток на каком мьютексе застрял и кого он, в свою очередь, ждёт.
Факты для карточек
- base | Какая команда gdb показывает все потоки процесса разом? —
info threads - core | Как переключиться на конкретный поток по номеру для
bt/print? —thread N - core | Как gdb помогает диагностировать дедлок, если программа просто зависла без вывода? —
info threads+btпо каждому потоку показывают, кто на каком мьютексе застрял
Почему дальше: не каждый краш находится ровно там, где gdb его показывает, — иногда точка остановки и точка реальной ошибки в коде далеко друг от друга.
7. Почему краш «далеко от причины»: ASAN против gdb
Переполнение буфера или запись по неверному указателю портит чужую память, но крах может случиться значительно позже — например, когда программа попытается использовать уже испорченный указатель совсем в другом месте кода. gdb в момент самого краша покажет именно точку симптома (где программа реально упала), а не точку, где память была испорчена изначально.
Санитайзеры (ASAN — -fsanitize=address, UBSAN — -fsanitize=undefined) инструментируют
каждое обращение к памяти на этапе компиляции и останавливают программу с диагностикой в
момент нарушения, а не когда испорченные данные позже вызовут крах в неожиданном месте —
то есть показывают точку причины напрямую. ASAN дополнительно ловит выход за границы,
use-after-free и двойное освобождение через «красные зоны» вокруг выделенных блоков и
теневую карту памяти; в связке с LeakSanitizer он же в конце работы программы репортит
утечки. Санитайзеры — это перекомпиляция с проверками, а не отдельная программа поверх
готового бинарника, и включаются одним флагом на этапе сборки.
Факты для карточек
- core | В чём разница между точкой, которую покажет gdb при краше, и точкой, которую покажет ASAN? — gdb показывает точку симптома (где реально упало), ASAN — точку причины (момент нарушения)
- base | Каким флагом компиляции включается ASAN? —
-fsanitize=address - base | Каким флагом компиляции включается UBSAN? —
-fsanitize=undefined
Почему дальше: санитайзеры требуют пересборки с флагами — если такой возможности нет (например, готовый чужой бинарник или библиотека), есть альтернатива без пересборки.
8. valgrind --tool=memcheck как альтернатива
Valgrind ищет ошибки работы с памятью и утечки без пересборки программы с особыми флагами. Модуль memcheck запускает бинарник внутри собственной виртуальной машины — эмулирует каждую машинную инструкцию на лету, отслеживая состояние памяти (инициализирована/не инициализирована, выделена/освобождена), и на каждое подозрительное обращение выводит диагностику со стеком вызовов.
Эмуляция каждой инструкции в софтверной VM принципиально тяжелее, чем компиляторная инструментация ASAN (которая добавляет проверки только вокруг реальных обращений к памяти уже в нативном коде) — valgrind ощутимо медленнее санитайзеров, счёт идёт на кратное замедление, а не на проценты накладных расходов. Главное преимущество — не нужен доступ к исходникам и пересборка, поэтому его применяют на готовых бинарниках или сторонних библиотеках, где ASAN не подключить; в собственном проекте с доступом к сборке обычно предпочитают санитайзеры именно из-за скорости на CI.
Факты для карточек
- base | Какой модуль valgrind ищет ошибки памяти? —
memcheck(valgrind --tool=memcheck) - core | Почему valgrind медленнее ASAN? — эмулирует каждую машинную инструкцию в софтверной VM, а не добавляет проверки только вокруг обращений к памяти в нативном коде
- core | Когда valgrind предпочтительнее санитайзеров? — когда нет доступа к исходникам/пересборке (готовый бинарник, сторонняя библиотека)
9. Задача дня: crash.c (tasks/09_gdb)
crash.c — программа, которая падает на части входов и портит память; задача — найти
дефекты отладчиком и починить минимально, не переписывая с нуля. Ожидаемое поведение после
починки:
./crash(без аргумента) печатаетlen=5, код возврата 0;./crash <слово>печатаетlen=<длина слова>, код возврата 0;./crash ""печатаетlen=0, код возврата 0;- сборка с
-fsanitize=address,undefinedне даёт ни одного сообщения об ошибке.
Порядок работы: собрать gcc -g -O0 -fsanitize=address,undefined crash.c -o crash; под
gdb ./crash командами run, bt, frame, info locals, watch разобраться, что именно
портит память, а что приводит к падению при выходе — это разные дефекты, и падение при
выходе из программы обычно означает испорченный служебный указатель (например, порчу стека
или метаданных кучи), который проявляется только в момент разрушения объекта или выхода из
функции. Проверка — python3 grade.py 09: компилирует crash.c компилятором C (gcc) с
ASAN/UBSAN и прогоняет 5 входов; answer.txt проверяется ревью — важен ход разбора
командами отладчика, а не только итоговый результат.
Факты для карточек
- base | Какой командой собирают
crash.cдля отладки с санитайзерами? —gcc -g -O0 -fsanitize=address,undefined crash.c -o crash - base | Что печатает
./crashбез аргументов после починки? —len=5, код возврата 0 - core | Почему падение может произойти не в строке с самой ошибкой, а при выходе из программы? — испорченный служебный указатель (стек/метаданные кучи) проявляется только в момент разрушения объекта, а не в момент самой порчи
Ссылка на задачу этого дня: tasks/09_gdb — проверка python3 grade.py 09, 5 входов, ASAN/UBSAN
чистые.
Проверь себя
-
Собрал бинарник с
-g -O2и заметил, чтоnextв gdb «перепрыгивает» через строки не по порядку. В чём причина и что нужно изменить в сборке?Ответ
Оптимизатор при `-O2` переставляет и инлайнит инструкции, отладочная информация перестаёт однозначно соответствовать исходнику; нужно пересобрать с `-O0` (оставив `-g`). -
Программа падает по
SIGSEGVтолько раз в несколько дней под нагрузкой в проде. Какие две вещи нужно сделать заранее, чтобы разобрать этот краш постфактум?Ответ
Выставить `ulimit -c unlimited`, чтобы ядро сохранило core dump при падении, и не пересобирать бинарник между падением и анализом — иначе адреса в core не совпадут с бинарником и `gdb prog core` покажет несогласованный стек. -
ASAN указывает на строку записи за границу массива, а обычный gdb без санитайзеров на этом же баге падал бы совсем в другом месте кода. Почему так?
Ответ
Порча памяти повреждает чужие данные, но видимый крash может случиться значительно позже, когда программа использует уже испорченные данные в другом месте — gdb без санитайзера показывает точку симптома (где реально упало), ASAN инструментирует каждое обращение и останавливает программу прямо в момент нарушения, то есть в точке причины. -
Нужно проверить на утечки готовую стороннюю библиотеку без исходников и без возможности пересобрать её с ASAN. Какой инструмент подходит и почему не санитайзер?
Ответ
`valgrind --tool=memcheck` — он эмулирует уже готовый бинарник в софтверной VM и не требует пересборки с флагами `-fsanitize=...`, в отличие от санитайзеров, которые требуют перекомпиляции исходного кода.
Материалы
- man 1 gdb — https://man7.org/linux/man-pages/man1/gdb.1.html
- Документация GDB (sourceware) — https://sourceware.org/gdb/current/onlinedocs/gdb.html/
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
- Clang: документация AddressSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html
- man 2 setrlimit (ulimit -c / RLIMIT_CORE) — https://man7.org/linux/man-pages/man2/setrlimit.2.html
- man 7 signal (SIGSEGV) — https://man7.org/linux/man-pages/man7/signal.7.html