Rust unsafe и FFI: когда необходимо, как ограничить, связь с C
0%

unsafe и FFI: когда необходимо, как ограничить, связь с C

unsafe и FFI: когда необходимо, как ограничить, связь с C

В предыдущей статье мы разбирали асинхронный рантайм — и несколько раз натыкались на то, что Waker, Pin и самоссылающиеся генераторы внутри устроены на сырых указателях. Это не грязный хак авторов tokio, а структурная особенность языка: весь безопасный Rust стоит на фундаменте, который сам по себе небезопасен.

Vec невозможно написать в безопасном Rust — он владеет неинициализированной памятью за len. Arc невозможно: разделяемое владение с атомарным счётчиком — ровно то, что запрещает aliasing XOR mutability. Mutex, split_at_mut, любой системный вызов, любое обращение к регистру устройства — всё это unsafe внутри. Стандартная библиотека — несколько сотен тщательно проверенных unsafe-блоков, обёрнутых в API, которым можно пользоваться, ни о чём не думая.

Статья про то, как устроена та самая обёртка. Задача — не «научиться писать unsafe», а научиться его сокращать, документировать инварианты и проверять их инструментами, потому что здесь компилятор перестаёт быть соавтором.

Задача: где кончается доказательство

Модель владения (статья 3) и времена жизни (статья 4) доказывают безопасность памяти статически — но только там, где компилятор видит всю картину. Он не видит её в четырёх ситуациях.

1. Аппаратура. Регистр GPIO по адресу 0x4002_0014 — не объект языка. Компилятор не знает, что запись туда включает светодиод и что две подряд идущие записи нельзя схлопывать. Здесь нужен write_volatile, и его корректность обеспечивает документация на микросхему, а не система типов.

2. Операционная система. read(2), mmap(2) — переход в код, который компилятор не компилировал. Он не знает, что mmap вернул валидную область, а munmap её отобрал. Вся стандартная библиотека ввода-вывода внутри — обёртки над unsafe extern; механика — в «Системных вызовах и IPC».

3. Чужой код. Тридцать лет промышленного C: OpenSSL, SQLite, zlib, драйверы вендоров. Переписывать — не вариант, вызывать — значит выйти за пределы проверяемого.

4. Абстракции, которые модель не выражает. Двусвязный список, Vec с неинициализированным хвостом, разделение среза на две изменяемые половины, арены, lock-free очереди. Модель владения отвергает часть корректных программ — это цена статической проверки, и unsafe здесь легальный выход: доказательство предъявляете вы.

Вывод, который надо сделать сразу: первые три случая — про границу с внешним миром, четвёртый — про производительность и структуры данных, и только он допускает вопрос «а можно без него?». В прикладном коде — веб-сервисе, CLI, обработке данных — ответ почти всегда «можно».

Что unsafe не делает

Главное заблуждение: «unsafe отключает borrow checker». Разбор именно этого места экономит недели.

Внутри unsafe-блока продолжают работать все обычные проверки: владение, перемещения, времена жизни, изменяемость, типы, исчерпывающий match, проверка границ у срезов. Компилятор не станет добрее ни на грамм.

fn main() {
    let s = String::from("привет");
    let _moved = s;
    unsafe {
        println!("{s}");   // ошибка ровно та же, что и без unsafe
    }
}
error[E0382]: borrow of moved value: `s`
 --> src/main.rs:5:19
  |
3 |     let _moved = s;
  |                  - value moved here
5 |         println!("{s}");
  |                   ^^^ value borrowed here after move

unsafe добавляет возможности, а не убирает проверки. Семантически это не «разрешение», а смена стороны, которая несёт доказательство. Обычный Rust: «доказал компилятор». unsafe: «доказал я, и вот моё доказательство в комментарии». Отсюда критерий качества кода: если рядом с unsafe нет комментария, объясняющего корректность, код сломан — даже если сегодня работает.

Пять сверхспособностей

Полный закрытый список того, что можно только в unsafe: разыменовать сырой указатель (*ptr); вызвать unsafe fn, включая любую функцию из extern-блока; прочитать или изменить static mut; реализовать unsafe-трейт (Send, Sync, GlobalAlloc, bytemuck::Pod); прочитать поле union.

В редакции 2024 к этому добавились два обязательных маркера в объявлениях: unsafe extern "C" { ... } вокруг блока внешних функций и #[unsafe(no_mangle)], #[unsafe(export_name = "…")], #[unsafe(link_section = "…")] вокруг атрибутов, влияющих на линковку. Логика одна: экспорт символа под фиксированным именем — обещание внешнему миру, небезопасное ровно так же, как разыменование указателя.

error: unsafe attribute used without unsafe
 --> src/lib.rs:1:3
  |
1 | #[no_mangle]
  |   ^^^^^^^^^ usage of unsafe attribute
  |
help: wrap the attribute in `unsafe(...)`: `#[unsafe(no_mangle)]`

Неопределённое поведение: главное недопонимание

