Отладчик Rust: как заглянуть под капот step by step
Вы когда-нибудь задавались вопросом: «Step by step interactive debugger for Rust: как это работает под капотом?» Если вы писали на Rust и пытались отловить хитрый баг, то наверняка сталкивались с GDB, LLDB или даже с такими инструментами, как rust-gdb. Но как именно они умеют останавливать выполнение программы, показывать значения переменных и делать шаги по исходному коду?
В этой статье мы не просто расскажем, как пользоваться отладчиком. Мы заглянем внутрь: от машинного кода до точек останова, от стековых фреймов до символов отладки. Если вам интересно не только что делать, но и почему это работает — вы по адресу.
Почему Rust — особенный случай для отладки?
Rust компилируется в нативный код через LLVM. Это значит, что для отладки мы используем те же инструменты, что и для C/C++: GDB и LLDB. Но Rust добавляет свои сложности:
- Собственность и заимствование — переменные могут быть перемещены, и отладчик должен это отслеживать.
- Мономорфизация дженериков — каждый
Tпревращается в конкретный тип, и имена функций становятся «зашифрованными». - Оптимизации — компилятор может переставлять код, и отладчик должен «размотать» это обратно.
Именно поэтому step by step interactive debugger for Rust — это не просто запуск программы под GDB. Это целая система, которая переводит машинные адреса обратно в строки исходного кода.
Как работает точка останова (breakpoint) под капотом?
Представьте: вы ставите точку останова на строке println!("Hello");. Что происходит в процессоре?
Когда вы запускаете программу под отладчиком, он делает следующее:
- Читает отладочную информацию (формат DWARF) из исполняемого файла. Там хранится соответствие: адрес в памяти → строка в коде.
- Заменяет инструкцию по этому адресу на специальную команду
INT3(на x86 —0xCC). Это «ловушка» для процессора. - Когда выполнение доходит до этой точки, процессор генерирует сигнал SIGTRAP.
- Отладчик перехватывает сигнал, сохраняет состояние регистров и показывает вам текущую строку.
Вот пример простой программы на Rust:
fn main() {
let x = 42;
let y = x + 10; // <-- точка останова
println!("y = {}", y);
}
Когда вы ставите брейкпоинт на let y = x + 10;, отладчик находит адрес этой инструкции в бинарнике и подменяет первый байт на 0xCC. После остановки он восстанавливает оригинальный байт, выполняет шаг, и снова ставит ловушку — если вы хотите продолжить.
Step over, step into, step out — как отладчик понимает, где «следующая строка»?
Это, пожалуй, самая магическая часть. Как отладчик знает, что после let y = x + 10; идёт println!?
Всё дело в отладочных таблицах. Компилятор Rust (через LLVM) генерирует секцию .debug_line в формате DWARF. Это сжатая таблица, которая сопоставляет адреса инструкций с номерами строк в исходном коде.
Например, для нашего кода таблица может выглядеть так:
Адрес 0x1000 → строка 2 (let x = 42;)
Адрес 0x1010 → строка 3 (let y = x + 10;)
Адрес 0x1020 → строка 4 (println!)
Когда вы нажимаете step over, отладчик вычисляет адрес следующей инструкции, которая не принадлежит текущей строке, и ставит временную точку останова туда. Выполнение продолжается до этого адреса — и вы переходите на следующую строку.
Для step into — отладчик проверяет, есть ли по текущему адресу вызов функции. Если да, он ищет первую строку этой функции и ставит брейкпоинт туда.
Просмотр переменных: как отладчик знает значение?
Rust — язык со строгим контролем памяти. Переменная может быть:
- В регистре процессора (если она маленькая и часто используется).
- На стеке (локальные переменные).
- В куче (через Box, Vec и т.д.).
Отладчик использует информацию из .debug_info (тоже DWARF). Там описано: «переменная x типа i32 находится по смещению -8 от RBP» или «переменная s типа String находится в регистре RAX».
Пример: если вы отлаживаете такой код:
fn main() {
let s = String::from("hello");
let len = s.len(); // брейкпоинт тут
println!("{}", len);
}
Отладчик знает, что s — это структура из трёх полей: указатель на кучу, длина и ёмкость. Он может показать вам содержимое строки, разыменовав указатель. Это работает, потому что отладочная информация содержит описание типа String.
Проблема: «зашифрованные» имена (mangled symbols)
Если вы когда-нибудь смотрели backtrace в Rust без отладчика, вы видели что-то вроде:
_ZN3std2io5stdio6_print6output17h3b8c5f3c9f5e7b9cE
Это mangled name (искажённое имя). Rust, как и C++, использует name mangling, чтобы кодировать информацию о модулях, дженериках и трейтах в одном имени функции.
Хороший отладчик (например, rust-gdb) автоматически «деманглит» эти имена обратно в читаемый вид: std::io::stdio::_print::output.
Без этого step by step interactive debugger for Rust был бы практически бесполезен — вы бы видели только адреса и кракозябры.
Собственный отладчик на Rust: минимальный пример
Чтобы понять, как всё работает на низком уровне, давайте напишем крошечный отладчик на самом Rust. Используем крейт nix для работы с системными вызовами.
Этот код ставит точку останова на конкретный адрес и ждёт сигнала:
use nix::sys::ptrace;
use nix::sys::signal::Signal;
use nix::sys::wait::waitpid;
use