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 имеет два недостатка:

  1. Может быть null (хотя после конструктора мы это проверили)

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