FFI: как создать мост между Rust и C/C++

26720389d7e681a0eaa7d70007753e92.png

Привет, Хабр!

Сегодня мы рассмотрим, как создать безопасные FFI-интерфейсы в Rust для интеграции с C/C++ библиотеками

Если говорить проще, FFI (foreign function interface — интерфейс вызова внешних функций) — это способ «позаимствовать» функциональность из другого языка. В контексте нашей статьи, с одной стороны у нас Rust, где каждый байт памяти охраняется компилятором, а на другой C++, где свобода обращения с памятью может обернуться утечками или, что еще хуже, непредсказуемым UB (англ. undefined behavior, в ряде источников непредсказуемое поведение). И наша задача — сделать так, чтобы эти два мира не конфликтовали, а работали в унисон.

Итак, что можно сделать с помощью FFI?

  • Подключить legacy-код: зачем переписывать десятки тысяч строк, если можно просто обернуть проверенные временем C/C++ функции в безопасный Rust-API? Например, взять библиотеку для обработки изображений или шифрования и подключить ее, не переживая об утечках памяти.

  • Использовать системные API: некоторые системные вызовы или аппаратные фишки доступны только через C. С FFI легко подключишь их к своему Rust-приложению.

  • Интегрировать C++ классы: даже если есть сложные C++ классы с кучей методов и состоянием, можно создать C-совместимые обертки и подключить их в Rust.

Основы FFI в Rust: разбор первичных конструкций

В Rust для взаимодействия с внешними библиотеками используется ключевое слово extern. Объявляя функцию с extern «C», мы говорим компилятору: «Смотри, тут код из языка». Типы данных из модуля std: os: raw помогают сохранить совместимость с C.

Рассмотрим базовый пример: вызов функции сложения, реализованной на C.

/* add.c - простая функция сложения */
#include 

int add(int a, int b) {
    return a + b;
}

Собираем библиотеку:

gcc -c add.c -o add.o
ar rcs libadd.a add.o

А теперь — Rust-код:

// main.rs - интеграция с C через FFI.
use std::os::raw::c_int;

extern "C" {
    /// функция для сложения двух чисел.
    fn add(a: c_int, b: c_int) -> c_int;
}

/// безопасная обертка вокруг небезопасного вызова C-функции `add`.
pub fn safe_add(a: i32, b: i32) -> i32 {
    // unsafe-блок локализован, чтобы минимизировать область возможных ошибок.
    unsafe { add(a as c_int, b as c_int) as i32 }
}

fn main() {
    let a = 42;
    let b = 58;
    let result = safe_add(a, b);
    println!("{} + {} = {}", a, b, result);
}

Здесь главное — свести unsafe до минимума, чтобы остальной код оставался чистым и безопасным.

Работа с ресурсами

Допустим, функция на C выделяет память и возвращает указатель на структуру. Если не обернуть это в RAII-модель Rust, рискуете оказаться с утечками памяти.

C-код (resource.c):

#include 
#include 

typedef struct {
    char *data;
    int length;
} Resource;

/// создаeт ресурс, копируя строку.
Resource* create_resource(const char* init_str) {
    Resource* res = (Resource*)malloc(sizeof(Resource));
    if (!res) return NULL;
    res->length = (int)strlen(init_str);
    res->data = (char*)malloc(res->length + 1);
    if (!res->data) {
        free(res);
        return NULL;
    }
    strcpy(res->data, init_str);
    return res;
}

/// имитация использования ресурса.
void use_resource(Resource* res) {
    if (res && res->data) {
        // тут какая-нибудь логика.
    }
}

/// освобождает ресурс.
void free_resource(Resource* res) {
    if (res) {
        free(res->data);
        free(res);
    }
}

Обертка на Rust:

use std::ffi::CString;
use std::os::raw::c_char;

/// представление C-структуры Resource. Тип объявлен как opaque.
#[repr(C)]
pub struct Resource {
    _private: [u8; 0],
}

extern "C" {
    /// создает ресурс.
    fn create_resource(init_str: *const c_char) -> *mut Resource;
    /// использует ресурс.
    fn use_resource(res: *mut Resource);
    /// освобождает ресурс.
    fn free_resource(res: *mut Resource);
}

