
Привет! Сегодня рассмотрим инструмент, который поможет вам с низкоуровневым кодом на Rust. Если вы пишете на Rust только безопасный код, возможно, никогда о нём не слышали.
А вот тем, кто периодически заглядывает в тёмные уголки unsafe, этот инструмент сэкономит нервишки.
Что такое Miri и зачем он нужен
Miri — это инструмент из набора Rust, который выполняет ваш код в специальном режиме интерпретации и отслеживает признаки Undefined Behavior во время выполнения.
А наш rustc не проверяет бóльшую часть правил, нарушение которых приводит к UB. В безопасном Rust эти ситуации попросту недостижимы благодаря системе типов и заимствований. А вот в unsafe‑блоке вы берёте ответственность на себя.
Почему rustc такой невнимательный? Дело в том, что тотальный контроль UB на этапе компиляции либо невозможен, либо крайне неэффективен. Некоторые вещи можно проверить только во время выполнения программы.
Например:
Выход за границы массива/буфера. При попытке выйти за границы массивов вы получите
panicв рантайме. Но вunsafeможно сделать указатель на произвольный адрес памяти и обратиться куда угодно без единого предупреждения, если компилятор это допустил, конечно. Rust не вставляет тайно проверку на каждое разыменование указателя, иначе бы потерялись преимущества от использованияunsafe.Использование неинициализированной памяти. В safe Rust такое практически невозможно, все переменные должны быть инициализированы перед использованием. Но через небезопасные трюки, те
MaybeUninit, raw‑указатели и тому подобно.Висячие указатели и use‑after‑free. Если вы освобождаете память, например, через тот же
BoxилиVec, или просто выходом из области видимости переменной на стеке, а потом вдруг продолжаете использовать старый указатель на эту память — всё, у нас классический висячий указатель. Программа может случайно работать, а может и упасть. Компилятор Rust не может статически проанализировать все пути и понять, что вы не воспользуетесь указателем после освобождения, особенно если вы такой хитрый и сохранили адрес в каком‑нибудьunsafeглобальном месте. Поэтому такие проблемы остаются на совести.Алиасинг и нарушение правил заимствований. В Safe Rust правило одно: либо одна изменяемая ссылка, либо сколько угодно неизменяемых. В
unsafeже вы можно обойти эти ограничения, например, преобразовать&Tв*const T, а потом обратно в две&T, получив две alias‑ссылки на один объект. Компилятор это допустит, так как вы же вunsafeи «сами отвечаете за последствия». Но в модели памяти Rust такая ситуация — UB. Опять же, статически компилятор не всегда может отловить подобное.Нарушение валидности значений. В Rust многие типы накладывают ограничения на допустимые битовые паттерны.
boolможет быть в памяти только 0 или 1,charэто корректный Unicode скалярный значение, ссылки типа&Tне могут бытьnullи должны указывать на корректный объект типаT, и так далее.Эти правила присутствуют всегда, но компилятор не проверяет их в рантайме, считается, что если вы получили
bool, то он корректен, а если смогли создать&T, то он валиден. Однако сunsafeкодом можно подсунуть невалидные биты. Компилятор этого не узнает, а программа может повести себя очень странно или сломаться, опираясь на предположение «такого не бывает».
И вот Miri существует именно для того, чтобы исполнять ваш код с дополнительным контролем всех этих правил. Его задача — поймать UB в тот момент, когда оно случается, и громко вам об этом сообщить.
Внутри Miri
Как Miri умудряется ловить то, что пропустил компилятор?
Он выполняет программу не напрямую на железе, а внутри абстрактной машины, интерпретируя специальное промежуточное представление Rust — MIR (Mid‑level IR). Когда вы компилируете Rust‑проект, исходный код транслируется в MIR, на котором проводятся проверки и оптимизации, и уже потом MIR превращается в машинный код. Miri подключается на этапе MIR: вместо генерации машинного кода он начинает эмулировать выполнение программы, строчка за строчкой, оператор за оператором.
Основные функции:
Miri отслеживает каждую область памяти (все аллокации в куче, стековые переменные и так далее). Он знает, какой адре�� какой памяти соответствует, когда память выделена, а когда освобождена. Если ваш код пытается обратиться по адресу, который не относится к никакой выделенной области (например, уже освобождён или вообще никогда не выделялся), Miri сразу стопорит исполнение.
Miri запоминает, какие байты памяти инициализированы, а какие нет. Пишете что‑то в массив и эти байты помечаются как инициализированные. Выделили память под структуру, но ещё не записали поля — пока что там неинициализировано. Если потом вы попытаетесь прочитать значение из неинициализированного байта, Miri сообщит.
Miri строго проверяет типы и значения. Помните про
bool, который может быть только 0 или 1? Или про не‑null требование для ссылок? Наш виртуальный исполнитель каждый раз, когда вы интерпретируете значение определённого типа, проверяет: а корректно ли само значение по правилам Rust? Если вы умудрились где‑то получитьboolсо значением2— это сразу обнаружится.Miri следит за выравниванием. Если у вас есть ссылка
&T, она должна быть правильно выровнена подT(кроме случаев, когда тип помеченrepr(packed), тогда компилятор сам не даст создать обычную ссылку). Но черезunsafeможно создать ссылку на память с неправильным выравниванием.Miri следит за aliasing‑правилами через модель «Stacked Borrows». Короче говоря, Miri пытается понять, не нарушили ли вы уникальность
&mutили отсутствие мутации через&. Он помечает каждую ссылку или указатель специальным тегом и хранит в памяти структуры данные о том, какие теги активны для данного участка памяти. Если вы, имея эксклюзивную&mutссылку на объект, вдруг где‑то на стороне воспользовались старой ссылкой или другим указателем на тот же объект — Miri это заподозрит. Про «Stacked Borrows» и «Tree Borrows» мы ещё поговорим подробнее ниже, но пока достаточно знать: Miri умеет ловить тонкие нарушения правил заимствования, которые компилятор на этапе компиляции отследить не способен.
Конечно, такой въедливый подход имеет цену: скорость работы. Запуск тестов под Miri ощутимо медленнее обычного.
Invalid bool
Посмотрим на примеры UB, которые проходит мимо компилятора, но ловятся Miri. Первый — bool со значением, отличным от true/false. Как такое может произойти? Ну, например, с помощью небезопасного преобразования.
fn main() { let bad_bits: u8 = 2; let b: bool = unsafe { std::mem::transmute(bad_bits) }; // ^^^ трансмутизируем байт 0x02 в bool. Компилятор **разрешает** это сделать! if b { println!("b = true"); } else { println!("b = false"); } }
Этот код благополучно компилируется и даже запускается. Что он выведет — вопрос интересный. По логике, b получился не 0 и не 1, а 2. В реальном же выполнении такое неопределённое поведение: может напечататься true, может false, или вообще оптимизатор мог удалить весь if как недостижимый (ведь для bool значение 2 «не бывает», значит, теоретически, ситуация невозможна). Но если запустить этот код обычно, скорее всего напечатает b = true, потому что любое ненулевое значение, вероятно, трактуется как true.
Однако мы‑то допустили запрещённую ситуацию. Давайте запустим под Miri (cargo +nightly miri run). Что скажет Miri? Он не доедет даже до принта, сразу при попытке сконструировать bool из значения 2 мы получим ошибку такого содержания:
error: Undefined Behavior: encountered 0x02, but expected a boolean (0x0 or 0x1) --> src/main.rs:3:27 | 3 | let b: bool = unsafe { std::mem::transmute(bad_bits) }; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ encountered 0x02, but expected a boolean | = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
Miri указал: «получено значение 0×02 там, где ожидалось булево значение (0×0 или 0×1)». То есть он поймал нарушение валидности для типа bool.
Подобным образом Miri проверяет и другие invarian, например, если вы с помощью unsafe сконструируете &u8 с значением, указывающим на уже освобождённую память, или char с числом вне диапазона Unicode, он это выявит при первом же использовании или проверке этих значений.
Висячий указатель и use-after-free
Теперь такая вот база — висячий указатель. Допустим, есть функция, которая возвращает указатель на элемент коллекции, а потом коллекция может измениться или быть освобождена. Или проще: у нас есть указатель на объект, который уже уничтожен.
Распространённый сценарий: взять сырой указатель на данные внутри Box, затем освободить Box и попробовать воспользоваться старым указателем. В Safe Rust компилятор такого не допустит, вы не сможете освободить Box пока у вас висит живая ссылка на него, а если это сырой указатель, компилятор не следит, но без unsafe вы им не воспользуетесь.
Но с помощью unsafe это сделать вполне можно.
Например:
fn main() { let ptr: *const i32; { let b = Box::new(123); ptr = &*b as *const i32; // ptr указывает на область памяти внутри Box // Когда выйдем из блока, Box будет освобождён } // Здесь b уже drop'нулся, память освобождена, ptr стал висячим unsafe { println!("ptr points to: {}", *ptr); // ^^^ UB: разыменование указателя, указывающего на освобождённую память } }
Компилируется легко, предупреждений ноль. При запуске без Miri иногда вам повезет, указатель всё ещё указывает на тот же адрес, а система не успела выдать эту память другому, и вы даже пр��чтёте старое значение 123. Иногда, правда, может быть и крах, если память уже переиспользована ОС, или если оптимизатор на основании UB решит вообще избавиться от этого кода. В общем, поведение неопределено, результат зависит от случая.
Запускаем Miri (cargo miri run на nightly) и смотрим его реакцию:
error: Undefined Behavior: pointer must be in-bounds at offset 0, but it points to deallocated allocation 4 --> src/main.rs:10:23 | 10 | println!("ptr points to: {}", *ptr); | ^^^^ pointer must be in-bounds at offset 0, but it points to deallocated allocation 4 | = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
Miri сообщил: «Указатель должен указывать на валидную память, а он указывает на деаллоцированную область». Программа стопнулась.
Может вы еще и заметили, что сообщение Miri содержит ID аллокации (в данном случае allocation 4), это такой вот внутренний номер выделенного блока памяти, который уже был освобождён.
Гонка данных в многопотоке
Rust славится тем, что в safe‑коде гонки данных невозможны, механизмы заимствований и типы синхронизации не дадут двум потокам одновременно без синхронизации менять одни и те же данные. Однако, как мы знаем, unsafe позволяет обойти и это правило. А ещё есть небезопасные глобальные переменные static mut, доступ к которым компилятор не контролирует. Если где‑то два потока одновременно залезут писать в static mut без защиты, получите классическую гонку данных. Это UB, хотя компилятор и runtime Rust никакого специального чекера гонок не имеют.
Пример: глобальная изменяемая переменная без синхронизации, два потока инкрементируют её
use std::thread; static mut COUNTER: i32 = 0; fn main() { let handles: Vec<_> = (0..2).map(|_| { thread::spawn(|| { for _ in 0..1000000 { unsafe { // одновременно инкрементируем без всяких атомиков - data race COUNTER += 1; } } }) }).collect(); for h in handles { h.join().unwrap(); } unsafe { println!("COUNTER = {}", COUNTER); } }
Этот код компилятор пропустит. При запуске в релизе программа, возможно, даже успешно завершится, напечатает какой‑то результат. Но результат непредсказуем.
А что скажет Miri? Он ведь с некоторых пор умеет симулировать многопоточное исполнение и включает детектор гонок (аналог ThreadSanitizer). Если запустить программу под Miri, он гарантированно обнаружит конфликт доступа. Пример сообщения:
error: Data race detected between (1) Write on thread 1 and (2) Write on thread 2 at address 0x... --> src/main.rs:8:21 | 8 | COUNTER += 1; | ^^^^^^^^^^^ concurrent write occurs here | note: (2) happens here: --> src/main.rs:8:21 | 8 | COUNTER += 1; | ^^^^^^^^^^^ = help: this indicates a bug in the program: a data race, which is Undefined Behavior
Miri укажет, что обнаружена гонка: два параллельных доступа к одной и той же ячейке памяти. В сообщении обычно показывается один из конфликтующих доступов в коде, и часто даётся ссылка, где случился другой. Сейчас поддержка многопоточности в Miri уже достаточно зрелая, хотя над удобством сообщений ещё работают.
Но вообще, если условно у вас гонка, проявляющаяся только в очень специфической последовательности событий, Miri не гарантирует перепробовать все последовательности.
Для систематической проверки гонок иногда используют библиотеку Loom, но это тема для отдельной статьи.
Stacked Borrows и Tree Borrows
Вернёмся к теме aliasing — правил доступа к памяти через разные ссылки и указатели. Для проверки таких нарушений Miri долго использовал модель Stacked Borrows: у каждой аллокации есть стек прав доступа, и новая ссылка кладётся наверх как актуальная. Если потом вы используете ссылку, которую вытеснило более новое заимствование, это считается UB.
Проблема в том, что SB довольно строгая. Она отлично ловит баги, но иногда даёт ложные срабатывания.
Пример: вы взяли &mut на элемент, сделали из неё *mut на тот же объект, записали через сырой указатель, а затем снова используете исходную &mut. По логике это «по очереди» и в одном потоке, но SB может трактовать это так, будто использование raw pointer навсегда «отозвало» исходную &mut.
Чтобы уменьшить такие случаи, появилась экспериментальная модель Tree Borrows. Вместо стека она строит дерево отношений заимствований и более гибко разрешает возвращаться к родительским ссылкам при соблюдении условий, то есть допускает ряд распространённых паттернов, которые SB запрещала.
Если вы видите предупреждение вида «violation of Stacked Borrows rules», а код выглядит разумно, возможно, это ложное срабатывание SB. Попробуйте прогнать тесты в режиме Tree Borrows, задав MIRIFLAGS="-Zmiri-tree-borrows".
Интеграция Miri в ваш CI
Предположим, мне получилось убедить вас, что Miri — штука полезная.
Во‑первых, Miri — это компонент Rust nightly. То есть вам нужен ночной компилятор для запуска. В стабильный Rust Miri не включён по дефолту (да и некоторые проверки Miri опираются на последние изменения в инфраструктуре, потому он чаще всего используется с nightly).
Установим: rustup +nightly component add miri. Это добавит утилиту cargo miri и всё необходимое.
Запуск тестов: находясь в директории вашего проекта, выполняем cargo +nightly miri test. Это сконфигурирует Miri под проект и запустит все тесты через интерпретатор Miri. Если где‑то будет UB — тест упадёт с ошибкой.
Обычно имеет смысл добавить в CI новый джоб специально под Miri. Например, для GitHub Actions можно сделать так:
jobs: miri_check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions-rs/toolchain@v1 with: toolchain: nightly components: miri - name: Run Miri tests run: cargo miri test --all --release
Берём nightly, добавляем компонент miri, и запускаем cargo miri test. Флаг --release не обязателен, но иногда неплох, Miri в release‑режиме тоже может выявлять UB. Однако release‑режим может изменить поведение программы так, что UB проявится иначе.
Для GitLab CI схожим образом можно использовать образ rust:nightly и выполнять команды rustup component add miri && cargo miri test.
MIRIFLAGS: это переменная окружения, позволяющая передать дополнительные опции Miri.
Некоторые полезные режимы:
MIRIFLAGS="-Zmiri-ignore-leaks"— игнорировать утечки памяти. По умолчанию Miri по завершении тестов выдает ошибку, если обнаружил утечки (неосвобождённая память). В принципе, это тоже полезно, но утечка памяти не UB, и иногда в тестах допустима.MIRIFLAGS="-Zmiri-check-number-validity"— включает дополнительные проверки на валидность чисел. Это про те самые bool/char, а также про проверку, что ваши целочисленные арифметические операции не переполняют, если вы не в релизе (в debug и так panic на overflow). В release Miri может отловить переполнение, но это уже не UB, а просто поведение.MIRIFLAGS="-Zmiri-backtrace=full"— в случае ошибки Miri покажет полный стек вызовов, включая внутри стандартной библиотеки.
Эти флаги можно комбинировать через пробел, передавая одной переменной. Например, в GitHub Actions:
- name: Run Miri tests run: MIRIFLAGS="-Zmiri-ignore-leaks -Zmiri-backtrace=full" cargo miri test
А вот в случае с FFI и внешними зависимости возможны сложности. Miri умеет выполнять только чисто Растовый код.
Несколько идей:
Для некоторых функций стандартной библиотеки Miri имеет шлюзы (shims). Например, доступ к окружению, простейшие операции с файлами, аллокация памяти — всё это Miri может эмулировать. Но если попробуете вызвать
libc::socketдля сети — скорее всего, Miri этого не умеет.Можно изолировать проблемный код. Допустим, есть модуль, который лезет в систему. А основная логика, которую вы хотите потестировать, не зависит от этого напрямую. Решение: абстрагироваться в тестах.
Если изолировать сложно, можно отключить определённые тесты или куски кода при запуске под Miri. В Rust с недавних пор есть условная компиляция
cfg(miri), которая включается, когда код выполняется именно интерпретатором Miri. То есть можно написать:#![cfg_attr(miri, allow(dead_code))] // пример: отключить предупреждения о неиспользуемом коде при Miri #[test] fn test_something() { if cfg!(miri) { // например, не выполнять слишком тяжёлый по времени тест под Miri return; } // остальной код теста... }Или в основном коде:
#[cfg(miri)] { // заменить реализацию функции-заглушки на более простую для Miri }Можно, например, подменить вызов внешней C‑функции на какой‑то фиктивный результат, чтобы Miri проглотил это.
Короче говоря, тут решают проблему обычно так: критичная unsafe‑логика обычно локализована и тестируется прямо, а всё, что Miri не переваривает, либо не прогоняется под Miri, либо временно отключается. Часто встречается в README проектов: «для запуска под Miri используйте фичу disable-network»
Чем Miri не является
Настало время остыть от похвал и трезво взглянуть на ограничения Miri.
Miri не проверяет «логические» ошибки. Если в вашем коде всё типобезопасно и не нарушает правил памяти, но при этом работает неправильно (алгоритм не тот, баг в бизнес‑логике) — Miri никак не поможет.
Miri анализирует только пути, которые реально выполняются.
Miri медленный и прожорливый.
Не всё поддерживается (FFI, asm, etc.).
Случайные ложные срабатывания и недетерминизм. Miri, особенно с включёнными проверками data race и слабой памяти, содержит некоторые рандомизированные элементы. Поэтому результат запуска Miri может отличаться от раза к разу. Сегодня тест прошёл, завтра упал и поймал UB (который всегда там был, просто не выстрелил ранее). Это, конечно, не ложное срабатывание, а реальное UB, до которого докопались с другой попытки.
Не всё UB сейчас покрывается. Miri развивается вместе с пониманием Rust. Есть экзотические виды UB, которые пока вне его проверки.
Попробуйте Miri на своём коде. Даже если вы не пишете unsafe напрямую, прогоните тесты — вдруг обнаружится сюрприз в зависимостях? (Бывали случаи, честно!). А если пишете unsafe, то Miri вообще мастхев.
Miri продолжает развиваться, впереди, уверен, ещё много интересного, возможно, через год‑два он научится ещё большим приемчикам, а может, какие‑то проверки перекочуют и в сам компилятор (кто знает).
Пусть ваши программы всегда ведут себя определённо!
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

