FFI на практике в Rust
FFI на практике в Rust
Что такое FFI и зачем он нужен
Когда проект доходит до интеграций с "чужим" нативным кодом, появляется тема FFI. Это частый практический шаг для системного софта, SDK и legacy-библиотек.
FFI (Foreign Function Interface) — способ вызывать код, собранный другим компилятором (чаще всего C или C++), из Rust и наоборот. Через FFI обычно подключают:
- системные библиотеки ОС (
libc); - старые SDK и DLL;
- драйверы и игровые движки;
- ускоренные куски на C внутри Rust-проекта.
На границе языков компилятор Rust перестаёт проверять контракт: неверный тип, "битый" указатель или несовпадение соглашения о вызовах дают неопределённое поведение. Поэтому весь unsafe лучше держать в тонком слое, а наружу отдавать обычный safe API с Result, документированными предусловиями и тестами.
Концепции: справочник, FFI · системное программирование · сборка: Cargo и build.rs.
Два направления
| Направление | Задача |
|---|---|
| C → Rust | В Rust объявляют extern "C" { fn c_func(...); } и линкуют .o / .a через build.rs |
| Rust → C | Экспортируют #[no_mangle] pub extern "C" fn ... и собирают cdylib |
extern "C" — соглашение о вызове (ABI): как аргументы лежат в регистрах/стеке. Это стабильный контракт для линковщика; речь о "диалекте C" как о бинарном интерфейсе, а не о синтаксисе языка.
Минимальный пример — вызов C из Rust
Шаг 1. Функция на C
native/add.c:
int add(int a, int b) {
return a + b;
}
Разбор:
- Объявляется функция
addна C с двумя целочисленными аргументами. - Тип
intна стороне C должен быть согласован с типом, который вы объявите в Rust (c_int). return a + b;возвращает сумму аргументов по значению.- Такая функция имеет простой ABI-контракт и удобна как первый FFI-пример.
- После компиляции символ
addдолжен быть доступен линковщику Rust.
Шаг 2. Сборка C в build.rs
fn main() {
cc::Build::new().file("native/add.c").compile("native_add");
println!("cargo:rerun-if-changed=native/add.c");
}
Разбор:
build.rsвыполняется Cargo перед сборкой основного Rust-кода.cc::Build::new()создаёт конфигурацию компиляции C-источников..file("native/add.c")добавляет конкретный C-файл в сборку..compile("native_add")собирает статическую библиотеку с указанным именем.println!("cargo:rerun-if-changed=...")сообщает Cargo пересобирать проект при изменении C-файла.
Crate cc вызывает системный компилятор C и сообщает Cargo имя статической библиотеки native_add для линковки.
Cargo.toml:
[build-dependencies]
cc = "1"
Разбор:
- Секция
build-dependenciesиспользуется только скриптомbuild.rs. cc = "1"подключает crate, который умеет вызывать системный C-компилятор.- Эта зависимость не попадает в runtime-бинарник приложения.
- Версия указывает совместимый диапазон
1.x. - Без этой записи код
cc::Buildвbuild.rsне скомпилируется.
Шаг 3. Объявление и safe-обёртка в Rust
src/lib.rs:
use std::ffi::c_int;
extern "C" {
fn add(a: c_int, b: c_int) -> c_int;
}
/// Safe-обёртка: контракт C-функции проверен вручную один раз.
pub fn add_safe(a: i32, b: i32) -> i32 {
unsafe { add(a, b) }
}
Разбор:
- Блок
extern "C" { ... }— "эти символы придут из нативной библиотеки". - Вызов
add(...)внутриunsafe { }— программист берёт на себя ответственность за корректность сигнатуры. add_safe— то, что видят остальные модули: обычная функция безunsafeу вызывающего.
Правила хорошей обёртки:
- задокументировать предусловия (указатель не null, длина буфера достаточна);
- наружу не отдавать "голые"
*mut Tс неясным временем жизни; - коды ошибок C переводить в
Result.
Проверка:
cargo test
Разбор:
- Команда собирает проект вместе с FFI-связкой и запускает тесты.
- Это быстрый способ убедиться, что линковка с C-функцией работает корректно.
- Если есть ошибка ABI или линковки, она проявится уже на этапе сборки/запуска теста.
- Тесты желательно включать и для safe-обёртки, и для граничных случаев.
- Для FFI такой smoke-прогон стоит делать после каждого изменения
build.rsили C-кода.
Unit-тест safe-обёртки:
#[test]
fn add_safe_works() {
assert_eq!(5, add_safe(2, 3));
}
Разбор:
- Тест проверяет, что Rust-обёртка корректно вызывает C-функцию.
assert_eq!(5, add_safe(2, 3))фиксирует ожидаемый контракт на границе FFI.- Если сигнатура C изменится, тест быстро покажет регрессию.
- Такие тесты держат
unsafeизолированным и документированным. - Для критичных обёрток добавляют и негативные кейсы (переполнение, null).
Структуры — #[repr(C)]
Rust по умолчанию может переупорядочить поля struct для оптимизации. C ожидает фиксированный layout в памяти.
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
Разбор:
#[repr(C)]фиксирует layout структуры по правилам C ABI.- Это гарантирует предсказуемый порядок и выравнивание полей при обмене с C-кодом.
- Поля
xиyтипаf64соответствуютdoubleв C. pubделает поля доступными внешнему коду и другим модулям.- Без
repr(C)компилятор Rust может изменить layout, что сломает FFI-контракт.
#[repr(C)] — порядок и выравнивание полей как у C-структуры. Без этого FFI с C сломается молча.
Строки через границу
Тип &str в Rust нельзя "просто передать" в C: у C-строки другой формат (часто указатель на байты + соглашение про NUL-терминатор).
Используют:
CString— владеющая копия с завершающим\0для передачи в C;CStr— просмотр C-строки по*const c_charпри вызове из C.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
pub fn to_c_string(s: &str) -> CString {
CString::new(s).expect("строка без внутреннего NUL")
}
pub unsafe fn from_c_str(ptr: *const c_char) -> Option<&'static str> {
if ptr.is_null() {
return None;
}
CStr::from_ptr(ptr).to_str().ok()
}
Разбор:
-
CString::new(s)создаёт владеющую C-совместимую строку с завершающим\0. -
expect(...)здесь учебный: в production лучше вернутьResult. -
from_c_strпомеченunsafe, потому что нельзя автоматически проверить валидностьptr. -
ptr.is_null()отсекает очевидно неверный указатель. -
CStr::from_ptr(ptr).to_str().ok()превращает C-строку в UTF-8&str, если байты корректны. -
CString::newвернёт ошибку, если внутриsуже есть байт0— в C это конец строки. -
from_c_strпомеченunsafe: вы обязаны знать, чтоptrвалиден и живёт достаточно долго. Lifetime'staticоправдан только если C гарантирует вечное хранение (литерал, статический буфер); иначе lifetime должен быть параметром вашей обёртки.
Экспорт Rust для C
src/lib.rs:
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]— имя символа в.so/.dllбудетrust_add, а не искажённое Rust-имя.pub extern "C"— функция видна линковщику с C-ABI.
Cargo.toml:
[lib]
crate-type = ["cdylib", "rlib"]
cdylib— динамическая библиотека для линковки из C/C++.rlib— чтобы тот же crate можно было использовать из Rust.
В C объявляют:
int rust_add(int a, int b);
Разбор:
- Это C-прототип функции, экспортированной из Rust-библиотеки.
- Имя
rust_addдолжно совпадать с символом из Rust (#[no_mangle] extern "C"). - Сигнатура по типам обязана быть ABI-совместимой между языками.
- По этому объявлению C-компилятор понимает, как вызывать внешнюю функцию.
- Реальная реализация будет найдена линковщиком в
.dll/.so/.dylib.
Паника через FFI — неопределённое поведение для вызывающего C. На границе ловят catch_unwind или возвращают код ошибки; C-стек нельзя "ронять" паникой Rust.
bindgen — заголовки .h → Rust
Для больших SDK вручную не пишут сотни extern. bindgen читает .h и генерирует модуль с объявлениями.
build.rs (упрощённо):
fn main() {
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen failed");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out.join("bindings.rs"))
.unwrap();
}
lib.rs:
mod ffi {
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
Сгенерированный код остаётся unsafe. Поверх него пишут ручные safe-типы-обёртки: bindgen не делает API безопасным автоматически.
cbindgen — Rust → заголовок C
Если ядро на Rust, а потребители на C++, cbindgen сканирует pub extern "C" и генерирует .h для их компилятора.
Ошибки на границе (что ломается чаще всего)
| Проблема | Последствие |
|---|---|
Неверный repr или размер поля | чтение "чужой" памяти |
| Use-after-free указателя | UB |
| Паника в callback, вызванном из C | крах процесса |
free в C для памяти, выделенной аллокатором Rust | повреждение кучи |
Решение — явные пары create / destroy, документированный владелец буфера, симметричные Box::into_raw / Box::from_raw только в одном модуле.
Передача объекта Rust в C и обратно:
#[no_mangle]
pub extern "C" fn note_create(text: *const c_char) -> *mut Note {
let s = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
Box::into_raw(Box::new(Note { text: s }))
}
#[no_mangle]
pub extern "C" fn note_destroy(ptr: *mut Note) {
if !ptr.is_null() {
unsafe { drop(Box::from_raw(ptr)) };
}
}
Разбор:
note_createсоздаёт Rust-объект и отдаёт C указатель черезBox::into_raw.CStr::from_ptrчитает C-строку; операцияunsafe, нужны гарантии валидностиtext.note_destroyпринимает тот же указатель и освобождает память черезBox::from_raw.- Проверка
!ptr.is_null()защищает от двойного освобождения наnull. - Правило "создал в Rust — уничтожай в Rust" снижает риск повреждения кучи.
Безопасный вызов Rust из C при возможной panic:
use std::panic::catch_unwind;
#[no_mangle]
pub extern "C" fn rust_add_safe(a: i32, b: i32) -> i32 {
match catch_unwind(|| a + b) {
Ok(v) => v,
Err(_) => -1, // код ошибки для C
}
}
Разбор:
catch_unwindперехватывает panic до выхода через FFI-границу.- При успехе возвращается обычный результат
a + b. - При panic C получает согласованный код ошибки (
-1), а не UB. - Такой паттерн обязателен для callback-функций, вызываемых из C.
- Коды ошибок нужно задокументировать в заголовке
.h.
Windows и COM
Для Win32 и WinRT используют crate windows / windows-sys — тысячи extern уже сгенерированы. Прикладной код всё равно оборачивает в safe-типы.
Выбор GUI-стека — GUI на Windows.
Чек-лист перед merge
- Весь
unsafeв одном-двух модулях с тестами. build.rsобъявляетrerun-if-changedдля исходников C.- CI собирает нужные платформы (Linux / Windows / macOS).
- Для критичных обёрток — sanitizers / miri там, где применимо.
- В документации указано: кто освобождает память и из какого потока можно вызывать API.
Контракт владения памятью
На FFI-границе заранее фиксируют простой и однозначный контракт:
- кто выделяет память;
- кто освобождает память;
- в каком модуле расположены
into_rawиfrom_raw; - можно ли передавать указатель между потоками.
Практическое правило: "кто выделил, тот и освобождает" плюс отдельные функции create_* и destroy_*. Это снижает риск повреждения кучи между разными аллокаторами.
FFI и обработка ошибок
Через C-ABI удобно передавать ошибки двумя способами:
- код возврата (
0— успех,!=0— ошибка); - структура результата, где есть
statusиmessage.
Внутри Rust такие значения превращают в Result<T, E>, чтобы остальной код работал в привычном стиле (обработка ошибок). Важно документировать все коды и их значение, иначе интеграция быстро превращается в угадывание.
Минимальный набор тестов для FFI
- unit-тесты safe-обёрток;
- интеграционный тест сборки
build.rs; - проверка, что строки и структуры корректно ходят через границу;
- негативные сценарии — null-pointer, некорректная длина буфера, ошибка нативной функции.
Для таких тестов хорошо подходит связка из Тестирования и CI-сборки на нескольких ОС.