Привет! Сегодня рассмотрим инструмент, который поможет вам с низкоуровневым кодом на 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.

  1. Miri не проверяет «логические» ошибки. Если в вашем коде всё типобезопасно и не нарушает правил памяти, но при этом работает неправильно (алгоритм не тот, баг в бизнес‑логике) — Miri никак не поможет.

  2. Miri анализирует только пути, которые реально выполняются.

  3. Miri медленный и прожорливый.

  4. Не всё поддерживается (FFI, asm, etc.).

  5. Случайные ложные срабатывания и недетерминизм. Miri, особенно с включёнными проверками data race и слабой памяти, содержит некоторые рандомизированные элементы. Поэтому результат запуска Miri может отличаться от раза к разу. Сегодня тест прошёл, завтра упал и поймал UB (который всегда там был, просто не выстрелил ранее). Это, конечно, не ложное срабатывание, а реальное UB, до которого докопались с другой попытки.

  6. Не всё UB сейчас покрывается. Miri развивается вместе с пониманием Rust. Есть экзотические виды UB, которые пока вне его проверки.


Попробуйте Miri на своём коде. Даже если вы не пишете unsafe напрямую, прогоните тесты — вдруг обнаружится сюрприз в зависимостях? (Бывали случаи, честно!). А если пишете unsafe, то Miri вообще мастхев.

Miri продолжает развиваться, впереди, уверен, ещё много интересного, возможно, через год‑два он научится ещё большим приемчикам, а может, какие‑то проверки перекочуют и в сам компилятор (кто знает).

Пусть ваши программы всегда ведут себя определённо!


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться