Files
eduplan-cpp-eltex/lessons/D3_gdb.md
T

255 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
чистые.
<details>
<summary>Проверь себя</summary>
1. Собрал бинарник с `-g -O2` и заметил, что `next` в gdb «перепрыгивает» через строки не по
порядку. В чём причина и что нужно изменить в сборке?
<details><summary>Ответ</summary>Оптимизатор при `-O2` переставляет и инлайнит инструкции,
отладочная информация перестаёт однозначно соответствовать исходнику; нужно пересобрать с
`-O0` (оставив `-g`).</details>
2. Программа падает по `SIGSEGV` только раз в несколько дней под нагрузкой в проде. Какие две
вещи нужно сделать заранее, чтобы разобрать этот краш постфактум?
<details><summary>Ответ</summary>Выставить `ulimit -c unlimited`, чтобы ядро сохранило core
dump при падении, и не пересобирать бинарник между падением и анализом — иначе адреса в
core не совпадут с бинарником и `gdb prog core` покажет несогласованный стек.</details>
3. ASAN указывает на строку записи за границу массива, а обычный gdb без санитайзеров на этом
же баге падал бы совсем в другом месте кода. Почему так?
<details><summary>Ответ</summary>Порча памяти повреждает чужие данные, но видимый крash
может случиться значительно позже, когда программа использует уже испорченные данные в
другом месте — gdb без санитайзера показывает точку симптома (где реально упало), ASAN
инструментирует каждое обращение и останавливает программу прямо в момент нарушения,
то есть в точке причины.</details>
4. Нужно проверить на утечки готовую стороннюю библиотеку без исходников и без возможности
пересобрать её с ASAN. Какой инструмент подходит и почему не санитайзер?
<details><summary>Ответ</summary>`valgrind --tool=memcheck` — он эмулирует уже готовый
бинарник в софтверной VM и не требует пересборки с флагами `-fsanitize=...`, в отличие от
санитайзеров, которые требуют перекомпиляции исходного кода.</details>
</details>
## Материалы
- 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