Создание и использование динамических библиотек в Rust

Kate

Administrator
Команда форума

Динамические библиотеки​

Для предоставления доступа к логике и данным программного кода, из которого скомпилирована библиотека, она экспортирует сущности, которые другие программы могут импортировать и использовать. Для того чтобы корректно пользоваться импортированной сущностью, нужно корректно интерпретировать её бинарное представление после импорта. Для этого динамические библиотеки, обычно, поставляются с описанием своего бинарного интерфейса (ABI). Чаще всего, это описание представляется в виде программного кода, описывающего сигнатуры экспортируемых функций, соглашение о вызовах и используемые в интерфейсе типы данных.

Возникает вопрос: как использовать например, классы из С++ в программе на другом языке, у которого классы (или аналогичные им сущности) представляются в бинарном виде совершенно не так, как в С++? Во избежание подобных проблем, при создании динамической библиотеки, которая должна быть доступна другим языкам, принято использовать стандартный С ABI. Иными словами, динамическая библиотека должна экспортировать только те сущности, которые присутствуют в языке С. Большинство языков, поддерживающих работу с динамическими библиотеками поддерживают и примитивы из С. И Rust входит в их число.

Создание динамической библиотеки​

Для создания динамической библиотеки c C ABI нужно, для начала, указать компилятору, что мы хотим в качестве выходного продукта иметь именно её. Для этого в файл Cargo.toml добавим следующее:

[lib]
crate-type = ["cdylib"]
Далее, чтобы экспортировать функцию из библиотеки, нужно пометить её ключевым словом extern:

#[no_mangle]
pub extern "C" fn foo() -> u32 {...}
Параметр "С" используется по умолчанию и его можно не указывать явно. Он означает, что при экспорте данной функции будет использован стандартный бинарный интерфейс С для целевой платформы компилятора. Другие варианты параметров экспорта функции можно посмотреть в документации.

Аттрибут #[no_mangle] сообщает компилятору, что имя функции не должно модифицироваться при компиляции.

Таким образом, при сборке проекта, в директории выходных файлов создаётся динамическая библиотека, экспортирующая символ foo. Теперь можно написать программу, которая:

  1. загрузит эту библиотеку,
  2. импортирует из неё символ foo,
  3. интерпретирует его как С функцию без аргументов, возвращающую 32-х битное беззнаковое целое число,
  4. будет вызывать библиотечную функцию и пользоваться результатом.

Подключение динамической библиотеки​

Для подключения динамической библиотеки и импорта символов из неё, используются функции операционной системы. Чтобы добиться кроссплатформенности можно использовать абстракции построенные поверх системных вызовов. Наиболее популярный вариант такой абстракции для работы с динамическими библиотеками в Rust - крейт libloading. Помимо загрузки библиотек и символов из них, этот крейт помогает избежать глупых ошибок. Например, использовать загруженный символ после того, как библиотека была выгружена.

Пример использования данной библиотеки из её документации:

fn call_dynamic() -> Result<u32, Box<dyn std::error::Error>> {
unsafe {
let lib = libloading::Library::new("/path/to/lib/library.so")?;
let func: libloading::Symbol<unsafe extern fn() -> u32> = lib.get(b"my_func")?;
Ok(func())
}
}
В данном примере мы загружаем библиотеку, расположенную по пути /path/to/lib/library.so. В случае успеха, мы импортируем символ с названием my_func, интерпретируя его как функцию с сигнатурой unsafe extern fn () -> u32 и присваивая переменной func. Если данный символ существует и импортировался успешно, то возвращаем результат вызова func.

Загрузка библиотеки является unsafe операцией, так как при этом, в общем случае, вызываются функции инициализации, гарантия корректности которых возлагается на разработчика. Это относится и к деинициализации библиотеки.

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

Пример​

Для демонстрации использования описанных выше возможностей, рассмотрим небольшой пример. В данном примере крейт собирается как динамическая библиотека, а example использует её.

Библиотека​

Крейт представляет собой библиотеку для работы с изображениями, построенную поверх крейта image. Данная библиотека позволяет открывать и сохранять изображения, а также применять пару преобразований: размытие и зеркальное отражение.

Единственная функция, которую должен импортировать пользователь библиотеки выглядит так:

