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

eafa4117d812895a1cf9ae274554716f.jpg

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

Содержание

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

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

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

  4. Пример

  5. Заключение

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

Для предоставления доступа к логике и данным программного кода, из которого скомпилирована библиотека, она экспортирует сущности, которые другие программы могут импортировать и использовать. Для того чтобы корректно пользоваться импортированной сущностью, нужно корректно интерпретировать её бинарное представление после импорта. Для этого динамические библиотеки, обычно, поставляются с описанием своего бинарного интерфейса (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> {
    unsafe {
        let lib = libloading::Library::new("/path/to/lib/library.so")?;
        let func: libloading::Symbol 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::(),
            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 {
  let load_fn: libloading::Symbol = lib.get(b"functions")?;
  let functions = load_fn();

  if functions.size != std::mem::size_of::() {
    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> {
    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 можно применить, например, для создания механизма динамического подключения плагинов к приложению.

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

Статья написана в преддверии старта курса Rust Developer.

Узнать подробнее о курсе.

© Habrahabr.ru