программы

Как стать властелином отладчика: помогут ELF, DWARF и много магии

294
0
7 февраля 2025
Изображение создано с помощью нейросети
программы
294
0
7 февраля 2025
Как стать властелином отладчика: помогут ELF, DWARF и много магии

Как устроены дебаггеры с точки зрения разработчика? Константин Деревцов, ведущий разработчик в команде файлового доступа в YADRO, рассказал, какие технологии лежат в основе любого популярного отладчика, а также как с помощью этих технологий реализуются привычные вещи — точки останова или step.

Особое внимание автор уделил нюансам отладки Rust-кода и поддержки Rust в дебаггерах. А если вы решили написать свой отладчик — дочитайте до конца, там будет аналитика, которая в общем виде расскажет о предстоящей работе.

Изображение создано с помощью нейросети
Из статьи вы узнаете
  • какие технологии лежат в основе дебаггера
  • как с их помощью реализуются основные функции
  • в чем особенности дебаггера на Rust
  • что понадобится для создания собственного отладчика

Сверяем понятия: что-то на эльфийском

Отладчик (дебаггер) — компьютерная программа для автоматизации процесса отладки: поиска ошибок в других программах. Программа автоматизирует процесс с помощью известных функций: breakpoints, step, просмотр переменных, перехват сигналов и так далее.

Какие бывают отладчики:

  • GDB — один из самых старых отладчиков, ведет историю с 80-х годов. Работает для UNIX-систем и поддерживает кучу архитектур.
  • rust-gdb — плагин для GDB на Python. Добавляет в GDB информацию о типах в Rust: векторах, мапах и так далее. Поставляется вместе с Rust. Так что, если вы Rust ставите через Rust up, скорее всего, этот плагин у вас уже есть.
  • LLDB — отладчик от команды LLVM. Не так хорош в Linux-системах, зато в Mac без него никуда.
  • WinDbg — отладчик для Windows.
  • BugStalker (BS) — открытый отладчик, специализирующийся на Rust. Работает под Linux.

Давайте синхронизируем наши словари, чтобы вы не запутались в понятиях, которые я упоминаю в тексте.

  • В паре с дебаггером мы будем говорить о debuggee — отлаживаемой программе.
  • x86−64 — архитектура, вокруг которой сконцентрирован материал. На других архитектурах примеры из текста могут не работать.
  • На некотором уровне абстракции процессор выполняет инструкции одну за другой, и ему надо знать, какую выполнить следующей. Program counter (PC) — регистр в х86−64, который содержит адрес следующей инструкции.
  • Stack frame — область памяти на системном стеке, которая аллоцируется, когда мы попадаем в функцию, и деаллоцируется, когда из функции выходим. Содержит локальные переменные и метаинформацию.
  • Stack pointer — указатель на вершину системного стека, располагается в регистре SP.
  • Return address (RA) — адрес возврата из текущей функции. Другими словами, это адрес инструкции, которую должен выполнить процессор после инструкции ret.
Задачу по нахождению RA мы упростим. Пусть у меня будет функция, которая может для любого значения PC вернуть адрес возврата из функции, которой принадлежит этот PC. Примерно такой функционал реализован в библиотеках типа libunwind и в дебаггерах.

Изучаем технологии в основе отладчиков

Перейдем к исследованию дебаггера. На верхушке айсберга видим технологии DWARF, PTRACE и ELF, а внизу — набор более редких технологий.

Сегодня будем говорить только о верхушке. Хорошая новость: чтобы понять большинство возможностей дебаггера, достаточно иметь представление об этих трех технологиях. Что касается «подводных» технологий, упомянутых на картинке: применяя закон Парето, можно смело сказать что 80% знаний необходимы, чтобы реализовать только 20% функционала (и не самого важного). Так что напишите в комментариях, если хотите узнать о «подводной» части.

PTRACE

PTRACE — системный вызов, который позволяет одному процессу (tracer) управлять и исследовать другой процесс (tracee). Tracer может:

  • запустить, остановить, перезапустить tracee,
  • посмотреть или изменить память tracee,
  • посмотреть или изменить состояние регистров,
  • отловить или внедрить сигналы в tracee.
PTRACE применяется не только в дебаггерах. Я думаю, вы работали с инструментами strace или perf — они построены вокруг PTRACE.

