Привет, Хабр! Меня зовут Константин, я работаю в команде файлового доступа в YADRO. Помимо основной работы, я пишу в open source, работаю над несколькими проектами — в том числе над дебаггером BugStalker (BS) на Rust.
В этой статье речь пойдет о разработке дебаггеров. Расскажу, какие технологии лежат в основе любого популярного отладчика и как с их помощью реализуются точки останова или функции 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
может сделать.
Действие | Аргумент |
Подключить tracee |
|
Отключить tracee |
|
Приостановить tracee |
|
Заставить tracee выполнить ровно одну инструкцию |
|
Продолжить выполнение tracee |
|
Прочитать или записать в память |
|
Прочитать или записать в регистры |
|
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
Аргумент pid
в коде выше это — process id tracee. Аргументы addr
и data
необязательные. Так, например, если хотим что-то записать в виртуальную память tracee, в adress
выставляем адрес, куда пишем, а в data
— данные на запись.
Все дебаггеры работают с PTRACE примерно одинаково. На этой блок-схеме изображена работа дебаггера с PTRACE.

Сначала подключим наш дебаггер (tracer) к debuggee (tracee). Далее попадаем в цикл, где с помощью WAITPID отлавливаем события. Как правило, под событием подразумевается остановка tracee и получение tracer некоторого сигнала OS. Например, если tracee встанет на точку останова, то waitpid вернет 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 на номера строк в исходном коде программы. Выглядит это так:
0x00001149 [ 5,52] NS uri: "/debugee.c"
0x00001158 [ 6, 2] NS PE
0x00001178 [ 7, 1] NS
0x0000117b [ 9,50] NS
0x00001189 [ 10,11] NS
0x00001191 [ 11, 1]
0x00001193 [ 13,12]
0x000011c6 [ 18, 9] NS
В первом столбце видим адрес инструкции. Во втором столбце — номер строки и номер столбца в исходном коде, которому эта инструкция соответствует. И в третьем столбце — набор флагов. Например, флаг ns значит new statement. Он сообщает дебаггеру, что тот может спокойно поставить сюда точку останова.
Далее поговорим об алгоритмах.
Реализуем функции отладчика
Теперь, когда мы познакомились с технологиями в основе отладчика, мы можем реализовать известные алгоритмы дебаггера. В примерах я буду использовать небольшую Rust-программу.
fn print_hello(print_num: u32) {
for _ in 0..print_num {
println!("Hello, world!");
}
}
fn main() {
let print_num = 3;
print_hello(print_num);
}
step instruction
Это довольно простая функция которая перемещает debugee ровно на одну инструкцию вперед. Рассмотрим пример:
(bs) break main.rs:8
(bs) run
8 let print_num = 3;
(bs) source asm
...
0x70B0 pushq %rax
0x70B1 movl $3, 4(%rsp)
0x70B9 movl $3, %edi
...
(bs) stepi
(bs) source asm
...
0x70B0 pushq %rax
0x70B1 movl $3, 4(%rsp)
0x70B9 movl $3, %edi
Пример работы step instruction
Реализация step instruction
тривиальна — достаточно сделать PTRACE_SINGLESTEP
.
breakpoint
На очереди самый сложный алгоритм — breakpoint
.
(bs) break main.rs:3
New breakpoint 1 at 0x...: main.rs:3
(bs) run
Hit breakpoint 1 at ...
3 println!("Hello, world!");
(bs) continue
Hello world!
Hit breakpoint 1 at ...
3 println!("Hello, world!");
(bs) continue
Hello world!
Hit breakpoint 1 at ...
3 println!("Hello, world!");
(bs) continue
Hello world!
Program exit with code: 0
Пример работы 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
).
step
Следующая функция дебаггера — это step, шаг на следующую строку в исходном коде с заходом внутрь функций.
(bs) break main.rs:8
(bs) run
Hit breakpoint 1 at ...
8 let print_num = 3;
(bs) step
9 print_hello(print_num);
(bs) step
::print_hello at main.rs:2
2 for _ in 0..print_num {
Пример работы step
Как это реализовано?
Используя текущее значение PC в
.debug_lines
, находим N— номер текущей строки в исходном коде.
Выполняем
step instruction
.Находим N — новый номер строки, появившийся после выполнения step instruction.
Если N = N
, переходим на шаг 2.
stepover
Stepover — шаг на следующую строку в исходном коде без захода внутрь функций. Stepover похож на step, хотя реализуется совсем по-другому.
(bs) break main.rs:8
(bs) run
Hit breakpoint 1 at ...
8 let print_num = 3;
(bs) stepover
9 print_hello(print_num);
(bs) stepover
Hello world! Hello world! Hello world!
10 }
Пример работы stepover
В
.debug_info
находимaddrmin
иaddrmax
— это адреса первой и последней инструкций для текущей функции.Из .debug_lines находим все строки так, чтобы
addrmin
<addrLINE
<addrmax
Ставим
breakpoint
на каждой из строк.Ставим
breakpoint
наreturn address
.Запускаем debuggee, пока не случится попадания в одну из наших точек останова.
step over
завершен, не забываем подчистить «временные» точки останова.
var
var
(или print
в GDB) — выводит на экран значение переменной.
(bs) break main.rs:9
(bs) run
Hit breakpoint 1 at ...
9 print_hello(print_num);
(bs) var print_num
print_num = u32(3)
Пример работы var
Как это реализовано?
Ищем
DIE
для переменной в секции.debug_info
.Ищем
DIE
для типа переменной, ссылка на этуDIE
находится в DIE-переменной.Исследуем тип, получаем необходимую метаинформацию (DWARF-тип, выравнивание, состав полей и прочее).
Вычисляем бинарное представление переменной (используем атрибут
DW_AT_location
).Собираем все вместе: интерпретируем сырой набор байт при помощи метаинформации о типе.
На этом первая часть статьи заканчивается — начинается более специализированная вторая часть, которая ближе к дебаггингу Rust-приложений.
Выполняем дебаггинг Rust-приложений
С дебаггином на Rust особых проблем нет: многие отладчики работают с ним более-менее хорошо. Однако каждый разработчик на Rust назовет нюансы, которые усложняют работу. Я выделил три проблемы и расположил их в порядке возрастания важности.
Rust first class citizen
Представьте, что у нас есть вектор:
let vec1 = vec![1, 2];
Посмотрим, что мы сможем сделать с ним в rust-gdb:
(gdb) print vec1
$1 = Vec(size=2) = {1, 2}
(gdb) print vec1[1]
Cannot subscript non-array type
(gdb) print vec1[1..]
']' expected
Как видите, хотя GDB и понимает, что перед ним что-то похожее на массив, он все же не знает, как взять элемент по индексу. Взять «слайс» GDB также не может, парсер команд не справляется.
Теперь посмотрим на BS:
(bs) var vec1
vec1 = Vec<i32> {
buf: [i32] {
0: i32(1)
1: i32(2)
}
cap: usize(2)
}
(bs) var vec1[1]
i32(2)
(bs) print vec1[1..]
vec1 = Vec<i32> {
buf: [i32] {
1: i32(2)
}
cap: usize(2)
}
В чем же тут магия? Дело в том, что плагины для GDB, каким является и Rust GDB, имеют ограниченный функционал. Соответственно, они вносят информацию о типе и позволяют GDB вывести вектор на экран. Но рассказать ему о том, что с вектором можно обращаться как с массивом, либо докинуть информацию в парсер команд, такие плагины не могут. Что ж, будем считать что это довольно минорная проблема.
Thread local переменные
Проблема чуть посерьезнее — thread local
. К сожалению, дебаг таких переменных в rust-gdb
невозможен.
Создадим thread local переменную TLS_1
:
thread_local! {
static TLS_1: std::cell::Cell<i32> = std::cell::Cell::new(0);
}
Попытаемся вывести ее на экран при помощи rust-gdb
:
(gdb) print TLS_1
Attempt to use a type name as an expression
Печально, воспользуемся BS:
(bs) var TLS_1
vars::TLS_1::{constant#0}::{closure#1}::VAL = Cell<i32>(0)
BS с таким справляется. Дело в том, что rust-gdb
не знает, что thread_local
— это на самом деле макрос, который разворачивает описанную в нем переменную TLS_1
и генерирует для нее другое имя, так что поиск по имени для переменной TLS_1
оказывается не совсем тривиальным.
Дебаггинг асинхронного кода
Ну и совсем уж печально дела обстоят с отладкой асинхронного кода. Для примера рассмотрим tokio tcp-echo
сервер с задержкой в 20 секунд перед возвратом ответа.
#[tokio::main(worker_threads = 2)]async fn main() -> Result<(), Box<dyn Error>> {let listener = TcpListener::bind(...).await?;
loop {let (mut socket, _) = listener.accept().await?;
tokio::spawn(async move {let mut buf = vec![0; 1024];
loop {let n = socket.read(&mut buf).await; sleep(Duration::from_secs(20)).await; socket.write_all(&buf[0..n]).await;}
});}
}
tcp-echo сервер
Представим что мы запустили приложение в rust-gdb
, отправили запрос, остановили сервер и теперь хотим посмотреть, например, backtrace
.
Я думаю, вы догадываетесь, что мы увидим: это будет стек вызовов на 50+ строк, который состоит из функций, принадлежащих внутренностям tokio
. К сожалению, такой backtrace
вряд ли будет полезен кому-то, кроме разработчиков tokio
. Прикладной разработчик хочет увидеть состояние программы, понять, сколько коннектов обрабатывается на сервере, и в каком состоянии они находятся.