Неправильная модель: «UB — значит программа упадёт или прочитает мусор». Правильная: UB — это разрешение компилятору считать, что такой ситуации не бывает, и оптимизировать исходя из этого. Из невозможного следует что угодно: код после проверки выброшен, ветвление удалено, вызов заинлайнен с неверными предположениями об алиасинге.

Следствие: программа с UB может годами работать правильно и сломаться от обновления компилятора, добавления строки логирования или смены -O2 на -O3. Это не мистика, а оптимизатор, наконец воспользовавшийся разрешением. То же явление на примере C — в «Неопределённом поведении».

Список UB живёт в The Reference: Behavior considered undefined. Ключевое: разыменование нулевого, висячего или невыровненного указателя; нарушение алиасинга (два &mut на одну память либо мутация, пока живёт &); гонка данных; чтение неинициализированной памяти, включая байты выравнивания; создание невалидного для типа значения (bool не 0 и не 1, char вне Unicode, enum с чужим дискриминантом, нулевой &T или NonNull, невалидный UTF-8 в str); вызов функции с неверным ABI; арифметика указателей за пределами одной аллокации; разворачивание стека наружу через extern "C".

Два инварианта: валидность и безопасность

Различение Ральфа Юнга — самый полезный инструмент мышления в теме («Two Kinds of Invariants»).

Инвариант валидности верен всегда, иначе UB немедленно и безусловно: у bool байт равен 0 или 1, у &T указатель выровнен, не нулевой и указывает на живой объект.

Инвариант безопасности — то, на что полагается безопасный код, но что unsafe-код внутри модуля вправе временно нарушать. У Vec<T> первые len элементов инициализированы: снаружи железно, а внутри push есть момент, когда len увеличен, а элемент ещё не записан.

Отсюда правило проектирования: временное нарушение инварианта безопасности допустимо только внутри модуля, целиком отвечающего за тип, и только пока наружу не утекает наблюдаемое состояние. Поэтому unsafe нельзя «размазать» по проекту — он живёт в одном модуле рядом с типом, который защищает.

Сырые указатели: адрес — ещё не указатель

*const T и *mut T — ссылки без гарантий: бывают нулевыми, висячими, невыровненными, алиасят произвольно. Создавать их безопасно, разыменовывать — нет.

fn main() {
    let mut n = 42i32;
    let p: *mut i32 = &mut n;
    let p_raw = &raw mut n;                // стабильно с Rust 1.82, без промежуточной ссылки
    unsafe {
        *p = 7;
        *p_raw += 1;
    }
    println!("{n}");                       // 8
}
error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
 --> src/main.rs:5:5
  |
5 |     *p = 7;
  |     ^^^^^^ dereference of raw pointer
  |
  = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules
          and cause data races: all of these are undefined behavior

Операторы &raw const / &raw mut (ранее макросы addr_of! / addr_of_mut!) существуют по важной причине: &mut x as *mut _ сначала создаёт настоящую ссылку, и уже она обязана соблюдать все правила алиасинга. Для полей #[repr(packed)]-структур, для неинициализированной памяти и для static mut это разница между корректным кодом и UB.

Provenance. В модели памяти Rust указатель — не просто адрес, а пара «адрес + происхождение»: из какой аллокации получен и на какой диапазон имеет право.

let v = vec![1u8, 2, 3];
let addr = v.as_ptr() as usize;   // provenance потеряно
let p = addr as *const u8;        // адрес тот же, прав нет
// unsafe { *p }                   // UB, хотя байты «на месте»

Корректно — не терять происхождение (ptr.add, ptr.wrapping_offset), а если целочисленный круг неизбежен, использовать ptr.expose_provenance() и ptr::with_exposed_provenance() (стабильны с 1.84). Проверка: MIRIFLAGS=-Zmiri-strict-provenance cargo miri test. Теория — «Pointers Are Complicated II» и документация std::ptr.

static mut — почти всегда ошибка. В редакции 2024 это уже не проходит просто так:

error: creating a shared reference to mutable static
 --> src/main.rs:5:29
  |
5 |     println!("{}", unsafe { COUNTER });
  |                             ^^^^^^^ shared reference to mutable static
  |
  = note: this reference has undefined behavior if the static is mutated concurrently
help: use `&raw const` instead to create a raw pointer

Правильный ответ — не &raw const, а другой инструмент: AtomicU32 для счётчика, OnceLock для ленивой инициализации, Mutex для состояния (конкурентность). static mut оправдан примерно только в no_std на одном ядре без прерываний — и там его лучше прятать за critical_section.

Правило проектирования: unsafe-ядро в safe-оболочке

Единственная работающая архитектура — та, которой пользуется сама std: маленькое unsafe-ядро, полностью инкапсулированное безопасным API, который невозможно применить неправильно.

Канонический пример — split_at_mut. В безопасном Rust он невыразим:

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    (&mut values[..mid], &mut values[mid..])
}
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/lib.rs:2:26
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
2 |     (&mut values[..mid], &mut values[mid..])
  |     -^^^^^^^^^^^^^^^^^^-------------------- second mutable borrow occurs here
  |     ||
  |     |first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`

Компилятор не ошибается — он просто не умеет доказывать, что диапазоны не пересекаются. Программа корректна, доказательство есть, но оно вне возможностей borrow checker. Это ровно тот случай, когда unsafe уместен:

use std::slice;

/// Делит срез на две непересекающиеся изменяемые половины.
///
/// # Паника
/// Если `mid > values.len()`.
pub fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();
    assert!(mid <= len, "mid вне границ среза");   // проверка ДО unsafe

    // SAFETY: `ptr` получен из живого `&mut [i32]` — значит не нулевой, выровнен
    // и валиден для `len` элементов. assert выше гарантирует `mid <= len`, поэтому
    // диапазоны [0, mid) и [mid, len) лежат внутри аллокации и не пересекаются:
    // два возвращаемых `&mut` не алиасят. Времена жизни результатов выводятся из
    // `values`, поэтому исходный `&mut` заимствован на всё время их жизни.
    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Что здесь сделано правильно — это шаблон для любого unsafe-кода. Функция не unsafe: снаружи её нельзя применить некорректно, плохой mid даёт панику, а не UB; превратить произвольный вход в панику вместо UB и есть суть безопасной обёртки. Проверка стоит до unsafe, чтобы было видно: инвариант установлен раньше, чем понадобился. Блок минимален — заворачивать в unsafe всё тело функции значит сломать ревью. // SAFETY: перечисляет предпосылки по пунктам, а не сообщает «здесь всё нормально»; в стандартной библиотеке это требование политики, а в вашем проекте — линт clippy::undocumented_unsafe_blocks.

unsafe fn и unsafe {} — разные вещи

unsafe fn означает: у функции есть контракт, который обязан соблюсти вызывающий, и проверить его внутри невозможно. unsafe {} означает: я здесь пользуюсь сверхспособностью. До редакции 2021 тело unsafe fn целиком считалось unsafe-блоком — удобно и вредно: сверхспособности терялись из виду. В редакции 2024 линт unsafe_op_in_unsafe_fn включён по умолчанию:

/// # Safety
/// `ptr` должен быть валиден для записи и выровнен под `u32`.
pub unsafe fn store(ptr: *mut u32, value: u32) {
    // SAFETY: гарантировано контрактом функции, см. секцию # Safety.
    unsafe { ptr.write(value) }
}
error[E0133]: dereference of raw pointer is unsafe and requires unsafe block
note: an unsafe function restricts its caller, but its body is safe by default

Правило: каждая unsafe fn обязана иметь в rustdoc секцию # Safety с перечислением требований (линт clippy::missing_safety_doc). Это не бюрократия — это то, что читает вызывающий, когда пишет свой // SAFETY:. Оба конца контракта должны сходиться дословно.

UnsafeCell: как из unsafe делают safe

Вся внутренняя изменяемость — Cell, RefCell, Mutex, RwLock, атомики, OnceLock — стоит на одном примитиве.

use std::cell::UnsafeCell;

pub struct MyCell<T> {
    value: UnsafeCell<T>,      // единственный легальный способ мутировать через &self
}

impl<T: Copy> MyCell<T> {
    pub fn set(&self, v: T) {
        // SAFETY: MyCell не Sync (UnsafeCell не Sync), значит доступ только из
        // одного потока; ссылка внутрь наружу не утекает, и между чтением и
        // записью нет точки, где существовал бы живой &T на это поле.
        unsafe { *self.value.get() = v }
    }
}

Почему нельзя просто unsafe { &mut *(r as *const T as *mut T) }? Потому что &T даёт компилятору право считать данные неизменными и передавать LLVM атрибуты noalias/readonly. UnsafeCell — единственный тип, отменяющий это обещание на уровне модели памяти. Он не «магия для обхода правил», а сообщение оптимизатору: сюда могут писать.

FFI: ABI — это не про синтаксис

Вызвать функцию из C — договориться о вещах, которых нет в исходном коде: в каких регистрах лежат аргументы, кто выравнивает и чистит стек, как возвращаются структуры, как выглядит имя символа в объектном файле, что происходит при разворачивании стека. Это ABI, соглашение платформы; разбор — в «Сборке и линковке» и «Основах ассемблера».

Rust не имеет стабильного ABI: extern "Rust" — внутреннее дело компилятора, раскладка структур и соглашение о вызовах могут меняться между версиями. Поэтому любой мост между языками говорит на extern "C" — самом старом и стабильном интерфейсе индустрии. Отсюда несимметричная реальность: связать Rust с C проще, чем Rust с Rust, собранным другим компилятором.

Раскладка типов: #[repr(C)]

repr(Rust) против repr(C): одна структура, две раскладки

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Packet { pub a: u8, pub b: u32, pub c: u8 }

fn main() {
    println!("{} {}", size_of::<Packet>(), std::mem::offset_of!(Packet, b));
    // с repr(C):  12 4
    // без него:    8 0   ← и то, и другое может измениться в любой версии
}

Минимум для границы: #[repr(C)] — на любую структуру, пересекающую границу; #[repr(transparent)] — на newtype над FFI-типом (раскладка ровно как у внутреннего); #[repr(i32)]/#[repr(u8)] — на enum, соответствующий C-перечислению, причём значение вне списка вариантов — UB, поэтому пришедшее из C число сначала проверяйте matchем по числу, а не приводите. И никогда не читайте структуру как срез байтов: байты заполнения не инициализированы. Для этого есть zerocopy и bytemuck — они проверяют требования типами, а не вашей памятью.

Соответствие типов

Пишите типы из core::ffi, а не «угаданные» примитивы: они подставляют правильный размер и знаковость для целевой платформы.

C Rust Мина
int, unsigned c_int, c_uint не i32 вручную: не везде 32 бита
long c_long 64 бита на Linux, 32 на Windows
size_t usize совпадает на всех поддерживаемых платформах
char c_char знаковость платформозависима: i8 на x86, u8 на ARM
void * *mut c_void не *mut u8
T *, может быть NULL Option<&T> или *const T &T нулевым не бывает никогда
T *, никогда не NULL NonNull<T> ноль в NonNull — мгновенное UB
bool из stdbool.h bool валидны только 0 и 1, иначе UB
указатель на функцию extern "C" fn(...) Option<extern "C" fn()> — того же размера
структура #[repr(C)] struct без repr(C) смещения не совпадут

Отдельно стоит знать про niche-оптимизацию: Option<&T>, Option<Box<T>>, Option<NonNull<T>> и Option<extern "C" fn()> занимают ровно столько же, сколько указатель, где None — это ноль. Это задокументированная гарантия, позволяющая выразить «указатель, который может быть NULL» типом, а не соглашением в комментарии.

Строки: единственный по-настоящему коварный случай

Строка через границу FFI: три представления одних и тех же байтов

Всё остальное на границе — арифметика смещений, а строки — конфликт двух несовместимых моделей. Rust: длина рядом с указателем, гарантированный UTF-8, нуль внутри разрешён. C: длина ищется сканированием до нуля, кодировка неизвестна, нуль внутри невозможен.

use std::ffi::{CStr, CString};
use core::ffi::c_char;

unsafe extern "C" {
    fn puts(s: *const c_char) -> i32;
}

fn main() {
    // ЛОВУШКА: CString здесь временное значение и умирает в конце строки.
    // let p = CString::new("привет").unwrap().as_ptr();
    // unsafe { puts(p) };                       // висячий указатель, UB

    let owned = CString::new("привет").expect("внутри строки нет нулей");
    unsafe { puts(owned.as_ptr()) };             // владелец жив, пока нужен
}

/// # Safety
/// `p` — либо ноль, либо валидный нуль-терминированный буфер,
/// живущий как минимум всё время жизни `'a`.
pub unsafe fn borrow_c_str<'a>(p: *const c_char) -> Option<&'a str> {
    if p.is_null() { return None; }
    // SAFETY: непустоту проверили, валидность и время жизни — контракт функции.
    unsafe { CStr::from_ptr(p) }.to_str().ok()
}

Ловушку с as_ptr() clippy ловит линтом dangling_pointers_from_temporaries (раньше temporary_cstring_as_ptr) — это самый частый баг новичка в FFI. В обратном направлении важны три вещи: CStr::from_ptr делает strlen, то есть O(n), а не бесплатное приведение; время жизни результата ничем не связано с данными, поэтому его надо привязать вручную параметром 'a, иначе получите &'static str на чужой буфер; to_str() возвращает Result, потому что байты из C могут не быть UTF-8 (если источнику не доверяете — to_string_lossy()).

Rust вызывает C: маленький, но полный проект

Структура, принятая в экосистеме: сырые объявления отдельно, безопасная обёртка отдельно.

statlib/
├── build.rs
├── csrc/stats.c        # int stats_mean(const double *data, size_t len, double *out)
└── src/
    ├── ffi.rs          # только объявления, ничего умного
    └── lib.rs          # безопасный API, единственный unsafe-блок
/* csrc/stats.c — 0 при успехе, отрицательный код при ошибке */
#include <stddef.h>

int stats_mean(const double *data, size_t len, double *out) {
    if (data == NULL || out == NULL) return -1;
    if (len == 0) return -2;
    double sum = 0.0;
    for (size_t i = 0; i < len; i++) sum += data[i];
    *out = sum / (double)len;
    return 0;
}
// build.rs — компилируем C и линкуем статически
fn main() {
    cc::Build::new().file("csrc/stats.c").warnings(true).compile("stats");
    println!("cargo:rerun-if-changed=csrc/stats.c");
}
// src/ffi.rs — дословный перевод заголовка, без фантазии
use core::ffi::{c_double, c_int};

unsafe extern "C" {                       // в редакции 2024 unsafe обязателен
    pub fn stats_mean(data: *const c_double, len: usize, out: *mut c_double) -> c_int;
}
// src/lib.rs — вся безопасность живёт здесь
mod ffi;

#[derive(Debug, PartialEq, Eq)]
pub enum StatsError { Empty, Foreign(i32) }

/// Среднее арифметическое, вычисленное библиотекой на C.
pub fn mean(data: &[f64]) -> Result<f64, StatsError> {
    if data.is_empty() {
        // У пустого среза as_ptr() даёт выровненный НЕнулевой указатель,
        // но разыменовывать его нельзя. Отсекаем случай на своей стороне.
        return Err(StatsError::Empty);
    }
    let mut out = 0.0f64;

    // SAFETY: `data` — живой срез, поэтому as_ptr() валиден для чтения ровно
    // data.len() элементов f64 (совпадает с C double на всех поддерживаемых
    // платформах). `out` — живая локальная переменная, валидная для записи
    // одного f64. Диапазоны не алиасят. C-функция ничего не сохраняет
    // и не освобождает, побочных эффектов не имеет.
    let code = unsafe { ffi::stats_mean(data.as_ptr(), data.len(), &mut out) };

    match code {
        0 => Ok(out),
        -2 => Err(StatsError::Empty),
        c => Err(StatsError::Foreign(c)),
    }
}

Обратите внимание на пропорцию: один unsafe-блок в одну строку, шесть строк обоснования и полностью безопасный API снаружи. Так это и должно выглядеть. Ошибки оформляются как обычно — своим типом с Display и Error (обработка ошибок), а не кодами возврата наружу.

Соглашение -sys. В экосистеме принято разделение на два крейта: foo-sys содержит только сырые объявления и логику сборки (часто сгенерированные bindgen), foo — безопасную обёртку. Причина не в эстетике: в Cargo.toml крейта -sys указывается ключ links = "foo", и cargo следит, чтобы нативная библиотека линковалась в графе ровно один раз. Без этого два крейта, каждый по-своему линкующий libfoo, дают конфликт символов.

Найти библиотеку в системе можно тремя способами: собрать из исходников через cc (воспроизводимость, вендоринг), найти установленную через pkg-config, или дать выбор через feature-флаг vendored. Кросс-компиляция ломается почти всегда именно здесь — см. «Установку и инструментарий».

C вызывает Rust

Обратное направление — как Rust становится библиотекой для чужого мира: нативный модуль Python, addon для Node, .so для C-приложения, статическая библиотека для прошивки.

use core::ffi::{c_char, c_int};
use std::ffi::CStr;
use std::panic::catch_unwind;

/// Считает слова в нуль-терминированной UTF-8 строке.
///
/// # Safety
/// `text` — валидный нуль-терминированный буфер, `out` — валидный указатель
/// на запись `u64`. Оба могут быть нулевыми: тогда вернём -1.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rs_count_words(text: *const c_char, out: *mut u64) -> c_int {
    if text.is_null() || out.is_null() { return -1; }

    // Паника не должна пересечь границу: ловим её и превращаем в код.
    let result = catch_unwind(|| {
        // SAFETY: непустоту проверили выше, валидность — контракт функции.
        let s = unsafe { CStr::from_ptr(text) };
        s.to_str().ok().map(|s| s.split_whitespace().count() as u64)
    });

    match result {
        // SAFETY: `out` не нулевой (проверено) и валиден для записи по контракту.
        Ok(Some(n)) => { unsafe { out.write(n) }; 0 }
        Ok(None) => -2,          // байты не UTF-8
        Err(_) => -99,           // паника внутри Rust
    }
}

Заголовок для C-стороны генерируется автоматически: cbindgen читает крейт и пишет int rs_count_words(const char *text, uint64_t *out);.

Паника через границу. Разворачивание стека — механизм Rust, C о нём ничего не знает, и его кадры стека к нему не готовы. Раньше паника, вылетевшая из extern "C", была прямым UB; с Rust 1.81 это гарантированный abort (анонс). Прогресс большой, но для библиотеки этого мало: уронить чужой процесс из-за unwrap() на кривом входе — плохое поведение. Правило: на каждой экспортируемой функции catch_unwind и код ошибки. Обратный случай, когда разворачивание должно проходить через границу насквозь, описывается отдельным ABI extern "C-unwind" (RFC 2945) — применять осознанно, зная, что обе стороны собраны совместимо.

Владение через границу: кто освобождает

Самая частая утечка и самый частый двойной free в FFI рождаются из одного вопроса: чей аллокатор. Память из Box/Vec/String обязана освобождаться Rust, память из malloc — через free. Смешивание — UB, даже если «оба просто куча»: аллокаторы держат свои метаданные.

pub struct Session { id: u64, buffer: Vec<u8> }

/// Создаёт сессию и передаёт владение вызывающему.
#[unsafe(no_mangle)]
pub extern "C" fn session_open(id: u64) -> *mut Session {
    Box::into_raw(Box::new(Session { id, buffer: Vec::new() }))
}

/// Освобождает сессию; указатель после вызова недействителен.
///
/// # Safety
/// `p` — либо ноль, либо результат `session_open`, ещё не переданный
/// в `session_close`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn session_close(p: *mut Session) {
    if p.is_null() { return; }
    // SAFETY: по контракту `p` получен из Box::into_raw и не освобождался.
    drop(unsafe { Box::from_raw(p) });
}

Правила, которые стоит зафиксировать в документации своего API: на каждую функцию, отдающую владение, есть парная освобождающая (Box::into_rawBox::from_raw, CString::into_rawCString::from_raw); *_free терпит нулевой указатель; непрозрачный указатель (*mut Session) не даёт C-стороне лазить внутрь структуры; повторный close — ответственность вызывающего, и это надо написать в # Safety.

Обратные вызовы

C-библиотеки принимают колбэк и void *user_data. Замыкание Rust — не указатель на функцию, поэтому нужен «трамплин»: свободная extern "C" функция, извлекающая замыкание из user_data.

use core::ffi::c_void;

unsafe extern "C" {
    fn c_for_each(n: usize, cb: extern "C" fn(usize, *mut c_void), user: *mut c_void);
}

extern "C" fn trampoline<F: FnMut(usize)>(i: usize, user: *mut c_void) {
    // SAFETY: `user` — это &mut F, переданный из for_each ниже; он жив всё
    // время вызова c_for_each, и повторного входа C не делает.
    let f = unsafe { &mut *(user as *mut F) };
    f(i);
}

pub fn for_each<F: FnMut(usize)>(n: usize, mut f: F) {
    let user = &mut f as *mut F as *mut c_void;
    // SAFETY: trampoline::<F> соответствует ожидаемой сигнатуре,
    // `user` указывает на живое `f` на всё время вызова.
    unsafe { c_for_each(n, trampoline::<F>, user) };
}

Мины: колбэк не должен паниковать (он extern "C"); если C сохраняет колбэк дольше, чем длится вызов, &mut f не годится — нужен Box::into_raw и парная функция освобождения; если C вызывает колбэк из другого потока, требуется F: Send, и это надо записать в границы типа, иначе получите гонку данных, которую компилятор не увидел.

Генераторы биндингов

Писать extern-блоки руками стоит для трёх-пяти функций, дальше — инструменты.

Инструмент Что делает Когда брать
bindgen из .h — сырые Rust-объявления любая C-библиотека, основа крейтов -sys
cbindgen из Rust — .h ваша библиотека вызывается из C
cxx двусторонний безопасный мост C++ с std::string, unique_ptr, исключениями
PyO3 + maturin нативный модуль Python ускорение узкого места в Python-проекте
napi-rs нативный addon Node.js то же для JS/TS
UniFFI биндинги Kotlin и Swift общая логика для Android и iOS
wasm-bindgen мост Rust ↔ JS WASM-модуль в браузере

Важная оговорка про bindgen: он генерирует сырые объявления — всё unsafe, всё сырые указатели, никакого «теперь безопасно» не происходит. Работа по написанию обёртки остаётся целиком на вас; bindgen лишь избавляет от опечаток в сигнатурах и подхватывает изменения заголовков. cxx — инструмент другого класса: мост описывается декларативно, код генерируется с обеих сторон, поэтому рассинхронизировать сигнатуры физически нельзя.

Практический совет по внедрению: самый дешёвый путь — не переписывание, а точечная замена узкого места через PyO3 или napi-rs с замером до и после (см. «Производительность Python» и «Экосистему Rust»). Так появились polars, pydantic-core, orjson, tokenizers.

Проверка: чем ловить то, что не доказал компилятор

unsafe-код без инструментальной проверки — код, который вы просто надеетесь считать правильным.

Miri — интерпретатор MIR, выполняющий ваши тесты и следящий за UB: висячие указатели, невыровненный доступ, чтение неинициализированного, нарушения алиасинга по модели Stacked Borrows / Tree Borrows, потеря provenance.

rustup +nightly component add miri
cargo +nightly miri test
MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test
MIRIFLAGS="-Zmiri-tree-borrows"      cargo +nightly miri test   # вторая модель алиасинга
error: Undefined Behavior: attempting a write access using <2848> at alloc1[0x0],
       but that tag does not exist in the borrow stack for this location
 --> src/lib.rs:14:9
   |
14 |         *r1 = 10;
   |         ^^^^^^^^ this error occurs as part of an access at alloc1[0x0..0x4]
   |
   = note: <2848> was created by a Unique retag at offsets [0x0..0x4]
   = note: <2848> was later invalidated at offsets [0x0..0x4] by a Unique retag

Читается это как «вы создали второй &mut, и первый перестал быть действительным». Ограничение Miri принципиальное и обидное: он почти не умеет FFI — чужой машинный код интерпретировать нечем (экспериментальный -Zmiri-native-lib есть, полагаться на него рано). Поэтому Miri закрывает unsafe-ядро ваших структур данных, но не границу с C.

Санитайзеры закрывают ровно то, чего не может Miri, — реальное исполнение вместе с чужим кодом:

RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu
RUSTFLAGS="-Zsanitizer=thread"  cargo +nightly test --target x86_64-unknown-linux-gnu

Для FFI имеет смысл собирать и C-часть с санитайзером (.flag("-fsanitize=address") в build.rs), иначе ошибки внутри библиотеки останутся невидимыми. Фаззинг обязателен, если через границу проходят данные из внешнего мира: парсер бинарного формата, декодер, разбор пакетов — cargo-fuzz и arbitrary находят за часы то, о чём вы не подумали (следующая статья).

Политика на уровне крейта — самое дешёвое и самое эффективное:

