Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+254
View File
@@ -0,0 +1,254 @@
# 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