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

Привет, Хабр!
Сегодня мы рассмотрим, как создать безопасные 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 в своих проектах? Делитесь опытом в комментариях.