[lints.rust]
unsafe_code = "forbid"                    # forbid нельзя перебить локальным allow

[lints.clippy]
undocumented_unsafe_blocks = "deny"       # unsafe без SAFETY-комментария не собирается
missing_safety_doc = "deny"               # unsafe fn без секции # Safety
multiple_unsafe_ops_per_block = "warn"    # одна сверхспособность на блок

Правильная конфигурация монорепозитория: unsafe_code = "forbid" во всех прикладных и доменных крейтах, unsafe разрешён в одном-двух явно названных крейтах-обёртках, у которых есть свой CI-прогон под Miri и санитайзерами. Тогда «где у нас unsafe» — ответ из Cargo.toml, а не из grep (структурные детали — в «Проде на Rust»). Для зависимостей: cargo geiger показывает объём unsafe в графе, cargo audit и RustSec — известные уязвимости, cargo vet и cargo crev — переиспользование чужих аудитов (цепочка поставок).

Стоит знать эмпирику. Исследования реальных проектов (Qin et al., PLDI 2020; Astrauskas et al., OOPSLA 2020) дают согласованную картину: unsafe встречается примерно в четверти-трети крейтов, большая его часть — тонкие FFI-обёртки, и все найденные ошибки безопасности памяти так или иначе проходили через unsafe-блок. Это ровно то, на что рассчитан язык: аудит сужается с миллиона строк до нескольких сотен.

Регистры и no_std: почему volatile

Класс unsafe, которого нет в прикладном коде и который во встраиваемом — основной.

use core::ptr::{read_volatile, write_volatile};

const GPIOA_ODR: *mut u32 = 0x4002_0014 as *mut u32;

/// # Safety
/// Только на STM32F4 и только при инициализированном тактировании GPIOA.
pub unsafe fn led_blink_once() {
    // SAFETY: адрес взят из reference manual, выровнен под u32,
    // регистр отображён в память и доступен для чтения-записи.
    unsafe {
        let v = read_volatile(GPIOA_ODR);
        write_volatile(GPIOA_ODR, v | (1 << 5));
        write_volatile(GPIOA_ODR, v & !(1 << 5));
    }
}

Почему нельзя просто *GPIOA_ODR = x? Для компилятора это обычная запись в память: две подряд идущие записи в один адрес он вправе схлопнуть в одну, а запись, результат которой никто не читает, — выбросить. Светодиод не мигнёт. volatile — указание «обращение имеет побочный эффект, не трогай его». Важно не путать: volatile не даёт атомарности и не упорядочивает доступ между потоками — для этого core::sync::atomic и барьеры.

В настоящем встраиваемом коде эти unsafe пишет генератор: svd2rust превращает SVD-описание микроконтроллера в PAC-крейт с типизированным API, где «записать в неверный бит» — ошибка компиляции; поверх ложатся HAL и embedded-hal. Подробно — в «The Embedded Rust Book» и в статье «GPIO и периферия».

Типичные грабли

unsafe ради скорости без замера. Самое частое и самое бессмысленное. get_unchecked вместо индексации почти никогда не даёт измеримого выигрыша: проверка границ — сравнение и предсказанный переход, а в цикле по итератору её обычно вообще нет. Сначала профиль, потом chunks_exact и массивы фиксированной длины, и только если не помогло — unsafe с бенчмарком в качестве оправдания.

unsafe impl Send for MyType {} как способ заткнуть компилятор. Компилятор говорил не «оформи иначе», а «у тебя тут возможна гонка данных». unsafe impl — обещание, что синхронизацию вы обеспечили другим способом. Не обеспечили — получите гонку, которую больше никто не найдёт статически.

transmute вместо нормального преобразования. Самая опасная функция в языке: переинтерпретирует байты, проверив только размер, — ни валидности, ни времён жизни. Почти всегда есть замена: as для чисел, f64::from_bits, char::from_u32, str::from_utf8, Box::from_raw, bytemuck/zerocopy для байтовых представлений. transmute времён жизни — отдельный ад, ломающий вариантность.

Ссылка из указателя с потолочным временем жизни. unsafe { &*ptr } создаёт &T со временем жизни, выведенным из контекста, — часто 'static. Компилятор поверит и молча разрешит вернуть его наружу. Всегда привязывайте время жизни явным параметром, как в borrow_c_str выше.

Промежуточная ссылка на packed и неинициализированное. &mut packed.field as *mut _ — UB: &mut на невыровненное поле уже нарушает валидность. Нужно &raw mut packed.field и read_unaligned/write_unaligned. Для неинициализированной памяти — только MaybeUninit, никогда mem::uninitialized.

Забытая проверка на ноль. C возвращает NULL при ошибке штатно, а *ptr без is_null() — не «упадёт с segfault», а UB со всеми правами оптимизатора. Лучший приём — заворачивать в Option<NonNull<T>> сразу на входе.

Утечка вместо освобождения при ошибке. Ранний выход между Box::into_raw и парным from_raw — утечка. Утечка безопасна, но её надо видеть: cargo miri test умеет их ловить, а на границе полезна Drop-обёртка над сырым указателем — RAII работает и в FFI.

