Rust хорош своей безопасностью, но рано или поздно приходится выйти за пределы уютного мирка borrow checker. Нужно подключить проверенную C-библиотеку, использовать системный API или просто переиспользовать существующий код. И тут начинается unsafe.
Правильно приготовленный unsafe позволяет создать безопасный API поверх небезопасного кода, сохранив все гарантии Rust для пользователей библиотеки.
Разберём, как писать FFI-обёртки, которые не подтекают и не падают.
Что такое FFI и зачем unsafe
FFI — механизм вызова функций, написанных на другом языке. В случае Rust это почти всегда C, у него стабильный ABI, и bindgen умеет генерировать объявления автоматически.
Проблема в том, что C ничего не знает про ownership, lifetimes и прочее от Rust. Когда вызываешь C-функцию, компилятор не может проверить:
Валиден ли переданный указатель
Кто владеет памятью — Rust или C
Когда эту память освобождать
Не используется ли указатель после освобождения
Поэтому любой FFI-вызов требует unsafe-блока. Но это не значит, что unsafe должен расползтись по всему коду. Цель — написать тонкий unsafe-слой и обернуть его в безопасный API.
Cвязываем Rust и C
Допустим, есть простая C-библиотека для работы с конфигурацией:
// config.h typedef struct Config Config; Config* config_new(const char* path); const char* config_get(Config* cfg, const char* key); int config_set(Config* cfg, const char* key, const char* value); void config_free(Config* cfg);
Классический паттерн: opaque pointer (непрозрачный указатель), функции-конструктор, методы, деструктор. Встречается в SQLite, OpenSSL, libcurl и сотнях других библиотек.
Первый шаг — объявить внешние функции в Rust:
use std::ffi::{c_char, c_int}; // Opaque type — не знаем внутреннюю структуру #[repr(C)] pub struct Config { _private: [u8; 0], // Zero-sized, непрозрачный } extern "C" { fn config_new(path: *const c_char) -> *mut Config; fn config_get(cfg: *mut Config, key: *const c_char) -> *const c_char; fn config_set(cfg: *mut Config, key: *const c_char, value: *const c_char) -> c_int; fn config_free(cfg: *mut Config); }
extern "C" указывает на C ABI. #[repr(C)] гарантирует C-совместимое расположение в памяти (хотя для opaque type это формальность).
Теперь можно вызывать эти функции, но каждый вызов — unsafe:
unsafe { let path = CString::new("/etc/app.conf").unwrap(); let cfg = config_new(path.as_ptr()); // ... работа с cfg ... config_free(cfg); }
Это работает, но ужасно неудобно и опасно. Забыл вызвать config_free — утечка. Вызвал дважды — double free. Использовал после free — use-after-free. Все классические C-проблемы.
RAII-обёртка: Drop спешит на помощь
В Rust ресурсы освобождаются автоматически через Drop. Обернём сырой указатель в структуру:
pub struct SafeConfig { ptr: *mut Config, } impl SafeConfig { pub fn new(path: &str) -> Result<Self, ConfigError> { let c_path = CString::new(path) .map_err(|_| ConfigError::InvalidPath)?; let ptr = unsafe { config_new(c_path.as_ptr()) }; if ptr.is_null() { return Err(ConfigError::FailedToOpen); } Ok(Self { ptr }) } pub fn get(&self, key: &str) -> Option<String> { let c_key = CString::new(key).ok()?; let value_ptr = unsafe { config_get(self.ptr, c_key.as_ptr()) }; if value_ptr.is_null() { return None; } // Копируем строку из C в Rust String let c_str = unsafe { CStr::from_ptr(value_ptr) }; c_str.to_str().ok().map(String::from) } pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> { let c_key = CString::new(key) .map_err(|_| ConfigError::InvalidKey)?; let c_value = CString::new(value) .map_err(|_| ConfigError::InvalidValue)?; let result = unsafe { config_set(self.ptr, c_key.as_ptr(), c_value.as_ptr()) }; if result == 0 { Ok(()) } else { Err(ConfigError::SetFailed) } } } impl Drop for SafeConfig { fn drop(&mut self) { if !self.ptr.is_null() { unsafe { config_free(self.ptr) }; } } }
Теперь unsafe изолирован внутри методов, а пользователь работает с безопасным API:
fn main() -> Result<(), ConfigError> { let mut config = SafeConfig::new("/etc/app.conf")?; if let Some(value) = config.get("database.host") { println!("Host: {}", value); } config.set("database.port", "5432")?; Ok(()) } // config автоматически освобождается здесь
Утечка памяти невозможна — Drop вызовется при выходе из scope, даже при панике. Double free невозможен — ownership у одного владельца.
NonNull и PhantomData
Предыдущий пример работает, но можно лучше. Сырой указатель *mut Config имеет два недостатка:
Может быть null (хотя после конструктора мы это проверили)
Компилятор считает его
Send + Syncпо умолчанию
NonNull решает первую проблему:
use std::ptr::NonNull; pub struct SafeConfig { ptr: NonNull<Config>, } impl SafeConfig { pub fn new(path: &str) -> Result<Self, ConfigError> { let c_path = CString::new(path) .map_err(|_| ConfigError::InvalidPath)?; let ptr = unsafe { config_new(c_path.as_ptr()) }; let ptr = NonNull::new(ptr) .ok_or(ConfigError::FailedToOpen)?; Ok(Self { ptr }) } pub fn get(&self, key: &str) -> Option<String> { let c_key = CString::new(key).ok()?; // as_ptr() возвращает сырой указатель для FFI let value_ptr = unsafe { config_get(self.ptr.as_ptr(), c_key.as_ptr()) }; // ... остальное без изменений } }
Для второй проблемы — thread safety — используем маркеры. Если C-библиотека не потокобезопасна (а большинство нет), нужно явно запретить Send и Sync:
use std::marker::PhantomData; pub struct SafeConfig { ptr: NonNull<Config>, // Маркер: SafeConfig не Send и не Sync _marker: PhantomData<*mut ()>, }
PhantomData<*mut ()> — zero-cost способ сказать компилятору, что структура ведёт себя как сырой указатель с точки зрения Send/Sync. Теперь попытка отправить SafeConfig в другой поток — ошибка компиляции.
Работа со строками: CString и CStr
В Rust строки — это UTF-8, не null-terminated. В C — null-terminated, кодировка произвольная.
Rust -> C: используем CString
use std::ffi::CString; fn call_c_function(rust_string: &str) { // CString добавляет \0 в конец // Может вернуть ошибку, если в строке есть \0 let c_string = CString::new(rust_string).expect("CString::new failed"); unsafe { some_c_function(c_string.as_ptr()); } // ВАЖНО: c_string живёт до конца scope // Если C-функция сохраняет указатель — проблема! }
Частая ошибка — передать указатель на временный CString:
// НЕПРАВИЛЬНО: dangling pointer! unsafe { some_c_function(CString::new("hello").unwrap().as_ptr()); } // CString уничтожен, указатель невалиден
CString уничтожается в конце выражения, C-функция получает висячий указатель. Всегда сохраняйте CString в переменную.
C -> Rust: используем CStr
use std::ffi::CStr; fn read_c_string(ptr: *const c_char) -> Option<String> { if ptr.is_null() { return None; } // CStr::from_ptr ищет \0 — небезопасно, если его нет let c_str = unsafe { CStr::from_ptr(ptr) }; // to_str() проверяет UTF-8, возвращает Result c_str.to_str().ok().map(String::from) }
Если известна длина строки (из C API), безопаснее использовать from_bytes_with_nul:
fn read_c_string_with_len(ptr: *const c_char, len: usize) -> Option<String> { let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, len + 1) // +1 для \0 }; CStr::from_bytes_with_nul(slice) .ok()? .to_str() .ok() .map(String::from) }
Кто владеет памятью
Самый важный вопрос при работе с FFI: кто отвечает за освобождение памяти?
Вариант 1: C выделяет, C освобождает
Типичный паттерн с opaque pointers. Наша задача вызвать деструктор в Drop:
impl Drop for SafeConfig { fn drop(&mut self) { unsafe { config_free(self.ptr.as_ptr()) }; } }
Вариант 2: Rust выделяет, C использует, Rust освобождает
Когда C-функция принимает указатель на данные, но не сохраняет его:
fn process_data(data: &[u8]) { unsafe { // C читает данные, но не сохраняет указатель c_process(data.as_ptr(), data.len()); } // data живёт, всё хорошо }
Вариант 3: Rust выделяет, C владеет
Опасная ситуация. Rust передаёт владение в C:
fn give_to_c(data: Vec<u8>) { let ptr = data.as_ptr(); let len = data.len(); let cap = data.capacity(); std::mem::forget(data); // Rust "забывает" про data, не вызывает drop unsafe { c_take_ownership(ptr, len, cap); // C теперь владеет памятью и должен вызвать rust_dealloc } } // Функция для C, чтобы вернуть память #[no_mangle] pub extern "C" fn rust_dealloc(ptr: *mut u8, len: usize, cap: usize) { unsafe { let _ = Vec::from_raw_parts(ptr, len, cap); // Vec уничтожается, память освобождается } }
Вариант 4: C выделяет, Rust должен освободить через C
Некоторые C API возвращают строки или буферы, которые нужно освободить специальной функцией:
fn get_error_message() -> String { let ptr = unsafe { c_get_last_error() }; // C выделяет let message = if ptr.is_null() { String::from("Unknown error") } else { let c_str = unsafe { CStr::from_ptr(ptr) }; c_str.to_string_lossy().into_owned() }; if !ptr.is_null() { unsafe { c_free_string(ptr) }; // C освобождает } message }
Никогда не вызывайте libc::free() для памяти, выделенной Rust, и наоборот. У каждого аллокатора свои структуры данных.
Callbacks: когда C вызывает Rust
Многие C-библиотеки используют callback'и. Это требует особой осторожности.
// C API typedef void (*callback_fn)(int status, void* user_data); void register_callback(callback_fn cb, void* user_data);
Обёртка на Rust:
// Функция с C ABI, которую можно передать в C extern "C" fn rust_callback(status: c_int, user_data: *mut c_void) { // Восстанавливаем Rust-замыкание из user_data let closure: &mut Box<dyn FnMut(i32)> = unsafe { &mut *(user_data as *mut Box<dyn FnMut(i32)>) }; closure(status as i32); } pub fn set_callback<F>(mut callback: F) where F: FnMut(i32) + 'static, { // Упаковываем замыкание в Box let boxed: Box<Box<dyn FnMut(i32)>> = Box::new(Box::new(callback)); let user_data = Box::into_raw(boxed) as *mut c_void; unsafe { register_callback(rust_callback, user_data); } // ВАЖНО: кто освободит boxed? // Нужен механизм, чтобы C вызвал очистку, или храним где-то }
Двойной Box (Box<Box<dyn FnMut>>) нужен, потому что Box<dyn FnMut> fat pointer (два слова), а *mut c_void один указатель.
Главная проблема — время жизни замыкания. Если C хранит указатель на callback, Rust не должен освобождать память до окончания использования. Обычно это решается хранением Box в структуре, которая живёт достаточно долго.
Паника через FFI-границу
Паника в Rust и исключения в C — разные механизмы. Паника, пересекающая FFI-границу — undefined behavior.
// НЕПРАВИЛЬНО: паника может пересечь границу extern "C" fn dangerous_callback(data: *mut c_void) { let slice = unsafe { std::slice::from_raw_parts(data as *const u8, 100) }; // Если data невалиден — паника внутри FFI callback println!("{:?}", slice); }
Решение — ловить панику:
extern "C" fn safe_callback(data: *mut c_void) { let result = std::panic::catch_unwind(|| { // Потенциально паникующий код let slice = unsafe { std::slice::from_raw_parts(data as *const u8, 100) }; println!("{:?}", slice); }); if result.is_err() { eprintln!("Panic in callback, recovering"); // Можно установить флаг ошибки, вернуть error code } }
Или использовать #[unwind(abort)] / -C panic=abort, чтобы паника сразу завершала программу.
Bindgen: автоматическая генерация bindings
Писать объявления вручную утомительно и чревато ошибками. bindgen генерирует их из заголовочных файлов:
cargo install bindgen-cli bindgen config.h -o src/bindings.rs
Или через build.rs для авто генерации при сборке:
// build.rs fn main() { println!("cargo:rustc-link-lib=config"); let bindings = bindgen::Builder::default() .header("wrapper.h") .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect("Unable to generate bindings"); let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); bindings .write_to_file(out_path.join("bindings.rs")) .expect("Couldn't write bindings!"); }
// src/lib.rs #![allow(non_upper_case_globals)] #![allow(non_camel_case_types)] #![allow(non_snake_case)] include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
bindgen создаёт низкоуровневые bindings — сырые указатели, extern функции. Безопасную обёртку всё равно придётся писать вручную, но база готова.
Хорошо написанная FFI-обёртка неотличима от нативной Rust-библиотеки. Пользователь получает все гарантии безопасности, а unsafe остаётся такой вот деталькой реализации.

Если вы регулярно выходите в unsafe ради FFI, полезно систематизировать практики: где держать инварианты, как проектировать API вокруг владения, потоков и аллокаторов. На курсе «Rust Developer. Professional» разбирается продвинутый Rust, популярные библиотеки и асинхронность — чтобы писать быстрый код без сюрпризов. Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
3 февраля в 20:00. «Веб-приложения на Rust: как и зачем». Записаться
12 февраля в 20:00. «Безопасная многопоточность: пишем пул потоков». Записаться
19 февраля в 20:00. «Rust: безопасная память без сборщика мусора». Записаться