/// RAII-обeртка для управления ресурсом.
pub struct ResourceWrapper {
    ptr: *mut Resource,
}

impl ResourceWrapper {
    /// создает новый ресурс или возвращает None, если что-то пошло не так.
    pub fn new(initial: &str) -> Option {
        let c_str = CString::new(initial).ok()?;
        let res_ptr = unsafe { create_resource(c_str.as_ptr()) };
        if res_ptr.is_null() {
            None
        } else {
            Some(Self { ptr: res_ptr })
        }
    }

    /// вызывает функцию использования ресурса.
    pub fn use_it(&self) {
        unsafe { use_resource(self.ptr) }
    }
}

impl Drop for ResourceWrapper {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            unsafe { free_resource(self.ptr) }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_resource_wrapper() {
        let resource = ResourceWrapper::new("Hello, Rust FFI")
            .expect("Не удалось создать ресурс");
        resource.use_it();
        // free_resource вызывается автоматически при выходе из области видимости.
    }
}

Применили паттерн RAII, чтобы автоматизировать освобождение памяти. Даже если функция create_resource вернет NULL, код корректно обработает эту ситуацию.

Интеграция с C++

Интеграция с C++ сложнее из-за name mangling и исключений. Чаще всего, для упрощения интеграции, создают обертки на C, скрывающие все сложности C++.

Пример C++ класса и его обертки

C++ заголовок (my_cpp_class.hpp):

#ifndef MY_CPP_CLASS_HPP
#define MY_CPP_CLASS_HPP

class MyCppClass {
public:
    MyCppClass();
    ~MyCppClass();
    void doSomething();
};

#endif // MY_CPP_CLASS_HPP

C++ обертка (wrapper.cpp):

#include "my_cpp_class.hpp"
#include 

extern "C" {
    // фабрика создания экземпляра класса.
    MyCppClass* my_cpp_class_new() {
        try {
            return new MyCppClass();
        } catch (const std::exception& e) {
            // можно добавить логирование ошибки.
            return nullptr;
        }
    }

    // вызов метода doSomething.
    void my_cpp_class_do_something(MyCppClass* instance) {
        if (instance) {
            try {
                instance->doSomething();
            } catch (...) {
                // если нужно, обработка исключений.
            }
        }
    }

    // удаление экземпляра.
    void my_cpp_class_delete(MyCppClass* instance) {
        delete instance;
    }
}

Rust-обертка для работы с C++:

use std::ptr;

/// представляем opaque тип для C++ класса.
#[repr(C)]
pub struct MyCppClass {
    _private: [u8; 0],
}

extern "C" {
    fn my_cpp_class_new() -> *mut MyCppClass;
    fn my_cpp_class_do_something(instance: *mut MyCppClass);
    fn my_cpp_class_delete(instance: *mut MyCppClass);
}

/// обертка вокруг C++ класса, инкапсулирующая unsafe-вызовы.
pub struct CppClassWrapper {
    ptr: *mut MyCppClass,
}

impl CppClassWrapper {
    /// создает экземпляр класса. Паникует, если создание не удалось.
    pub fn new() -> Self {
        let ptr = unsafe { my_cpp_class_new() };
        if ptr.is_null() {
            panic!("Не удалось создать объект MyCppClass");
        }
        Self { ptr }
    }

    /// вызывает метод doSomething.
    pub fn do_something(&self) {
        unsafe { my_cpp_class_do_something(self.ptr) }
    }
}

impl Drop for CppClassWrapper {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            unsafe { my_cpp_class_delete(self.ptr) }
        }
    }
}

Минимизировали unsafe-блоки, добавив проверки на NULL и обернув вызовы в Rust API.

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

Пример с использованием RAII уже знаком — автоматическое освобождение ресурсов через трейт Drop. Для более сложных сценариев можно подключить PhantomData для контроля времени жизни:

use std::marker::PhantomData;
use std::os::raw::c_void;

/// обертка для ресурса с управлением времени жизни.
pub struct SafeHandle<'a> {
    handle: *mut c_void,
    _marker: PhantomData<&'a ()>,
}

impl<'a> SafeHandle<'a> {
    /// создает новую обертку, если handle не равен NULL.
    pub fn new(handle: *mut c_void) -> Option {
        if handle.is_null() {
            None
        } else {
            Some(Self { handle, _marker: PhantomData })
        }
    }

    /// пример метода, использующего handle.
    pub fn do_something(&self) {
        unsafe {
            // вызов внешней функции с использованием handle.
        }
    }
}

impl<'a> Drop for SafeHandle<'a> {
    fn drop(&mut self) {
        if !self.handle.is_null() {
            unsafe {
                // например, вызов функции освобождения handle.
                // free_handle(self.handle);
            }
        }
    }
}

Паниковать — не всегда хорошее решение. Можно использовать типы Result и Option, а также кастомные ошибки для обработки нештатных ситуаций:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum FfiError {
    #[error("Ошибка создания ресурса: неверный формат строки")]
    InvalidCString,
    #[error("Ошибка создания ресурса: функция вернула NULL")]
    NullResource,
}

pub fn create_resource_safe(initial: &str) -> Result {
    let c_str = CString::new(initial).map_err(|_| FfiError::InvalidCString)?;
    let res_ptr = unsafe { create_resource(c_str.as_ptr()) };
    if res_ptr.is_null() {
        Err(FfiError::NullResource)
    } else {
        Ok(ResourceWrapper { ptr: res_ptr })
    }
}

Этот подход помогает создавать более надежные и поддерживаемые API.

Автоматизация биндингов с помощью bindgen

Bindgen — отличный инструмент для автоматической генерации Rust-оберток по C/C++ заголовочным файлам.

Создадим файл build.rs для автоматической генерации биндингов:

// build.rs
extern crate bindgen;
use std::env;
use std::path::PathBuf;

fn main() {
    // указываем путь к заголовочному файлу.
    let header_path = "wrapper.h";

    // генерируем биндинги с необходимыми опциями.
    let bindings = bindgen::Builder::default()
        .header(header_path)
        .clang_arg("-I./include")  // если заголовки лежат в отдельной папке.
        .derive_default(true)
        .generate()
        .expect("Не удалось сгенерировать биндинги");

    // записываем сгенерированные биндинги в OUT_DIR.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Не удалось записать биндинги");
}
В Cargo.toml добавляем:
[build-dependencies]
bindgen = "0.65.1"
Предположим, заголовочный файл wrapper.h выглядит так:
// wrapper.h
#ifndef WRAPPER_H
#define WRAPPER_H

int multiply(int a, int b);

#endif // WRAPPER_H
В main.rs используем биндинги:
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

fn main() {
    let result = unsafe { multiply(6, 7) };
    println!("6 * 7 = {}", result);
}

Bindgen позволяет не тратить время на ручное создание оберток, однако всегда проверяйте генерируемый код на соответствие требованиям безопасности и качества.

Потокобезопасность

Работая с внешними библиотеками, нужно учитывать, что они могут не быть потокобезопасными. Для безопасного доступа к таким API следует использовать синхронизацию.

Если библиотека не потокобезопасна, оборачивайте вызовы в мьютексы или другие механизмы синхронизации.

Пример:

use std::sync::{Mutex, Arc};

lazy_static::lazy_static! {
    // Глобальный мьютекс для синхронизации вызовов к небезопасному API.
    static ref FFI_MUTEX: Mutex<()> = Mutex::new(());
}

/// Функция для синхронизированного вызова unsafe-функции.
pub fn synchronized_call(f: F) -> R
where
    F: FnOnce() -> R,
{
    let _guard = FFI_MUTEX.lock().unwrap();
    f()
}

fn main() {
    let result = synchronized_call(|| unsafe { add(10, 20) });
    println!("Синхронизированный вызов: 10 + 20 = {}", result);
}
Для совместного использования ресурсов между потоками используйте Arc и комбинируйте его с RAII:
use std::sync::Arc;

pub struct SharedResource {
    inner: Arc,
}

impl SharedResource {
    pub fn new(initial: &str) -> Result {
        ResourceWrapper::new(initial).map(|res| Self { inner: Arc::new(res) })
    }

    pub fn use_resource(&self) {
        self.inner.use_it();
    }
}

Так можно безопасно делиться ресурсами, не теряя контроля над их временем жизни.

Интеграция с C-библиотекой обработки изображений

В качестве примера рассмотрим, как можно интегрировать C-библиотеку для обработки изображений в Rust-приложение. Условно есть проверенная временем библиотека на C, которая умеет загружать изображение из файла, применять к нему фильтр и освобождать выделенные ресурсы.