CString как временное значение. CString::new(x).unwrap().as_ptr() — висячий указатель в момент завершения выражения.

Ожидание, что паника «просто вернётся» из колбэка. Она сделает abort. catch_unwind на каждой extern "C" функции — правило без исключений.

unsafe, размазанный по проекту. Двадцать блоков в пятнадцати файлах проверить невозможно: инварианты одного зависят от другого, полного доказательства не существует ни для одного. Один модуль, один тип, одна ответственность.

Цена: честно

Аудит дороже написания. Строка unsafe пишется за минуту, а живёт годами, и каждое её изменение требует перечитать все инварианты заново. Считать unsafe-код надо не в строках, а в «сколько инвариантов я обещаю поддерживать вечно». Это очень похоже на стоимость владения кодом на C — потому что это и есть код на C, только с лучшими инструментами.

Компилятор перестаёт помогать именно там, где сложнее всего. Внутри unsafe-модуля вы возвращаетесь в мир, где ошибка нелокальна и проявляется через сорок минут в другом месте. Ощущение «Rust безопасный» здесь работает против вас: расслабленность в unsafe-коде опаснее, чем в C, где вы хотя бы настороже.

FFI обнуляет часть выигрыша. Если программа — тонкая обёртка над большой C-библиотекой, безопасность памяти всей системы определяется этой библиотекой, а не Rust. Переписав main вокруг OpenSSL, вы получили не memory safety, а типы, Result и cargo. Это немало, но формулировать надо честно.

Сборка становится настоящей болью. Как только появился build.rs с cc или pkg-config, вы приобрели зависимость от системного компилятора, заголовков и путей. Кросс-компиляция, статическая линковка, musl, Windows, воспроизводимость — всё ломается именно здесь. Чистый Rust-проект собирается одной командой на любой платформе; проект с FFI — нет.

Где unsafe избыточен. Веб-сервисы, CLI, обработка данных, любая бизнес-логика. Если в прикладном крейте появился unsafe, в 95% случаев это непонятая ошибка borrow checker (лечится Rc/RefCell/ареной), преждевременная оптимизация (лечится бенчмарком) или изобретение велосипеда вместо готового крейта. unsafe_code = "forbid" там — не догматизм, а экономия времени ревьюеров.

Где он оправдан. Обёртки над ОС и C-библиотеками (libc, rustix, windows-sys); структуры данных, которые модель владения не выражает (интрузивные списки, lock-free очереди, арены); горячие места с доказанным профилем; регистры устройств; аллокаторы и рантаймы. Заметьте: это всё библиотечный код за стабильным API — ровно так устроена сама std.

Связь с соседними треками

Мини-итог

  • unsafe ничего не отключает: владение, времена жизни и типы работают внутри блока полностью. Он добавляет пять возможностей и переносит бремя доказательства с компилятора на вас.
  • UB — не «упадёт», а разрешение компилятору считать, что такого не бывает. Программа с UB может работать годами и сломаться от обновления тулчейна.
  • Различайте валидность (нарушать нельзя никогда) и безопасность (можно временно, внутри модуля, отвечающего за тип).
  • Единственная работающая архитектура: узкое unsafe-ядро плюс безопасный API, который невозможно применить неправильно. Некорректный вход должен давать панику или Result, а не UB.
  • Каждый блок — с // SAFETY: по пунктам, каждая unsafe fn — с секцией # Safety. Это проверяется линтами, а не доброй волей.
  • Сырой указатель — это адрес плюс provenance. Круг через usize теряет права; &raw const/&raw mut нужны, чтобы не создавать промежуточную ссылку.
  • В FFI обязательны #[repr(C)], типы из core::ffi, unsafe extern "C" и #[unsafe(no_mangle)] в редакции 2024.
  • Строки — главный источник ошибок на границе: нуль-терминация, O(n) на strlen и to_str, время жизни, которое привязывают руками, и смертельный CString::new(..).as_ptr().
  • Владение через границу: кто выделил — тот и освобождает. Box::into_rawBox::from_raw, CString::into_rawCString::from_raw, парная *_free на каждый отданный указатель.
  • Паника не должна пересекать extern "C": с 1.81 это abort, а вежливая библиотека ловит её catch_unwind и возвращает код.
  • Проверяйте инструментами: Miri для своего unsafe-ядра, санитайзеры и фаззинг для границы с C, unsafe_code = "forbid" во всех остальных крейтах.
  • В прикладном коде unsafe почти всегда лишний. Его настоящее место — библиотеки: обёртки над ОС и C, структуры данных, драйверы.

Источники

Что дальше

Тестирование и документация: тесты, doc-тесты, бенчмарки, property-based — мы только что оказались в зоне, где компилятор ничего не доказывает и единственным способом узнать правду остаются инструменты. Следующая глава разбирает их системно: как устроен cargo test изнутри, почему doc-тесты в Rust — часть контракта, а не украшение, как property-based и фаззинг ищут входные данные, о которых вы не подумали, и как Miri, санитайзеры и loom закрывают ровно те классы ошибок, которые остались после этой статьи.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов