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

FFI на практике в Rust

Разработчику Архитектору

FFI на практике в Rust

Что такое FFI и зачем он нужен

FFI (Foreign Function Interface) — способ вызывать код, собранный другим компилятором (чаще всего C или C++), из Rust и наоборот. Так подключают:

  • системные библиотеки ОС (libc);
  • старые SDK и DLL;
  • драйверы и игровые движки;
  • ускоренные куски на C внутри Rust-проекта.

На границе языков компилятор Rust перестаёт проверять контракт: неверный тип, «битый» указатель или несовпадение соглашения о вызовах — неопределённое поведение (как в C). Поэтому весь 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;
}

Шаг 2. Сборка C в build.rs

fn main() {
cc::Build::new().file("native/add.c").compile("native_add");
println!("cargo:rerun-if-changed=native/add.c");
}

Crate cc вызывает системный компилятор C и сообщает Cargo имя статической библиотеки native_add для линковки.

Cargo.toml:

[build-dependencies]
cc = "1"

Шаг 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

Структуры — #[repr(C)]

Rust по умолчанию может переупорядочить поля struct для оптимизации. C ожидает фиксированный layout в памяти.

#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}

#[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 уже есть байт 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);

Паника через 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 только в одном модуле.


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.

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).