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, обработке данных — ответ почти всегда «можно».
C, syscall, регистр?"} B -- да --> C["unsafe неизбежен.
Задача — сузить обёртку"] B -- нет --> D{"Компилятор отверг код,
который я считаю верным?"} D -- да --> J{"Хватит Rc, RefCell,
арены с индексами?"} J -- да --> F["Не пишите unsafe"] J -- нет --> K{"Есть готовый крейт
с проверенным unsafe?"} K -- да --> L["Возьмите его: чужой
аудит дешевле своего"] K -- нет --> C D -- нет --> E{"Есть профиль, где видна
проверка границ?"} E -- нет --> F E -- да --> H["Сначала итераторы,
chunks_exact, массивы"] H --> I{"Помогло?"} I -- да --> F I -- нет --> C
Что 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(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» типом, а не соглашением в комментарии.
Строки: единственный по-настоящему коварный случай
Всё остальное на границе — арифметика смещений, а строки — конфликт двух несовместимых моделей. 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. Кросс-компиляция ломается почти всегда именно здесь — см. «Установку и инструментарий».
в Result, а не в UB S->>U: as_ptr, len, адрес out U->>C: stats_mean Note over U,C: Здесь гарантии Rust заканчиваются C->>C: Читает len элементов C-->>U: код возврата, out записан U->>S: код i32 превращается в Result Note over C,K: Если C внутри позовёт write
или сделает longjmp — Rust
об этом не узнает
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_raw ↔ Box::from_raw, CString::into_raw ↔ CString::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: те же грабли, но без страховки. - Как символы находят друг друга — «Сборка и линковка»: статические и динамические библиотеки, манглинг имён,
-sys-крейты изнутри. - С кем на самом деле говорит
extern— «Системные вызовы и IPC» и «Управление памятью»: почемуmmapвозвращает то, что возвращает, и почему аллокаторы нельзя смешивать. - Стоит ли оно того — «Профилирование CPU» и «Память и аллокации»: без них разговор про «
unsafeради скорости» смысла не имеет. - Зачем индустрия за это взялась — «Современные альтернативы C» и «Безопасное программирование». Где
unsafe— норма жизни — «C для встраиваемых».
Мини-итог
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_raw↔Box::from_raw,CString::into_raw↔CString::from_raw, парная*_freeна каждый отданный указатель. - Паника не должна пересекать
extern "C": с 1.81 этоabort, а вежливая библиотека ловит еёcatch_unwindи возвращает код. - Проверяйте инструментами: Miri для своего
unsafe-ядра, санитайзеры и фаззинг для границы с C,unsafe_code = "forbid"во всех остальных крейтах. - В прикладном коде
unsafeпочти всегда лишний. Его настоящее место — библиотеки: обёртки над ОС и C, структуры данных, драйверы.
Источники
- The Rustonomicon — основная книга по
unsafe; главы «Meet Safe and Unsafe» и «FFI» — прямое продолжение этой статьи. - The Reference: Behavior considered undefined — список UB; держите открытым, пока пишете
unsafe. Раскладка типов — Type layout и Nomicon: Other reprs. - Unsafe Code Guidelines Reference — рабочая версия того, что ещё не зафиксировано окончательно.
- Ralf Jung, «Two Kinds of Invariants» и «Pointers Are Complicated II» — лучшее объяснение инвариантов и provenance от автора модели памяти Rust.
- RustBelt (Jung et al., POPL 2018) — формальное доказательство того, что
unsafe-ядроstdкорректно инкапсулировано; Stacked Borrows и Tree Borrows — модели алиасинга, по которым судит Miri. - The Rust FFI Omnibus — короткие рабочие рецепты вызова Rust из Python, Ruby, C, Java, C#.
- Cliff L. Biffle, «Learn Rust the Dangerous Way» — перевод оптимизированного C в Rust шаг за шагом, от
unsafeк безопасному коду; лучшее чтение по теме. - Jon Gjengset, «Rust for Rustaceans» — главы «Unsafe Code» и «Foreign Function Interfaces». Mara Bos, «Rust Atomics and Locks» — свободно доступная книга о том, как из
unsafeстроятсяMutexи каналы. - The Embedded Rust Book —
volatile, PAC, регистры,no_std. - Редакция 2024: unsafe attributes, unsafe extern blocks, RFC 2585, RFC 2945.
- Инструменты: bindgen, cbindgen, cxx, PyO3, UniFFI, zerocopy, bytemuck, libc, cargo-geiger, RustSec.
Что дальше
Тестирование и документация: тесты, doc-тесты, бенчмарки, property-based — мы только что оказались в зоне, где компилятор ничего не доказывает и единственным способом узнать правду остаются инструменты. Следующая глава разбирает их системно: как устроен cargo test изнутри, почему doc-тесты в Rust — часть контракта, а не украшение, как property-based и фаззинг ищут входные данные, о которых вы не подумали, и как Miri, санитайзеры и loom закрывают ровно те классы ошибок, которые остались после этой статьи.