PTRACE умеет делать много всего через один системный вызов. Вся мощь системного вызова PTRACE заключается в первом аргументе, который называется REQUEST. Он описывает, что PTRACE может сделать.

Аргумент pid в коде выше это — process id tracee. Аргументы addr и data необязательные. Так, например, если хотим что-то записать в виртуальную память tracee, в adress выставляем адрес, куда пишем, а в data — данные на запись.

Все дебаггеры работают с PTRACE примерно одинаково. На этой блок-схеме изображена работа дебаггера с PTRACE.

Сначала подключим наш дебаггер (tracer) к debuggee (tracee). Далее попадаем в цикл, где с помощью WAITPID отлавливаем события. Как правило, под событием подразумевается остановка tracee и получение tracer некоторого сигнала OS. Например, если tracee встанет на точку останова, то в tracer придет сигнал SIGTRAP, а tracee при этом будет остановлен. Но об этом я расскажу чуть позже.

Когда tracee остановлен, а WAITPID вернул tracer сигнал, пользователь дебаггера может приступить к интроспекции кода: в дебаггере появляется promt, где можно выполнить команды для просмотра переменных, стека вызовов, step по коду и так далее.

Когда процесс интроспекции закончен, пользователь вводит команду continue (или иным другим способом продолжает выполнение debuggee). Тогда внутри дебаггера происходит вызов PTRACE с реквестом PTRACE_CONT, и выполнение tracee продолжается, пока не случится следующее событие.

ELF и DWARF

Следующая технология — ELF (Executable and Linkable Format). Это формат исполняемых двоичных файлов.

ELF предполагает наличие секций в файле. Каждая секция — это данные, которые необходимы для компиляции, выполнения или отладки программы. ELF не описывает, что именно находится в конкретной секции, но описывает их формат. Ниже представлены некоторые секции из типичного ELF-файла:

Возможно, слева вы увидели несколько знакомых секций. Так, например, в секции .text находится список инструкций — по сути, программный код. В секции .rodata мы найдем константы, а в .note — произвольную информацию.

В правом облаке все названия имеют префикс .debug. Совокупность этих секций и есть debug-информация приложения. Это то, что генерирует компилятор и что используется дебаггерами. Для описания этих секций существует специальный стандарт.

DWARF (Debugging With Arbitrary Record Formats) — стандарт, описывающий формат .debug_xxx секций в ELF.

Мы рассмотрим две секции — .debug_info и .debug_line. В других секциях чаще всего будут находиться индексы для ускорения поиска или данные, на которые может ссылаться секция .debug_info — например, таблицы строк.

.debug_info

Эта секция состоит из структур, которые называются debug information entry, далее — DIE.

DWARF использует структуру данных DIE для представления переменных, функций, типов и других сущностей из которых состоят наши программы. У каждой DIE есть тег с информацией о том, что именно она описывает (DW_TAG_variable, DW_TAG_pointer_type, DW_TAG_subprogram) и набор атрибутов, дополняющих описание конкретной сущности. DIE находятся в зависимости друг от друга и формируют древовидную структуру.

Рассмотрим небольшой пример:

Это вывод программы DWARFDUMP для небольшого файла, в котором есть функция main и две переменных a и b. Корневая DIE имеет тег DW_TAG_subprogram — то есть, описывает некоторую функцию. Рассмотрим ее атрибуты:

  • DW_AT_name — имя функции, в данном случае — main.
  • DW_AT_decl_file и DW_AT_decl_line — файл и номер строки исходного кода, в которой находится функция.
  • DW_AT_low_pc и DW_AT_high_pc — описывают range-инструкции, которые в совокупности составляют тело функции и ее пролог.

У этой DIE есть два потомка. Оба описывают некоторые переменные, что подсказывает тег DW_TAG_variable. По атрибуту DW_AT_name мы понимаем, что это переменные с именем a и b. У переменной a есть атрибут DW_AT_type, в котором находится ссылка на другую DIE, она описывает тип переменной a. Атрибут DW_AT_location позволяет дебаггеру получить значение переменной (бинарное представление).

Получить байты — нетривиальная задача, потому что на разных этапах выполнения программы бинарное представление может находиться в разных местах. Например, данные переменной, а могут быть аллоцированны на стеке, затем перемещены в регистр, а затем опять возвращены на стек.