/// Returns all functions of this library.
#[no_mangle]
pub extern "C" fn functions() -> FunctionsBlock {
FunctionsBlock::default()
}
Данная функция возвращает структуру FunctionsBlock, содержащую указатели на все полезные функции данной библиотеки.

/// Contains functions provided by library. Allow to import just `functions()` function and get all
/// functionality of library through this struct.
/// `size` field contain size of this struct. It helps to avoid versioning and some other errors.
#[allow(unused)]
#[repr(C)]
pub struct FunctionsBlock {
size: usize,
open_image: OpenImageFn,
save_image: SaveImageFn,
destroy_image: DestroyImageFn,
blur_image: BlurImageFn,
mirror_image: MirrorImageFn,
}

impl Default for FunctionsBlock {
fn default() -> Self {
Self {
size: std::mem::size_of::<Self>(),
open_image: img_open,
save_image: img_save,
destroy_image: img_destroy,
blur_image: img_blur,
mirror_image: img_mirror,
}
}
}
Аттрибут #[repr(С)] сообщает компилятору, что данная структура должна быть бинарно-совместима с аналогичной структурой, определённой в С.

Такой подход позволяет клиентскому коду импортировать только одну функцию, а все остальные получить, как результат её вызова. Это снижает вероятность ошибки при вводе имени каждой функции. Поле size содержит размер структуры в байтах и используется для того, чтобы клиентский код мог удостовериться, что корректно интерпретирует тип структуры, а не пропустил поле, например.

Типы функций содержащихся в FunctionsBlock описываются следующим образом:

/// Loads image from file function type.
type OpenImageFn = unsafe extern "C" fn(RawPath, *mut ImageHandle) -> ImageError;
/// Saves image to file function type.
type SaveImageFn = unsafe extern "C" fn(RawPath, ImageHandle) -> ImageError;
/// Destroys image function type.
type DestroyImageFn = unsafe extern "C" fn(ImageHandle);

/// Performs a Gaussian blur on the supplied image function type.
type BlurImageFn = unsafe extern "C" fn(ImageHandle, f32) -> ImageHandle;
/// Flips image horizontally function type.
type MirrorImageFn = unsafe extern "C" fn(ImageHandle);
Ключевое слово extern необходимо, так как эти функции будут вызываться внешним клиентом. Поэтому они должны быть совместимы с С ABI. Компилятор Rust выдаст предупреждение при попытке использовать не совместимые с С типы в сигнатурах extern функций.

Реализации функций конвертируют C примитивы в объекты Rust и вызывают функции крейта image.

/// # Safety
/// - `path` is valid pointer to null-terminated UTF-8 string.
/// - `handle` is valid image handle.
unsafe extern "C" fn img_save(path: RawPath, handle: ImageHandle) -> ImageError {
if handle.0.is_null() || path.0.is_null() {
return ImageError::parameter;
}

let path: &Path = match (&path).try_into() {
Ok(p) => p,
Err(e) => return e,
};

let img = handle.as_image();
match img.save(path) {
Ok(_) => ImageError::NoError,
Err(e) => e.into(),
}
}

/// Destroys image created by this library.
unsafe extern "C" fn img_destroy(handle: ImageHandle) {
handle.into_image();
}

/// Blurs image with `sigma` blur radius. Returns new image.
unsafe extern "C" fn img_blur(handle: ImageHandle, sigma: f32) -> ImageHandle {
let image = handle.as_image();
let buffer = image::imageops::blur(image, sigma);
let blurred = image::DynamicImage::ImageRgba8(buffer);
ImageHandle::from_image(blurred)
}

/// Flip image horizontally in place.
unsafe extern "C" fn img_mirror(handle: ImageHandle) {
let image_ref = handle.as_image();
image::imageops::flip_horizontal_in_place(image_ref);
}
ImageHandle и RawPath - это небольшие обёртки для С-совместимых типов. Эти обёртки позволяют использовать ассоциированные функции и реализовывать трейты. Подробнее: newtype pattern. ImageHandle содержит указатель на объект типа DynamicImage из крейта image. RawPath содержит указатель на С строку.

