14 KiB
Задача 09 — отладка: найти и исправить дефекты (gdb)
В crash.c лежит программа, которая падает на части входов и портит память.
Задача — не переписать её с нуля, а найти дефекты отладчиком и починить минимально.
Ожидаемое поведение после починки
./crash(без аргумента) печатаетlen=5и завершается с кодом 0;./crash <слово>печатаетlen=<длина слова>и завершается с кодом 0;./crash ""печатаетlen=0и завершается с кодом 0;- сборка с
-fsanitize=address,undefinedне даёт ни одного сообщения об ошибке.
Факты для карточек
- base | Что печатает
./crashбез аргументов после починки? —len=5, код возврата 0 - base | Что печатает
./crash ""после починки? —len=0, код возврата 0 - core | Какие два санитайзера должны не давать сообщений после починки? — ASan и UBSan (
-fsanitize=address,undefined)
Почему дальше: чтобы вообще видеть номера строк и имена переменных в отладчике, а не голый ассемблер, программу нужно собрать определённым образом — с этого и начинается разбор.
Сборка с отладочной информацией и санитайзерами
gcc -g -O0 -fsanitize=address,undefined crash.c -o crash
-g встраивает в бинарник отладочную информацию в формате DWARF — таблицу соответствия
машинных адресов номерам строк исходника, именам переменных, их типам и границам функций.
Именно из DWARF gdb берёт всё, что показывает человеку: bt печатает имена функций и строки
вместо голых адресов, print x знает тип x и умеет напечатать его по правилам этого типа
(структуру — по полям, массив — по элементам), list показывает исходный код вокруг текущей
точки. Без -g DWARF-секций в бинарнике нет, и gdb видит только адреса и ассемблер.
-O0 отключает оптимизации компилятора: оптимизатор переставляет и удаляет инструкции,
инлайнит функции и переиспользует один регистр под несколько переменных с непересекающимся
временем жизни — из-за этого отладочная информация от -O2 часто не соответствует
однозначно исходному коду (строки «прыгают», переменная в print показывает не то значение,
потому что регистр уже переиспользован под другую переменную). Отсюда правило: для отладки
всегда пересобирают отдельно с -g -O0, а не отлаживают прод-сборку с оптимизациями.
-fsanitize=address,undefined — это ASan и UBSan вместе, инструментирование на этапе
компиляции, а не отдельный инструмент поверх готового бинарника:
- ASan окружает каждый выделенный блок памяти недоступными «красными зонами» и ведёт теневую карту состояния памяти — ловит выход за границы буфера, use-after-free и двойное освобождение прямо в момент обращения, с точным стеком, а не когда испорченные данные позже вызовут крах в случайном другом месте;
- UBSan инструментирует места, где поведение по стандарту C не определено — знаковое
переполнение, сдвиг за пределы разрядности типа, разыменование
NULLс невалидным типом — и печатает конкретную строку кода нарушения.
Ловушки
- Собрать без
-gи пытаться понять крах по голомуbtс адресами вместо строк → отладка вслепую по ассемблеру, не соответствует задаче «найти минимальными правками». - Отлаживать сборку с
-O2→ строки вbt/printне совпадают с реальным местом бага из-за инлайнинга и переиспользования регистров → ложный след при поиске дефекта. - Пропустить пересборку с
-fsanitize=address,undefinedперед финальной проверкой → баг, который не падает явным креша при обычном запуске (например, чтение за границей внутри тем же выделенного блока), останется незамеченным доgrade.py.
Факты для карточек
- base | Что даёт флаг
-gпри сборке для gdb? — отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных - core | Почему для отладки собирают с
-O0, а не с-O2? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику - core | Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение? — все три, в момент обращения к памяти, а не постфактум
- deep | Что именно ловит UBSan, в отличие от ASan? — неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью
Почему дальше: раз отладочная информация уже в бинарнике, нужно знать конкретные команды gdb, которыми по ней ходят при разборе краша.
Команды gdb для разбора краша
Рабочий цикл: gdb ./crash, затем run [аргумент] — передаёт управление процессу; если
процесс получает сигнал (обычно SIGSEGV от порчи памяти или abort от санитайзера), gdb сам
останавливается на инструкции, вызвавшей сбой.
bt— стек вызовов, цепочка кадров от текущей функции доmain; первое, на что смотрят после остановки — показывает, в какой функции и через какие вызовы дошли до краша.frame N— переключает контекстprint/listна конкретный кадр стека изbt, чтобы посмотреть локальные переменные вызывающей функции, а не только текущей.info locals— печатает все локальные переменные текущего кадра с их значениями по информации из DWARF.watch var— аппаратная точка наблюдения: останавливает выполнение при каждом изменении значения переменной. Нужна, когда переменная меняется как будто сама по себе из другого места кода (типичный симптом переполнения буфера, которое затирает соседнюю память) — безwatchпришлось бы расставлять точки останова по подозрению вручную.print x— печатает текущее значениеx, по типу из DWARF (структуру — по полям).
Санитайзер добавляет отдельный слой: при нарушении (ASan/UBSan) программа останавливается
сама, печатая стек и описание нарушения ещё до того, как повреждение памяти успело привести
к крашу в другом, не связанном с причиной месте — это часто быстрее, чем ловить последствия
вручную в gdb по голому SIGSEGV.
Факты для карточек
- base | Какая команда gdb показывает цепочку вызовов до краша? —
bt - core | Чем
watch varотличается от обычногоbreak? — останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода - core | Зачем нужен
frame Nпослеbt? — переключает контекстprint/info localsна конкретный кадр стека, чтобы смотреть переменные не только текущей функции
Отчёт и проверка
Исправить crash.c (минимальные правки, стиль сохранить). Заполнить answer.txt: сколько
дефектов нашёл, какие именно, какими командами отладчика это подтвердил (по шагам), почему
падало именно так.
Проверка: python3 grade.py 09 — компилирует crash.c (компилятор C, gcc) с ASAN/UBSAN и
прогоняет 5 входов. answer.txt проверяется ревью (это часть оценки: важен ход разбора, а
не только результат).
Факты для карточек
- base | Сколько входов прогоняет
grade.py 09? — 5 - base | Каким компилятором собирается
crash.cна проверке? — gcc (компилятор C)
Проверь себя
-
Почему для отладки нельзя использовать тот же бинарник, что уже собран с
-O2для финальной проверки производительности?Ответ
Оптимизатор переставляет и удаляет инструкции, инлайнит вызовы и переиспользует регистры под разные переменные — отладочная информация от такой сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или неверные значения; для отладки нужна отдельная сборка `-g -O0`. -
Программа падает с
SIGSEGVбез санитайзеров, но при сборке с ASan вместо краша печатается сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?Ответ
ASan останавливает программу в момент фактического выхода за границу буфера — в точке причины; без ASan повреждённая память могла не привести к немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные использовались уже в другом месте — это типичная картина «краш далеко от причины». -
Чем
watch varполезнее обычной точки останова (break) для поиска места, где переменная портится неожиданно?Ответ
`break` останавливает выполнение в заданной строке кода, но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения, которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже если изменение происходит из неожиданного места (например, из-за записи за границей другого буфера, затирающей соседнюю память).