Чтобы понять, где же находятся актуальные данные и нужен атрибут DW_AT_location. В нем записаны инструкции для специальной виртуальной машины, которая реализовывается каждым дебаггером. Эта виртуальная машина принимает на вход программу (в виде набора инструкций из атрибута DW_AT_location) и текущее значение PC, а на выход отдает байтовое представление нашей переменной.

.debug_line

Следующая секция debug_line попроще, она содержит маппинг адресов инструкций из секции .text на номера строк в исходном коде программы. Выглядит это так:

В первом столбце видим адрес инструкции. Во втором столбце — номер строки и номер столбца в исходном коде, которому эта инструкция соответствует. И в третьем столбце — набор флагов. Например, флаг ns значит new statement. Он сообщает дебаггеру, что тот может спокойно поставить сюда точку останова.

Далее поговорим об алгоритмах.

Реализуем функции отладчика

Теперь, когда мы познакомились с технологиями в основе отладчика, мы можем реализовать известные алгоритмы дебаггера. В примерах я буду использовать небольшую Rust-программу.

step instruction

Это довольно простая функция которая перемещает debugee ровно на одну инструкцию вперед. Рассмотрим пример:

Реализация step instruction тривиальна — достаточно сделать PTRACE_SINGLESTEP.

breakpoint

На очереди самый сложный алгоритм — breakpoint.

Как это реализовано?

  1. Для начала надо поставить точку останова. На вход команды break поступает строка, в нашем случае main.rs.3. Но точка останова ставится не на строку, а на инструкцию, так что, используя секцию .debug_lines, находим адрес инструкции для строки main.rs:3.
  2. Далее заменяем найденную инструкцию на волшебную инструкцию INT3 (при помощи PTRACE_POKEDATA мы можем писать и в секцию .text). INT3 — это так называемая программная точка останова. Когда выполнение дойдет до этой инструкции, tracer отловит SIGTRAP с помощью WAITPID, а сам tracee остановится.

Программа остановилась, пользователь сделал интроспекцию, теперь нажимаем continue, чтобы продолжить выполнение программы.

Проблема в том, что на данный момент у нас невалидный программный код, потому что валидную инструкцию мы заменили на INT3. Проведем некоторые манипуляции.

  1. Вернем изначальную инструкцию на место INT3.
  2. Сделаем шаг назад. Как вы помните, у нас есть регистр PC, и если мы хотим, чтобы процессор выполнил предыдущую инструкцию, достаточно его декрементировать.
  3. Выполним ровно одну инструкцию (при помощи PTRACE_SINGLESTEP).
  4. И снова запишем INT3 на место. Теперь можем продолжать выполнение debuggee (используем PTRACE_CONT).

stepover

Stepover — шаг на следующую строку в исходном коде без захода внутрь функций. Stepover похож на step, хотя реализуется совсем по-другому.

  1. В .debug_info находим addrmin и addrmax — это адреса первой и последней инструкций для текущей функции.
  2. Из .debug_lines находим все строки так, чтобы addrmin < addrLINE < addrmax
  3. Ставим breakpoint на каждой из строк.
  4. Ставим breakpoint на return address.
  5. Запускаем debuggee, пока не случится попадания в одну из наших точек останова,
  6. step over завершен, не забываем подчистить «временные» точки останова.

var

var (или print в GDB) — выводит на экран значение переменной.

Как это реализовано?

  1. Ищем DIE для переменной в секции .debug_info.
  2. Ищем DIE для типа переменной, ссылка на эту DIE находится в DIE-переменной.
  3. Исследуем тип, получаем необходимую метаинформацию (DWARF-тип, выравнивание, состав полей и прочее).
  4. Вычисляем бинарное представление переменной (используем атрибут DW_AT_location).
  5. Собираем все вместе: интерпретируем сырой набор байт при помощи метаинформации о типе.

На этом первая часть статьи заканчивается — начинается более специализированная вторая часть, которая ближе к дебаггингу Rust-приложений.

Выполняем дебаггинг Rust-приложений

С дебаггином на Rust особых проблем нет: многие отладчики работают с ним более-менее хорошо. Однако каждый разработчик на Rust назовет нюансы, которые усложняют работу. Я выделил три проблемы и расположил их в порядке возрастания важности.