/// Incapsulate raw pointer to image.
#[repr(transparent)]
struct ImageHandle(*mut c_void);
/// Contain pointer to null-terminated UTF-8 path.
#[repr(transparent)]
struct RawPath(*const c_char);
Аттрибут #[repr(transparent)] сообщает компилятору, что структура должна быть представлена в бинарном виде также, как и её поле.

Работа с С строками не безопасна, поэтому мы полагаемся на то, что в качестве путей клиентский код будет передавать нам корректные null-terminated С строки в UTF-8 кодировке.

Коды ошибок представлены в виде нумерованного перечисления:

/// Error codes for image oprerations.
#[repr(u32)]
#[derive(Debug)]
enum ImageError {
NoError = 0,
Io,
Decoding,
Encoding,
Parameter,
Unsupported,
}
Аттрибут #[repr(u32)] сообщает компилятору, что значения перечисления представляется в памяти, как 32-х битные беззнаковые целые числа.

Использование библиотеки​

Приложение, использующее библиотеку, представлено в данном проекте в виде example-а use_lib.

Модуль bindings повторяет определения типов данных из библиотеки, что позволяет правильно интерпретировать загруженные бинарные данные. Также, в нем находится функция, возвращающую путь к библиотеке. Эта функция возвращает разные значения для разных операционных систем. Это достигается за счёт условной компиляции.

/// Statically known path to library.
#[cfg(target_os = "linux")]
pub fn lib_path() -> &'static Path {
Path::new("target/release/image_sl.so")
}

/// Statically known path to library.
#[cfg(target_os = "windows")]
pub fn lib_path() -> &'static Path {
Path::new("target/release/image_sl.dll")
}
Модуль img предоставляет две абстракции: ImageFactory и Image. Также, он содержит приватную обёртку для работы с библиотекой Lib.

При создании экземпляра структуры Lib происходит импорт символа functions, получение набора функций Functions и проверка корректности полученной структуры посредством сравнения размеров. Структура Lib хранит в себе счётчик ссылок на загруженную библиотеку и полученные из неё функции. Это позволяет нам гарантировать, что функции библиотеки не будут вызываться после её отключения. В дальнейшем, Lib будет копироваться во все экземпляры структуры Image, предоставляя им доступ к функциям библиотеки.

/// Creates new instance of `Lib`. Loads functons from shared library.
pub unsafe fn new(lib: Library) -> Result<Self, anyhow::Error> {
let load_fn: libloading::Symbol<FunctionsFn> = lib.get(b"functions")?;
let functions = load_fn();

if functions.size != std::mem::size_of::<Functions>() {
return Err(anyhow::Error::msg(
"Lib Functions size != app Functions size",
));
}

Ok(Self {
lib: Arc::new(lib),
functions,
})
}
ImageFactory необходим для создания новых Image через функцию open() и передачи в них копии Lib.

Image инкапсулирует работу с изображением, предоставляя safe интерфейс. Также, Image контролирует уничтожение изображения, вызывая в деструкторе функцию destroy_image().

Функция main() создаёт экземпляр ImageFactory, открывает .jpg изображение, размывает его, отражает зеркально и сохраняет размытый и отраженный варианты в формате .png.

fn main() -> Result<(), Box<dyn Error>> {
println!("{:?}", std::env::current_dir());
let image_factory = ImageFactory::new()?;
let mut image = image_factory.open_image("data/logo.jpg")?;

let blurred = image.blur(40.);
image.mirror();

image.save("data/mirrored.png")?;
blurred.save("data/blurred.png")?;
Ok(())
}

Заключение​

Мы рассмотрели механизм создания и подключения динамических библиотек в Rust. Для создания библиотеки в Cargo.toml добавляется указание на генерацию динамической библиотеки crate-type = ["cdylib"] . Для экспорта символов используется ключевое слово extern.

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

Описанные подходы были продемонстрированы на примере библиотеки работы с изображениями и приложения, эту библиотеку использующего.

На практике, динамические библиотеки написанные на Rust можно применять для ускорения требовательных к производительности мест приложения на высокоуровневых языках, таких как C#, Java, Python. Или, например, для добавления нового функционала в легаси код на низкоуровневых языках, таких как С и С++.

Возможность подключения динамических библиотек в Rust можно применить, например, для создания механизма динамического подключения плагинов к приложению.

Благодарю за внимание. Побольше вам удобных библиотек!

 
Сверху