Files

26 KiB
Raw Permalink Blame History

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 чистые.

Проверь себя
  1. Собрал бинарник с -g -O2 и заметил, что next в gdb «перепрыгивает» через строки не по порядку. В чём причина и что нужно изменить в сборке?

    ОтветОптимизатор при `-O2` переставляет и инлайнит инструкции, отладочная информация перестаёт однозначно соответствовать исходнику; нужно пересобрать с `-O0` (оставив `-g`).
  2. Программа падает по SIGSEGV только раз в несколько дней под нагрузкой в проде. Какие две вещи нужно сделать заранее, чтобы разобрать этот краш постфактум?

    ОтветВыставить `ulimit -c unlimited`, чтобы ядро сохранило core dump при падении, и не пересобирать бинарник между падением и анализом — иначе адреса в core не совпадут с бинарником и `gdb prog core` покажет несогласованный стек.
  3. ASAN указывает на строку записи за границу массива, а обычный gdb без санитайзеров на этом же баге падал бы совсем в другом месте кода. Почему так?

    ОтветПорча памяти повреждает чужие данные, но видимый крash может случиться значительно позже, когда программа использует уже испорченные данные в другом месте — gdb без санитайзера показывает точку симптома (где реально упало), ASAN инструментирует каждое обращение и останавливает программу прямо в момент нарушения, то есть в точке причины.
  4. Нужно проверить на утечки готовую стороннюю библиотеку без исходников и без возможности пересобрать её с ASAN. Какой инструмент подходит и почему не санитайзер?

    Ответ`valgrind --tool=memcheck` — он эмулирует уже готовый бинарник в софтверной VM и не требует пересборки с флагами `-fsanitize=...`, в отличие от санитайзеров, которые требуют перекомпиляции исходного кода.

Материалы