# 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=...`, в отличие от санитайзеров, которые требуют перекомпиляции исходного кода.
## Материалы - 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