Как стать автором
Обновить
545.9
YADRO
Тут про железо и инженерную культуру

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

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров4K

Привет, Хабр! Меня зовут Константин, я работаю в команде файлового доступа в 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

PTRACE_TRACEME, PTRACE_ATTACH, PTRACE_SEIZE

Отключить tracee

PTRACE_DETACH

Приостановить tracee

PTRACE_INTERUPT

Заставить tracee выполнить ровно одну инструкцию

PTRACE_SINGLESTEP

Продолжить выполнение tracee 

PTRACE_CONT

Прочитать или записать в память

PTRACE_PEEKDATA, PTRACE_POKEDATA

Прочитать или записать в регистры

PTRACE_GETREGS, PTRACE_SETREGS

#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

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

  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).

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

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

  1. Используя текущее значение PC в .debug_lines, находим N init — номер текущей строки в исходном коде.

  2. Выполняем step instruction.

  3. Находим N — новый номер строки, появившийся после выполнения step instruction.

  4. Если N = N init, переходим на шаг 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

  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) — выводит на экран значение переменной. 

(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

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

  1. Ищем DIE для переменной в секции .debug_info .

  2. Ищем DIE для типа переменной, ссылка на эту DIE находится в DIE-переменной.

  3. Исследуем тип, получаем необходимую метаинформацию (DWARF-тип, выравнивание, состав полей и прочее).

  4. Вычисляем бинарное представление переменной (используем атрибут DW_AT_location).

  5. Собираем все вместе: интерпретируем сырой набор байт при помощи метаинформации о типе.

На этом первая часть статьи заканчивается — начинается более специализированная вторая часть, которая ближе к дебаггингу 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. Прикладной разработчик хочет увидеть состояние программы, понять, сколько коннектов обрабатывается на сервере, и в каком состоянии они находятся.

Backtrace с внутренностями 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, которая предоставляет безопасные обертки.

Если остались вопросы о создании отладчика, пишите в комментарии — расскажу больше о процессе. 

Теги:
Хабы:
Всего голосов 29: ↑29 и ↓0+40
Комментарии5

Публикации

Информация

Сайт
yadro.com
Дата регистрации
Дата основания
Численность
5 001–10 000 человек
Местоположение
Россия
Представитель
Ульяна Соловьева