Вместо того чтобы переписывать все это на Rust, создадим надежную и безопасную обертку, которая превращает сырой код в решение с RAII, обработкой ошибок и минимизации unsafe-блоков.

C-часть: библиотека для обработки изображений

/* image_lib.c - простая C-библиотека для загрузки и обработки изображений */
#include 
#include 
#include 

// Определяем структуру, представляющую изображение.
typedef struct {
    unsigned char *data;    // указатель на пиксельные данные
    int width;              // ширина изображения
    int height;             // высота изображения
    char error_msg[256];    // строка для сообщения об ошибке
} Image;

/// Загружает изображение из указанного файла.
/// В случае успеха возвращает указатель на Image, в противном - NULL и устанавливает error_msg.
/// Для простоты пример не использует реальное декодирование, а просто симулирует загрузку.
Image* load_image(const char* file_path) {
    if (file_path == NULL || strlen(file_path) == 0) {
        return NULL;
    }
    
    // Аллоцируем память для структуры Image
    Image* img = (Image*)malloc(sizeof(Image));
    if (!img) {
        return NULL;
    }
    
    // Для примера зададим фиксированные размеры
    img->width = 800;
    img->height = 600;
    int size = img->width * img->height * 3; // RGB
    
    // Выделяем память для данных изображения
    img->data = (unsigned char*)malloc(size);
    if (!img->data) {
        free(img);
        return NULL;
    }
    
    // Симулируем заполнение данными (например, заполняем серым цветом)
    memset(img->data, 128, size);
    
    // Очищаем сообщение об ошибке
    img->error_msg[0] = '\0';
    
    return img;
}

/// Применяет фильтр к изображению.
/// filter_type: 0 - инверсия, 1 - градация серого.
/// Возвращает 0 при успехе, или отрицательное значение при ошибке.
int apply_filter(Image* img, int filter_type) {
    if (!img || !img->data) {
        return -1; // ошибка: передан некорректный указатель
    }
    
    int size = img->width * img->height * 3;
    if (filter_type == 0) {
        // Простой эффект инверсии цвета
        for (int i = 0; i < size; i++) {
            img->data[i] = 255 - img->data[i];
        }
    } else if (filter_type == 1) {
        // Эффект градации серого: берем среднее значение для каждого пикселя
        for (int i = 0; i < size; i += 3) {
            unsigned char gray = (img->data[i] + img->data[i+1] + img->data[i+2]) / 3;
            img->data[i] = img->data[i+1] = img->data[i+2] = gray;
        }
    } else {
        snprintf(img->error_msg, sizeof(img->error_msg), "Unknown filter type: %d", filter_type);
        return -2; // ошибка: неизвестный тип фильтра
    }
    
    return 0; // успех
}

/// Освобождает ресурсы, выделенные для изображения.
void free_image(Image* img) {
    if (img) {
        if (img->data) {
            free(img->data);
        }
        free(img);
    }
}

Rust-часть: обертка для C-библиотеки

//! image_wrapper.rs - безопасная обертка для C-библиотеки обработки изображений.
//!
//! Здесь мы минимизируем unsafe-блоки, оборачивая C-функции в продакшен-безопасный API,
//! который включает RAII для автоматического освобождения ресурсов и грамотную обработку ошибок.

use std::ffi::{CString, CStr};
use std::os::raw::{c_char, c_int};
use std::ptr;
use std::error::Error;
use std::fmt;

/// Представление C-структуры Image в виде opaque-типа.
#[repr(C)]
pub struct Image {
    _private: [u8; 0],
}

/// Объявляем внешние C-функции.
extern "C" {
    fn load_image(file_path: *const c_char) -> *mut Image;
    fn apply_filter(img: *mut Image, filter_type: c_int) -> c_int;
    fn free_image(img: *mut Image);
}

/// Кастомная ошибка для обработки нештатных ситуаций при работе с C-библиотекой.
#[derive(Debug)]
pub enum ImageError {
    LoadError(String),
    FilterError(String),
    NullPointer,
}

impl fmt::Display for ImageError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ImageError::LoadError(msg) => write!(f, "Load image error: {}", msg),
            ImageError::FilterError(msg) => write!(f, "Apply filter error: {}", msg),
            ImageError::NullPointer => write!(f, "Received null pointer from C function"),
        }
    }
}

