Building and using dynamic link libraries in Rust


Sometimes, during development, it becomes necessary to combine code in different programming languages. Or dynamically loading, unloading and replacing different parts of the program during its execution. Dynamic libraries can solve these problems. They can be compiled and updated independently of the programs they are using, as long as the interface is preserved. This approach opens up a number of additional opportunities for software development. For example, writing different application modules in different languages. Or creating a system of dynamically connected plugins. In this article, we will (in theory and by example) look at how to create and load dynamic libraries in Rust.

Content

  1. Dynamic libraries

  2. Creating a dynamic library

  3. Linking a dynamic library

  4. Example

  5. Conclusion

Dynamic libraries

To provide access to the logic and data of the program code from which the library is compiled, it exports entities that other programs can import and use. In order to use the imported entity correctly, you need to correctly interpret its binary representation after import. For this, dynamic libraries are usually supplied with a description of their binary interface (ABI). Most often, this description is presented in the form of program code describing the signatures of the exported functions, the calling convention, and the data types used in the interface.

The question arises: how to use, for example, classes from C ++ in a program in another language, in which classes (or similar entities) are represented in a binary form in a completely different way than in C ++? To avoid such problems, it is customary to use the standard C ABI when creating a dynamic library that should be available to other languages. In other words, a dynamic link library should export only those entities that are present in the C language. Most languages ​​that support working with dynamic link libraries also support primitives from C. And Rust is one of them.

Creating a dynamic library

To create a dynamic library with a C ABI, you first need to tell the compiler that we want to have it as an output product. To do this, in the file Cargo.toml add the following:

[lib]
crate-type = ["cdylib"]

Further, to export a function from the library, you need to mark it with the keyword extern:

#[no_mangle]
pub extern "C" fn foo() -> u32 {...}

The “C” parameter is used by default and can be left blank. It means that when exporting this function, the standard C binary interface for the compiler target platform will be used. Other options for the export function parameters can be found in documentation

Attribute #[no_mangle] tells the compiler that the function name should not be modified at compile time.

Thus, when building a project, a dynamic library is created in the output directory that exports the symbol foo… Now you can write a program that:

  1. will load this library,

  2. imports a symbol from it foo,

  3. interprets it as a C function with no arguments, returning a 32-bit unsigned integer,

  4. will call the library function and use the result.

Linking a dynamic library

To connect a dynamic library and import symbols from it, the functions of the operating system are used. To achieve cross-platform, you can use abstractions built on top of system calls. The most popular version of this abstraction for working with dynamic link libraries in Rust is the crate libloading… Besides loading libraries and symbols from them, this crate helps to avoid silly mistakes. For example, use the loaded symbol after the library has been unloaded.

An example of using this library from its documentation:

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())
    }
}

In this example, we load the library located along the path /path/to/lib/library.so… If successful, we import the symbol with the name my_func, interpreting it as a function with signature unsafe extern fn () -> u32 and assigning to the variable func… If the given symbol exists and was imported successfully, then we return the result of the call func

Loading the library is unsafe operation, since in this case, in the general case, initialization functions are called, the correctness of which is guaranteed by the developer. This also applies to library deinitialization.

Loading a symbol from a library is also not safe, since the compiler has no way of checking the correct type of the loaded symbol. The developer is responsible for the correct interpretation of the binary data of the loaded symbol.

Example

To demonstrate the use of the features described above, consider a small example… In this example, the crate is built as a dynamic library, and example uses it.

Library

A crate is an imaging library built on top of a crate. image… This library allows you to open and save images, as well as apply a couple of transformations: blur and mirror reflection.

The only function that the user of the library has to import looks like this:

/// Returns all functions of this library.
#[no_mangle]
pub extern "C" fn functions() -> FunctionsBlock {
    FunctionsBlock::default()
}

This function returns the structure FunctionsBlockcontaining pointers to all the useful functions of this library.

/// 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,
        }
    }
}

Attribute #[repr(С)] tells the compiler that a given structure must be binary compatible with a similar structure defined in C.

This approach allows the client code to import only one function, and get all the rest as a result of calling it. This reduces the chance of mistakes when entering the name of each function. Field size contains the size of the structure in bytes and is used so that the client code can make sure that it correctly interprets the type of the structure, and does not miss a field, for example.

The types of functions contained in FunctionsBlock are described as follows:

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

Keyword extern necessary as these functions will be called by an external client. Therefore, they must be ABI compliant. The Rust compiler will issue a warning when trying to use incompatible C types in signatures extern functions.

Function implementations convert C primitives to Rust objects and call crate functions 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 and RawPath are small wrappers for C-compatible types. These wrappers allow you to use associated functions and implement traits. More details: newtype patternImageHandle contains a pointer to an object of type DynamicImage from the crate image. RawPath contains a pointer to a C string.

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

Attribute #[repr(transparent)] tells the compiler that the structure should be represented in binary as well as its field.

Working with strings is not safe, so we rely on the fact that the client code will pass correct paths to us. null-terminated From a UTF-8 encoded string.

The error codes are presented as a numbered enumeration:

/// Error codes for image oprerations.
#[repr(u32)]
#[derive(Debug)]
enum ImageError {
    NoError = 0,
    Io,
    Decoding,
    Encoding,
    Parameter,
    Unsupported,
}

Attribute #[repr(u32)] tells the compiler that the enumeration values ​​are represented in memory as 32-bit unsigned integers.

Using the library

The application using the library is presented in this project as an example use_lib

Module bindings repeats the data type definitions from the library, which allows the loaded binary data to be interpreted correctly. Also, it contains a function that returns the path to the library. This function returns different values ​​for different operating systems. This is achieved through conditional compilation.

/// 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")
}

Module img provides two abstractions: ImageFactory and Image… Also, it contains a private wrapper for working with the library. Lib

When instantiating a structure Lib the symbol is imported functions, getting a set of functions Functions and checking the correctness of the resulting structure by comparing sizes. Structure Lib stores in itself a counter of links to the loaded library and functions derived from it. This allows us to ensure that library functions will not be called after it is disabled. Further, Lib will be copied to all instances of the structure Imageby giving them access to library functions.

/// 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 necessary to create new Image through the function open() and transferring copies to them Lib

Image encapsulates image manipulation by providing a safe interface. Also, Image controls the destruction of the image by calling the function in the destructor destroy_image()

Function main() creates an instance ImageFactory, opens .jpg image, blurs it, flips it, and keeps the blurred and reflected options in the format .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(())
}

Conclusion

We have covered the mechanism for creating and linking dynamic libraries in Rust. To create a library in Cargo.toml an indication to generate a dynamic library is added crate-type = ["cdylib"] … The keyword is used to export symbols extern

The crate was used to load the library libloadingthat hides the complexity and insecurity of system calls behind abstractions. After loading the library and symbols from it, you should create safe wrappers over them to maintain invariants and reduce the number of potential errors.

The described approaches have been demonstrated at example library imaging and applications using this library.

In practice, dynamic libraries written in Rust can be used to accelerate performance-demanding places in an application in high-level languages ​​such as C #, Java, Python. Or, for example, to add new functionality to legacy code in low-level languages ​​such as C and C ++.

The ability to connect dynamic libraries in Rust can be used, for example, to create a mechanism for dynamically connecting plugins to an application.

Thank you for attention. More convenient libraries for you!


The article was written on the eve of the start of the course Rust Developer.

Learn more about the course.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *