
Как стать властелином отладчика: помогут 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.
Изучаем технологии в основе отладчиков
Перейдем к исследованию дебаггера. На верхушке айсберга видим технологии DWARF, PTRACE и ELF, а внизу — набор более редких технологий.

Сегодня будем говорить только о верхушке. Хорошая новость: чтобы понять большинство возможностей дебаггера, достаточно иметь представление об этих трех технологиях. Что касается «подводных» технологий, упомянутых на картинке: применяя закон Парето, можно смело сказать что 80% знаний необходимы, чтобы реализовать только 20% функционала (и не самого важного). Так что напишите в комментариях, если хотите узнать о «подводной» части.
PTRACE
PTRACE — системный вызов, который позволяет одному процессу (tracer) управлять и исследовать другой процесс (tracee). Tracer может:
- запустить, остановить, перезапустить tracee,
- посмотреть или изменить память tracee,
- посмотреть или изменить состояние регистров,
- отловить или внедрить сигналы в tracee.
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.
Как это реализовано?
- Для начала надо поставить точку останова. На вход команды break поступает строка, в нашем случае main.rs.3. Но точка останова ставится не на строку, а на инструкцию, так что, используя секцию .debug_lines, находим адрес инструкции для строки main.rs:3.
- Далее заменяем найденную инструкцию на волшебную инструкцию INT3 (при помощи PTRACE_POKEDATA мы можем писать и в секцию .text). INT3 — это так называемая программная точка останова. Когда выполнение дойдет до этой инструкции, tracer отловит SIGTRAP с помощью WAITPID, а сам tracee остановится.
Программа остановилась, пользователь сделал интроспекцию, теперь нажимаем continue, чтобы продолжить выполнение программы.
Проблема в том, что на данный момент у нас невалидный программный код, потому что валидную инструкцию мы заменили на INT3. Проведем некоторые манипуляции.
- Вернем изначальную инструкцию на место INT3.
- Сделаем шаг назад. Как вы помните, у нас есть регистр PC, и если мы хотим, чтобы процессор выполнил предыдущую инструкцию, достаточно его декрементировать.
- Выполним ровно одну инструкцию (при помощи PTRACE_SINGLESTEP).
- И снова запишем INT3 на место. Теперь можем продолжать выполнение debuggee (используем PTRACE_CONT).
stepover
Stepover — шаг на следующую строку в исходном коде без захода внутрь функций. Stepover похож на step, хотя реализуется совсем по-другому.
- В .debug_info находим addrmin и addrmax — это адреса первой и последней инструкций для текущей функции.
- Из .debug_lines находим все строки так, чтобы addrmin < addrLINE < addrmax
- Ставим breakpoint на каждой из строк.
- Ставим breakpoint на return address.
- Запускаем debuggee, пока не случится попадания в одну из наших точек останова,
- step over завершен, не забываем подчистить «временные» точки останова.
var
var (или print в GDB) — выводит на экран значение переменной.
Как это реализовано?
- Ищем DIE для переменной в секции .debug_info.
- Ищем DIE для типа переменной, ссылка на эту DIE находится в DIE-переменной.
- Исследуем тип, получаем необходимую метаинформацию (DWARF-тип, выравнивание, состав полей и прочее).
- Вычисляем бинарное представление переменной (используем атрибут DW_AT_location).
- Собираем все вместе: интерпретируем сырой набор байт при помощи метаинформации о типе.
На этом первая часть статьи заканчивается — начинается более специализированная вторая часть, которая ближе к дебаггингу 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. Прикладной разработчик хочет увидеть состояние программы, понять, сколько коннектов обрабатывается на сервере, и в каком состоянии они находятся.

Проблемы тут две:
- 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-секций.
Чтобы использовать PTRACE и прочие SYSCALL, можно взять библиотеку Nix, которая предоставляет безопасные обертки.