Проблемы тут две:
Future
в Rust разворачиваются компилятором в конечный автомат, по которому затем генерируется debug-информация. В исходном коде мы видим красивыеasync/await
вместо конечного автомата, так что происходит расхождение: debug-информация генерируется для одного кода, а видим мы другой.Как было сказано выше, в стеке вызовов мы видим состояние
tokio runtime
, а не состояние приложения.
Эту проблему можно решить. Так, BS представляет семейство async-команд которые позволяют получить «асинхронный backtrace» или сделать «асинхронные step» (подробнее тут).
Посмотрим на возможности async backtrace
:
(bs) async backtrace all
Thread #1 (pid: 28406) block on:
#0 async fn tokio_tcp::main suspended at await point 1
#1 async fn tokio::net::tcp::listener::accept suspended at await point 0
Async worker #2 (pid: 28796, local queue length: 0)
Async worker #3 (pid: 28797, local queue length: 0)
Idle tasks:
Task: 4
#0 async fn tokio_tcp::main::{async_block#0} suspended at await point 1
#1 sleep future, sleeping 7 seconds from now
Какую информацию мы можем почерпнуть из такого трейса?
Поток 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
и прочие системные вызовы, можно взять библиотеку Nix, которая предоставляет безопасные обертки.
Если остались вопросы о создании отладчика, пишите в комментарии — расскажу больше о процессе.