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: безопасная память без сборщика мусора». Записаться
Комментарии (3)

Tam1SH
27.01.2026 21:25Раздел про "NonNull и PhantomData" просто неверен, в виду того, что raw pointer по дефолту является !Send + !Sync и лучше уж следовало написать про то, как реализуется Sync и как бороться с !Send.
Jijiki
тут есть нюанс если мы используем
std::FFI::Cstring
получается если библиотека дожила до раста её придётся переделывать, ведь проще принимать числа чем мучаться со строками. Правда если строки уже известны и определены, если строки неопределены то наверно по старинке
я пользуюсь rust-sdl2 и gl и не пользуюсь ffi строками, в пользу чисел
ну да, как бы получается пишем тот самый конфиг для шейдера, но он более явный за счет явных енамов и в коде состояния более читаемые