← Назад к списку тем

27. FFI и взаимодействие с C

extern "C", #[repr(C)], bindgen/cbindgen, ABI-совместимость, указатели через границу FFI, безопасные обёртки.

Зачем нужен FFI и что такое ABI-контракт

Foreign Function Interface — это граница, на которой Rust перестаёт быть Rust: компилятор больше не может опираться на систему типов, borrow checker и свои гарантии, потому что по другую сторону может быть C, C++, Python-интерпретатор или системный вызов ядра. Именно на FFI-границе безопасность становится ответственностью программиста, а не компилятора.

Ключевой факт, который часто упускают: у Rust нет стабильного ABI. Layout структур с repr(Rust) (используемый по умолчанию) не специфицирован — компилятор волен переставлять поля, добавлять padding, менять порядок в зависимости от версии, оптимизаций и даже от прогона компиляции. Это значит, что два модуля, скомпилированных разными версиями rustc, в общем случае не могут безопасно обмениваться структурами напрямую, если только они не собраны из одного исходника одной версией компилятора.

  • extern "C" — единственный практически стабильный контракт: layout, calling convention и передача аргументов фиксированы платформенным C ABI (System V AMD64, Win64 и т.д.).
  • Любое межъязыковое взаимодействие (C, C++, Python, Node.js, JVM через JNI) в итоге сводится к C ABI как к общему знаменателю.
  • Rust-to-Rust FFI между разными crate в одном workspace не нужен — там работает repr(Rust) и обычные трейты; extern "C" нужен именно на границе с другим языком или другой единицей компиляции с иным компилятором/версией.
🔑 Ключевая идея: FFI — это не "вызов C-функции", а согласие на явный, документированный ABI-контракт. Всё, что не описано этим контрактом (владение памятью, время жизни, потокобезопасность), должно быть закреплено в документации и обёрнуто в safe-код вручную.

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);

Линковка: статическая vs динамическая

  • staticlib (crate-type = ["staticlib"]) — генерирует .a/.lib, линкуется в конечный C/C++-бинарь на этапе сборки. Хорош для встраивания в существующую систему сборки.
  • cdylib (crate-type = ["cdylib"]) — генерирует .so/.dylib/.dll с C-совместимым ABI и без Rust-метаданных. Стандарт для распространения библиотек, вызываемых из Python/Node/etc.
  • Для сборки/линковки внешних C-зависимостей из 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());
    }
}

#[repr(C)]: предсказуемый layout данных

По умолчанию 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
reprLayoutКогда применять
repr(Rust)Не специфицирован, может меняться между сборкамиПо умолчанию для внутреннего Rust-кода, никогда для FFI
repr(C)Порядок полей как объявлено + padding по правилам CСтруктуры/enum, пересекающие FFI-границу
repr(packed)Без padding вообще (align = 1)Бинарные протоколы, сетевые пакеты; риск unaligned access
repr(transparent)Layout идентичен единственному непустому полюNewtype-обёртки над примитивом/указателем для FFI (например, handle)
⚠️ Подводный камень с padding: компилятор может вставлять байты выравнивания между полями структуры. Если Rust-структура и C-структура объявлены "похоже", но с разным порядком полей разной ширины, padding может отличаться, а значит и layout — несмотря на то, что оба помечены как совместимые. Всегда генерируйте одну сторону из другой (bindgen/cbindgen) вместо ручного дублирования.

#[repr(transparent)] особенно полезен для непрозрачных handle-типов — Rust-обёртка гарантированно имеет тот же layout, что и указатель внутри:

#[repr(transparent)]
pub struct DbHandle(*mut c_void);

bindgen и cbindgen: автоматизация вместо ручного дублирования

Ручное переписывание C-заголовков в Rust-объявления (и наоборот) — источник систематических багов при рассинхронизации. Экосистема решает это в двух направлениях.

bindgen — из 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 в C-заголовок

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
bindgencbindgen
НаправлениеC header → RustRust → C header
Когда нуженRust-код вызывает существующую C-библиотекуRust-библиотека публикуется для C/C++-потребителей
Зависимостьlibclang во время сборкиТолько парсинг Rust AST (syn), без libclang
Типичный запускВнутри build.rs на каждой сборкеОтдельный шаг публикации/релиза

Указатели через границу FFI

