Перейти к основному содержимому

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

  1. Весь unsafe в одном-двух модулях с тестами.
  2. build.rs объявляет rerun-if-changed для исходников C.
  3. CI собирает нужные платформы (Linux / Windows / macOS).
  4. Для критичных обёрток — sanitizers / miri там, где применимо.
  5. В документации указано: кто освобождает память и из какого потока можно вызывать API.

Контракт владения памятью

На FFI-границе заранее фиксируют простой и однозначный контракт:

  1. кто выделяет память;
  2. кто освобождает память;
  3. в каком модуле расположены into_raw и from_raw;
  4. можно ли передавать указатель между потоками.

Практическое правило: "кто выделил, тот и освобождает" плюс отдельные функции create_* и destroy_*. Это снижает риск повреждения кучи между разными аллокаторами.


FFI и обработка ошибок

Через C-ABI удобно передавать ошибки двумя способами:

  • код возврата (0 — успех, !=0 — ошибка);
  • структура результата, где есть status и message.

Внутри Rust такие значения превращают в Result<T, E>, чтобы остальной код работал в привычном стиле (обработка ошибок). Важно документировать все коды и их значение, иначе интеграция быстро превращается в угадывание.


Минимальный набор тестов для FFI

  • unit-тесты safe-обёрток;
  • интеграционный тест сборки build.rs;
  • проверка, что строки и структуры корректно ходят через границу;
  • негативные сценарии — null-pointer, некорректная длина буфера, ошибка нативной функции.

Для таких тестов хорошо подходит связка из Тестирования и CI-сборки на нескольких ОС.


Связанные материалы