Files

14 KiB
Raw Permalink Blame History

Задача 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)
Проверь себя
  1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с -O2 для финальной проверки производительности?

    ОтветОптимизатор переставляет и удаляет инструкции, инлайнит вызовы и переиспользует регистры под разные переменные — отладочная информация от такой сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или неверные значения; для отладки нужна отдельная сборка `-g -O0`.
  2. Программа падает с SIGSEGV без санитайзеров, но при сборке с ASan вместо краша печатается сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?

    ОтветASan останавливает программу в момент фактического выхода за границу буфера — в точке причины; без ASan повреждённая память могла не привести к немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные использовались уже в другом месте — это типичная картина «краш далеко от причины».
  3. Чем watch var полезнее обычной точки останова (break) для поиска места, где переменная портится неожиданно?

    Ответ`break` останавливает выполнение в заданной строке кода, но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения, которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже если изменение происходит из неожиданного места (например, из-за записи за границей другого буфера, затирающей соседнюю память).