Rust first class citizen

Представьте, что у нас есть вектор:

Посмотрим, что мы сможем сделать с ним в rust-gdb:

Как видите, хотя GDB и понимает, что перед ним что-то похожее на массив, он все же не знает, как взять элемент по индексу. Взять «слайс» GDB также не может, парсер команд не справляется.

Теперь посмотрим на BS:

В чем же тут магия? Дело в том, что плагины для GDB, каким является и Rust GDB, имеют ограниченный функционал. Соответственно, они вносят информацию о типе и позволяют GDB вывести вектор на экран. Но рассказать ему о том, что с вектором можно обращаться как с массивом, либо докинуть информацию в парсер команд, такие плагины не могут. Что ж, будем считать что это довольно минорная проблема.

Переменные thread_local

Проблема чуть посерьезнее — переменные thread_local. К сожалению, дебаг таких переменных в rust-gdb невозможен.

Создадим переменную thread_local TLS1:

Попытаемся вывести ее на экран при помощи rust-gdb:

Печально, воспользуемся BS:

BS с таким справляется. Дело в том, что rust-gdb не знает, что thread_local — это на самом деле макрос, который разворачивает описанную в нем переменную TLS1 и генерирует для нее другое имя, так что поиск по имени для переменной TLS1 оказывается не совсем тривиальным.

Дебаггинг асинхронного кода

Ну и совсем уж печально дела обстоят с отладкой асинхронного кода. Для примера рассмотрим tokio tcp-echo сервер с задержкой в 20 секунд перед возвратом ответа.

Представим что мы запустили приложение в rust-gdb, отправили запрос, остановили сервер и теперь хотим посмотреть, например, backtrace.

Я думаю, вы догадываетесь, что мы увидим: это будет стек вызовов на 50+ строк, который состоит из функций, принадлежащих внутренностям tokio. К сожалению, такой backtrace вряд ли будет полезен кому-то, кроме разработчиков tokio. Прикладной разработчик хочет увидеть состояние программы, понять, сколько коннектов обрабатывается на сервере, и в каком состоянии они находятся.

backtrace с внутренностями tokio

Проблемы тут две:

  • Future в Rust разворачиваются компилятором в конечный автомат, по которому затем генерируется debug-информация. В исходном коде мы видим красивые async/await вместо конечного автомата, так что происходит расхождение: debug-информация генерируется для одного кода, а видим мы другой.
  • Как было сказано выше, в стеке вызовов мы видим состояние tokio runtime, а не состояние приложения.

Эту проблему можно решить. Так, BS представляет семейство async-команд которые позволяют получить «асинхронный backtrace» или сделать «асинхронные step» (подробнее тут).

Посмотрим на возможности async backtrace:

Какую информацию мы можем почерпнуть из такого трейса?

  • Поток 1 заблокирован на future main, которая ожидает результат работы future, делающий accept входящих коннектов.
  • Потоки 2 и 3 являются асинхронными воркерами.
  • В системе в настоящий момент есть спящая tokio task c id 4 (future на вершине стека спит уже как 7 секунд).

Подробно о реализации написано читайте здесь.

К сожалению, наличие async backtrace и async step — далеко не весь функционал, который нужен для отладки асинхронного кода. Например, просмотр локальных переменных и создание точек останова будет работать плохо в любом дебаггере.

Что стоит за собственным отладчиком

BS содержит 80 000 строк кода, в то время как GDB — более 3 000 000 строк. Такая разница в SLOC объяснима: GDB написан на С++, в нем куча легаси и архитектуры, которую нужно поддерживать.

При написании дебаггера не обойтись без unsafe. Я насчитал 33 unsafe-блока в BS, в том числе:

  • libc,
  • сырые указатели,
  • интерпретацию памяти,
  • разбор ELF-секций.

Из внешних зависимостей для написания дебаггера понадобится только libc. Если не хотите писать размотку стека, стоит использовать libunwind. Из необходимой экосистемы Rust возьмите Gimli — библиотеку для парсинга DWARF.

Чтобы использовать PTRACE и прочие SYSCALL, можно взять библиотеку Nix, которая предоставляет безопасные обертки.

Наверх
Будь первым, кто оставит комментарий