impl Error for ImageError {}

/// Безопасная обертка над C-структурой Image.
pub struct ImageWrapper {
    ptr: *mut Image,
}

impl ImageWrapper {
    /// Загружает изображение по указанному пути.
    /// При ошибке возвращает ImageError.
    pub fn new(file_path: &str) -> Result {
        // Преобразуем путь к изображению в CString.
        let c_path = CString::new(file_path).map_err(|e| ImageError::LoadError(e.to_string()))?;
        // Вызов функции загрузки изображения из C.
        let img_ptr = unsafe { load_image(c_path.as_ptr()) };
        if img_ptr.is_null() {
            return Err(ImageError::NullPointer);
        }
        Ok(ImageWrapper { ptr: img_ptr })
    }
    
    /// Применяет фильтр к изображению.
    /// filter_type: 0 - инверсия, 1 - градация серого.
    pub fn apply_filter(&mut self, filter_type: i32) -> Result<(), ImageError> {
        let res = unsafe { apply_filter(self.ptr, filter_type as c_int) };
        if res != 0 {
            // Если функция вернула ошибку, пытаемся извлечь сообщение об ошибке
            // Для простоты, здесь возвращаем фиксированное сообщение.
            return Err(ImageError::FilterError(format!("Filter type {} not supported or failed", filter_type)));
        }
        Ok(())
    }
    
    /// Пример метода для извлечения дополнительной информации из C-структуры.
    /// В реальной библиотеке может быть функция, возвращающая сообщение об ошибке.
    pub fn get_error_message(&self) -> Option {
        // Допустим, у нас есть указатель на строку с ошибкой внутри структуры.
        // Здесь для примера возвращаем None.
        None
    }
}

impl Drop for ImageWrapper {
    fn drop(&mut self) {
        // Автоматически освобождаем ресурсы при выходе объекта из области видимости.
        if !self.ptr.is_null() {
            unsafe { free_image(self.ptr) }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_image_loading_and_filter() {
        // Пробуем загрузить изображение из файла.
        let mut img = ImageWrapper::new("example.jpg")
            .expect("Failed to load image");
        
        // Применяем фильтр инверсии.
        img.apply_filter(0).expect("Failed to apply inversion filter");
        
        // Применяем фильтр градации серого.
        img.apply_filter(1).expect("Failed to apply grayscale filter");
        
        // Объект ImageWrapper будет автоматически освобожден.
    }
}

fn main() {
    // Демонстрация использования безопасного API для обработки изображений.
    match ImageWrapper::new("sample_image.jpg") {
        Ok(mut image) => {
            println!("Изображение успешно загружено!");
            // Применяем фильтр инверсии.
            if let Err(e) = image.apply_filter(0) {
                eprintln!("Ошибка при применении фильтра: {}", e);
            } else {
                println!("Фильтр успешно применен!");
            }
        },
        Err(e) => eprintln!("Ошибка загрузки изображения: {}", e),
    }
}

Создаем простую библиотеку, которая «загружает» изображение (симулируется фиксированный размер и данные), применяет один из двух фильтров и освобождает ресурсы. Функция load_image возвращает указатель на структуру, а apply_filter проверяет корректность входных данных и возвращает код ошибки, если что-то пошло не так.

В Rust уже объявляем внешний API с помощью extern «C», а затем создаем структуру ImageWrapper, которая оборачивает указатель на C-структуру. Конструктор new преобразует строку пути в CString и вызывает C-функцию. Метод apply_filter выполняет вызов в unsafe-блоке и проверяет результат, возвращая Result. RAII реализован через Drop, который гарантирует, что функция free_image будет вызвана при выходе из области видимости.

Заключение

Мы прошли путь от простейших вызовов C-функций до интеграции с полноценными C++ классами, затронув техники управления памятью, синхронизации и автоматизации биндингов. Но, поверьте, это лишь вершина айсберга FFI — о нeм можно рассказывать вечно.

Главная идея проста: не ленитесь изолировать unsafe-код, тестировать всё вдоль и поперек и внимательно изучать документацию.

А как вы используете FFI в своих проектах? Делитесь опытом в комментариях.

© Habrahabr.ru