На FFI-границе владение перестаёт быть выражено типами — Box<T>, Vec<T>, лайфтаймы не существуют для C. Единственный универсальный язык — сырые указатели *mut T / *const T, и передача владения должна быть закреплена вручную через пару функций "создать/освободить".

Передача владения через Box::into_raw / Box::from_raw

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)); }
    }
}
🚫 Двойное освобождение и use-after-free: если C-код вызовет counter_free дважды на одном указателе — либо забудет вызвать вовсе — это неопределённое поведение или утечка. Rust не может проконтролировать дисциплину вызова на C-стороне: контракт "ровно один free на каждый new" держится только на документации и дисциплине автора биндинга.

Строки: CString и CStr

Критичное отличие: 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 при возврате указателей на локальные данные

// ❌ UB: указатель на данные из стекового фрейма, который уже уничтожен
#[unsafe(no_mangle)]
pub extern "C" fn bad_get_buffer() -> *const u8 {
    let local = [1u8, 2, 3];
    local.as_ptr() // local уничтожается на выходе из функции — dangling pointer
}

Безопасные обёртки: паттерн safe wrapper

Идиоматический подход — не раздавать 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.

Обработка ошибок через границу FFI

У C нет ни Result, ни исключений в привычном виде — стандартные механизмы сигнализации ошибок ограничены и должны быть явно спроецированы на Rust-типы.

  • Коды возврата — самый переносимый вариант: int с соглашением "0 = успех", остальное — код ошибки. Просто, но требует внешнего справочника кодов.
  • errno-подобный thread-local — глобальное/TLS состояние последней ошибки, читаемое отдельным вызовом (как errno в POSIX). Подходит, когда нужно передать детальное сообщение без изменения сигнатуры основной функции.
  • Out-параметры — указатель, куда пишется результат или код ошибки, а возвращаемое значение сигнализирует успех/неудачу. Особенно полезно, когда сам результат — не примитив, а структура/строка.
// Вариант: код возврата + 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, // код: ошибка парсинга
    }
}

Паника не должна пересекать FFI-границу

Если 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"
⚠️ Компромисс panic = "abort": внутри самого Rust-кода catch_unwind перестаёт работать вообще (unwind отключён на уровне кодогена), так что этот флаг подходит только если вы гарантируете отсутствие паник на верхнем уровне другими средствами (валидация, Result везде) — либо принимаете abort как единственный сценарий отказа.

Best practices: чеклист для production FFI

  1. Никогда не используйте repr(Rust) на границе. Каждая структура, пересекающая FFI, должна быть #[repr(C)] или #[repr(transparent)].
  2. Генерируйте биндинги, не пишите вручную. bindgen/cbindgen устраняют целый класс ошибок рассинхронизации типов.
  3. Документируйте владение для каждого указателя. Явно фиксируйте в doc-комментарии: кто выделяет память, кто освобождает, разрешён ли null.
  4. Каждая функция, отдающая указатель, имеет парную функцию освобождения — и это должно быть видно из именования (_new/_free, _open/_close).
  5. catch_unwind на каждой точке входа, доступной из C — либо гарантированный panic = "abort" с полным пониманием последствий.
  6. Проверяйте null на входе в unsafe extern-функции прежде, чем разыменовывать любой указатель-параметр.
  7. Прячьте unsafe за safe-обёртками с RAII (Drop) для любого ресурса, требующего явного освобождения на C-стороне.
  8. Учитывайте потокобезопасность отдельно. C ABI ничего не знает о Send/Sync — если C-библиотека не документирована как thread-safe, синхронизацию нужно обеспечивать на Rust-стороне.
  9. Тестируйте под Miri и/или AddressSanitizer там, где это возможно — граница FFI не покрывается гарантиями borrow checker, и часть ошибок проявляется только при санитайзинге или в проде под нагрузкой.
  10. Версионируйте C ABI отдельно от Rust API — semver Cargo.toml не имеет отношения к стабильности экспортируемых C-символов; для cdylib нужен собственный контракт совместимости (например, через soname).
🔑 Главный вывод темы: FFI-код в Rust — это не "unsafe-версия обычного Rust", а отдельная дисциплина проектирования контракта на границе языков, где корректность держится на явных соглашениях (owning/borrowing указателей, кодах ошибок, отсутствии паник), закреплённых в коде и документации, а не выведенных компилятором.