Foreign Function Interface — это граница, на которой Rust перестаёт быть Rust: компилятор больше не может опираться на систему типов, borrow checker и свои гарантии, потому что по другую сторону может быть C, C++, Python-интерпретатор или системный вызов ядра. Именно на FFI-границе безопасность становится ответственностью программиста, а не компилятора.
Ключевой факт, который часто упускают: у Rust нет стабильного ABI. Layout структур с repr(Rust) (используемый по умолчанию) не специфицирован — компилятор волен переставлять поля, добавлять padding, менять порядок в зависимости от версии, оптимизаций и даже от прогона компиляции. Это значит, что два модуля, скомпилированных разными версиями rustc, в общем случае не могут безопасно обмениваться структурами напрямую, если только они не собраны из одного исходника одной версией компилятора.
repr(Rust) и обычные трейты; extern "C" нужен именно на границе с другим языком или другой единицей компиляции с иным компилятором/версией.Блок extern "C" объявляет сигнатуры функций из внешней библиотеки — компилятор верит объявлению на слово, поэтому несовпадение сигнатур с реальной C-функцией — это UB, которое не поймает ни один тест, пока платформа "случайно" не изменит layout стека.
// Импорт функций из libc / внешней C-библиотеки
extern "C" {
fn abs(input: i32) -> i32;
fn strlen(s: *const c_char) -> usize;
}
fn main() {
unsafe {
println!("{}", abs(-3));
}
}
Экспорт Rust-функций для вызова из C требует двух вещей: фиксированной ABI-конвенции и отключения name mangling, иначе символ в скомпилированной библиотеке будет иметь непредсказуемое имя вроде _ZN4crate3foo17h...E.
#[unsafe(no_mangle)]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
Соответствующий C-заголовок для потребителя:
// mylib.h
int32_t rust_add(int32_t a, int32_t b);
crate-type = ["staticlib"]) — генерирует .a/.lib, линкуется в конечный C/C++-бинарь на этапе сборки. Хорош для встраивания в существующую систему сборки.crate-type = ["cdylib"]) — генерирует .so/.dylib/.dll с C-совместимым ABI и без Rust-метаданных. Стандарт для распространения библиотек, вызываемых из Python/Node/etc.build.rs используется crate cc (компиляция .c-файлов) и crate pkg-config (поиск системных библиотек через pkg-config).// build.rs
fn main() {
cc::Build::new()
.file("vendor/hash.c")
.include("vendor/include")
.compile("hash");
// Либо через системную библиотеку:
let lib = pkg_config::probe("openssl").unwrap();
for path in lib.link_paths {
println!("cargo:rustc-link-search=native={}", path.display());
}
}
По умолчанию layout структур в Rust (repr(Rust)) не гарантирован и может отличаться между сборками. Атрибут #[repr(C)] фиксирует layout по правилам C: поля располагаются в порядке объявления, с padding для выравнивания, как это сделал бы компилятор C на данной платформе.
#[repr(C)]
pub struct Point3D {
pub x: f64,
pub y: f64,
pub z: f64,
}
// Соответствующая C-структура:
// typedef struct { double x, y, z; } Point3D;
#[repr(C)]
pub enum Status {
Ok = 0,
NotFound = 1,
Error = 2,
}
// эквивалент C enum с фиксированной шириной int
| repr | Layout | Когда применять |
|---|---|---|
repr(Rust) | Не специфицирован, может меняться между сборками | По умолчанию для внутреннего Rust-кода, никогда для FFI |
repr(C) | Порядок полей как объявлено + padding по правилам C | Структуры/enum, пересекающие FFI-границу |
repr(packed) | Без padding вообще (align = 1) | Бинарные протоколы, сетевые пакеты; риск unaligned access |
repr(transparent) | Layout идентичен единственному непустому полю | Newtype-обёртки над примитивом/указателем для FFI (например, handle) |
#[repr(transparent)] особенно полезен для непрозрачных handle-типов — Rust-обёртка гарантированно имеет тот же layout, что и указатель внутри:
#[repr(transparent)]
pub struct DbHandle(*mut c_void);
Ручное переписывание C-заголовков в Rust-объявления (и наоборот) — источник систематических багов при рассинхронизации. Экосистема решает это в двух направлениях.
bindgen парсит C/C++ заголовки через libclang и генерирует Rust-объявления extern "C", структур с #[repr(C)] и констант. Используется, когда Rust-код является потребителем существующей C-библиотеки.
// build.rs
fn main() {
println!("cargo:rustc-link-lib=curl");
println!("cargo:rerun-if-changed=wrapper.h");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("couldn't write bindings");
}
// в lib.rs — сгенерированный код подключается как модуль
mod ffi {
#![allow(non_camel_case_types, non_snake_case, dead_code)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
cbindgen решает обратную задачу: сканирует Rust-crate (обычно cdylib) и генерирует .h-файл для C/C++-потребителей вашей Rust-библиотеки. Запускается как отдельный шаг сборки (не в build.rs, а обычно в CI или вручную через CLI/скрипт), чтобы не создавать циклическую зависимость.
// cbindgen.toml
language = "C"
header = "/* Auto-generated by cbindgen. Do not edit. */"
include_guard = "MYLIB_H"
// Сгенерированный mylib.h (фрагмент) для функции rust_add выше
#ifndef MYLIB_H
#define MYLIB_H
#include <stdint.h>
int32_t rust_add(int32_t a, int32_t b);
#endif
| bindgen | cbindgen | |
|---|---|---|
| Направление | C header → Rust | Rust → C header |
| Когда нужен | Rust-код вызывает существующую C-библиотеку | Rust-библиотека публикуется для C/C++-потребителей |
| Зависимость | libclang во время сборки | Только парсинг Rust AST (syn), без libclang |
| Типичный запуск | Внутри build.rs на каждой сборке | Отдельный шаг публикации/релиза |
На FFI-границе владение перестаёт быть выражено типами — Box<T>, Vec<T>, лайфтаймы не существуют для C. Единственный универсальный язык — сырые указатели *mut T / *const T, и передача владения должна быть закреплена вручную через пару функций "создать/освободить".
pub struct Counter {
value: i64,
}
#[unsafe(no_mangle)]
pub extern "C" fn counter_new() -> *mut Counter {
// Box уходит в C-мир — Rust больше не следит за его временем жизни
Box::into_raw(Box::new(Counter { value: 0 }))
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_increment(ptr: *mut Counter) {
if ptr.is_null() {
return; // всегда проверяем null на входе
}
unsafe { (*ptr).value += 1; }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_free(ptr: *mut Counter) {
if !ptr.is_null() {
// Box::from_raw возвращает владение — Drop отработает при выходе из скоупа
unsafe { drop(Box::from_raw(ptr)); }
}
}
counter_free дважды на одном указателе — либо забудет вызвать вовсе — это неопределённое поведение или утечка. Rust не может проконтролировать дисциплину вызова на C-стороне: контракт "ровно один free на каждый new" держится только на документации и дисциплине автора биндинга.
Критичное отличие: Rust String/&str хранят длину явно и допускают внутренние нулевые байты; C-строки — это NUL-terminated массивы char. Прямое приведение типов между ними — UB.
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
// Rust String -> C string (передача В C)
#[unsafe(no_mangle)]
pub extern "C" fn greeting() -> *mut c_char {
let s = CString::new("hello from rust").unwrap();
s.into_raw() // владение передано наружу, нужна парная free-функция
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn greeting_free(s: *mut c_char) {
if !s.is_null() {
unsafe { drop(CString::from_raw(s)); }
}
}
// C string -> Rust &str (приём ИЗ C, без копирования)
pub unsafe fn read_c_string(ptr: *const c_char) -> Result<String, std::str::Utf8Error> {
let c_str = unsafe { CStr::from_ptr(ptr) };
Ok(c_str.to_str()?.to_owned())
}
into_raw), обязана иметь парную функцию, принимающую его обратно (from_raw). Асимметрия между "выдать" и "забрать" — самый частый источник утечек и double-free в FFI-коде.
// ❌ UB: указатель на данные из стекового фрейма, который уже уничтожен
#[unsafe(no_mangle)]
pub extern "C" fn bad_get_buffer() -> *const u8 {
let local = [1u8, 2, 3];
local.as_ptr() // local уничтожается на выходе из функции — dangling pointer
}
Идиоматический подход — не раздавать unsafe extern "C" функции напрямую пользователям crate, а спрятать их за safe API, где инварианты (не-null, корректный lifetime, единоличное владение) проверяются один раз, в одном месте.
// Условная C-библиотека с handle-based API:
// DbHandle* db_open(const char* path);
// int db_query(DbHandle* h, const char* sql, char** out_result);
// void db_close(DbHandle* h);
mod ffi {
use std::os::raw::{c_char, c_int};
#[repr(C)]
pub struct DbHandle { _private: [u8; 0] } // непрозрачный тип
extern "C" {
pub fn db_open(path: *const c_char) -> *mut DbHandle;
pub fn db_query(h: *mut DbHandle, sql: *const c_char, out: *mut *mut c_char) -> c_int;
pub fn db_close(h: *mut DbHandle);
}
}
/// Safe-обёртка: RAII через Drop, все инварианты проверены здесь
pub struct Database {
handle: *mut ffi::DbHandle, // приватное поле — наружу не течёт
}
impl Database {
pub fn open(path: &'_ str) -> Result<Self, DbError> {
let c_path = CString::new(path).map_err(|_| DbError::InvalidPath)?;
// unsafe изолирован внутри конструктора
let handle = unsafe { ffi::db_open(c_path.as_ptr()) };
if handle.is_null() {
return Err(DbError::OpenFailed);
}
Ok(Database { handle })
}
pub fn query(&self, sql: &str) -> Result<String, DbError> {
let c_sql = CString::new(sql).map_err(|_| DbError::InvalidQuery)?;
let mut out: *mut c_char = std::ptr::null_mut();
let rc = unsafe { ffi::db_query(self.handle, c_sql.as_ptr(), &mut out) };
if rc != 0 {
return Err(DbError::QueryFailed(rc));
}
let result = unsafe { CStr::from_ptr(out) }.to_string_lossy().into_owned();
Ok(result)
}
}
/// Drop гарантирует освобождение C-ресурса при любом выходе из скоупа,
/// включая раннюю ошибку и панику при unwind
impl Drop for Database {
fn drop(&mut self) {
unsafe { ffi::db_close(self.handle); }
}
}
// Database не Clone/Copy — единоличное владение handle гарантирует
// отсутствие double-close на уровне типов, а не по договорённости
unsafe-логика (null-проверки, преобразование строк, вызов extern-функций) сосредоточена в нескольких методах. Потребитель crate работает с обычным safe Rust-типом, а RAII через Drop гарантирует освобождение C-ресурса даже при панике во время unwind.
У C нет ни Result, ни исключений в привычном виде — стандартные механизмы сигнализации ошибок ограничены и должны быть явно спроецированы на Rust-типы.
int с соглашением "0 = успех", остальное — код ошибки. Просто, но требует внешнего справочника кодов.errno в POSIX). Подходит, когда нужно передать детальное сообщение без изменения сигнатуры основной функции.// Вариант: код возврата + out-параметр
#[unsafe(no_mangle)]
pub unsafe extern "C" fn parse_config(
input: *const c_char,
out_value: *mut i32,
) -> i32 {
if input.is_null() || out_value.is_null() {
return -1; // код: невалидные аргументы
}
let s = unsafe { CStr::from_ptr(input) };
match s.to_str().ok().and_then(|v| v.parse().ok()) {
Some(value) => {
unsafe { *out_value = value; }
0 // успех
}
None => -2, // код: ошибка парсинга
}
}
Если Rust-функция, вызванная из C, паникует, unwind-механизм пытается размотать стек через C-фреймы — поведение неопределено (в лучшем случае аварийное завершение, в худшем — тихое повреждение состояния). std::panic::catch_unwind обязателен на каждой точке входа, доступной извне.
use std::panic;
#[unsafe(no_mangle)]
pub extern "C" fn rust_divide(a: i32, b: i32) -> i32 {
let result = panic::catch_unwind(|| a / b); // деление на 0 паникует
match result {
Ok(v) => v,
Err(_) => {
eprintln!("panic caught at FFI boundary");
i32::MIN // санитайзинг: возвращаем безопасное значение-сентинел
}
}
}
Для cdylib, публикуемых как библиотеки, часто дополнительно ставят panic = "abort" в профиле релиза: unwind через FFI-границу невозможен в принципе, поэтому дешевле сразу упасть, чем тащить unwind-таблицы, которые всё равно нельзя безопасно использовать за пределами Rust-фреймов.
// Cargo.toml
[profile.release]
panic = "abort"
catch_unwind перестаёт работать вообще (unwind отключён на уровне кодогена), так что этот флаг подходит только если вы гарантируете отсутствие паник на верхнем уровне другими средствами (валидация, Result везде) — либо принимаете abort как единственный сценарий отказа.
#[repr(C)] или #[repr(transparent)]._new/_free, _open/_close).panic = "abort" с полным пониманием последствий.Drop) для любого ресурса, требующего явного освобождения на C-стороне.Send/Sync — если C-библиотека не документирована как thread-safe, синхронизацию нужно обеспечивать на Rust-стороне.soname).