Rust’s plugin system, when using dynamic loading with libloading, allows you to load compiled code into a running application without recompiling the main binary.

Let’s see this in action. Imagine we have a main application that needs to load a "greeter" plugin.

main.rs

use libloading::{Error, Library};
use std::error::Error as StdError;
use std::ffi::CStr;

// Define the trait that our plugins will implement.
// This trait must be visible to both the main application and the plugin.
// A common way is to define it in a shared crate.
#[repr(C)]
pub struct Greeter {
    greet: extern "C" fn(*const std::os::raw::c_char) -> *const std::os::raw::c_char,
}

// This is the entry point function that our plugin will expose.
// It must return a raw pointer to a Greeter struct.
type PluginEntry = unsafe extern "C" fn() -> *mut Greeter;

fn main() -> Result<(), Box<dyn StdError>> {
    let plugin_path = "./libgreeter_plugin.so"; // Or .dylib on macOS, .dll on Windows

    // Load the dynamic library.
    // `unsafe` is required because loading external code can be dangerous.
    let lib = unsafe { Library::new(plugin_path)? };

    // Get a pointer to the entry point function.
    // The symbol name "load_plugin" is arbitrary but must match the plugin.
    let entry_point: libloading::Symbol<PluginEntry> = unsafe { lib.get(b"load_plugin\0")? };

    // Call the entry point function to get a pointer to our Greeter struct.
    let greeter_ptr = unsafe { entry_point()? };

    // Safely access the Greeter struct via the raw pointer.
    // We need to ensure the pointer is valid before dereferencing.
    let greeter = unsafe { &*greeter_ptr };

    // Call the greet function from the plugin.
    let name = std::ffi::CString::new("World")?;
    let greeting_ptr = (greeter.greet)(name.as_ptr());

    // Convert the C-style string pointer back to a Rust String.
    let greeting = unsafe {
        // Ensure the pointer is not null before dereferencing.
        if greeting_ptr.is_null() {
            return Err("Plugin returned a null greeting pointer.".into());
        }
        CStr::from_ptr(greeting_ptr).to_string_lossy().into_owned()
    };

    println!("Greeting from plugin: {}", greeting);

    // The `greeter_ptr` points to memory managed by the plugin.
    // We need to free it using a function provided by the plugin.
    // Let's assume the plugin also exposes a `free_greeter` function.
    // In a real scenario, you'd define this in your shared trait/interface.
    type FreeGreeter = unsafe extern "C" fn(*mut Greeter);
    let free_greeter: libloading::Symbol<FreeGreeter> = unsafe { lib.get(b"free_greeter\0")? };
    unsafe { free_greeter(greeter_ptr) };

    Ok(())
}

greeter_plugin/src/lib.rs

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

// This struct must match the one defined in the main application.
// `#[repr(C)]` is crucial for ensuring memory layout compatibility.
#[repr(C)]
pub struct Greeter {
    greet: extern "C" fn(*const c_char) -> *const c_char,
}

// The actual implementation of the greeting logic.
fn perform_greet(name_ptr: *const c_char) -> *const c_char {
    let name = unsafe {
        if name_ptr.is_null() {
            return std::ptr::null();
        }
        std::ffi::CStr::from_ptr(name_ptr).to_str().unwrap_or("")
    };
    let greeting_message = format!("Hello, {}!", name);
    // We need to return a C-style string that the caller can free.
    // `CString::new` allocates memory that the caller must deallocate.
    let c_greeting = CString::new(greeting_message).expect("CString::new failed");
    // `into_raw` gives ownership of the allocated memory to the caller.
    c_greeting.into_raw()
}

// This is the entry point function that `libloading` will look for.
// It must be `pub extern "C"` and return a pointer to our `Greeter` struct.
#[no_mangle]
pub extern "C" fn load_plugin() -> *mut Greeter {
    // We create a Greeter instance on the heap.
    // The `greet` field points to our `perform_greet` function.
    let greeter_instance = Box::new(Greeter {
        greet: perform_greet,
    });
    // `into_raw` gives ownership of the Box's memory to the caller.
    Box::into_raw(greeter_instance)
}

// A function to free the memory allocated for the Greeter struct.
// This is essential to prevent memory leaks.
#[no_mangle]
pub extern "C" fn free_greeter(ptr: *mut Greeter) {
    if !ptr.is_null() {
        // `Box::from_raw` takes ownership of the memory and drops the Box,
        // deallocating the memory.
        unsafe {
            Box::from_raw(ptr);
        }
    }
}

// For the CString returned by `perform_greet`, the caller needs to free it.
// This is a common pattern in C FFI, but it's error-prone.
// A more robust approach would involve a shared trait and a more sophisticated FFI layer.

Cargo.toml (for the main app)

[package]
name = "dynamic_loading_example"
version = "0.1.0"
edition = "2021"

[dependencies]
libloading = "0.7"

Cargo.toml (for the plugin)

[package]
name = "greeter_plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"] # This is crucial for creating a dynamic library

To build and run:

  1. Build the plugin: cd greeter_plugin && cargo build --release && cd .. This will create greeter_plugin/target/release/libgreeter_plugin.so (or .dylib/.dll).
  2. Build the main application: cargo build --release
  3. Copy the plugin to the executable’s directory (or ensure it’s in the system’s library path). For simplicity, let’s copy it: cp greeter_plugin/target/release/libgreeter_plugin.so .
  4. Run the main application: ./target/release/dynamic_loading_example

You should see: Greeting from plugin: Hello, World!

The core problem this solves is runtime extensibility. You can add new functionality to an application after it’s deployed, without needing to distribute a new version of the main application. This is the foundation for things like:

  • Plugin architectures: Think of IDEs, web browsers, or audio editors where users can install extensions.
  • Dynamic configuration: Loading different sets of rules or logic based on runtime conditions.
  • Hot-swapping code: In some scenarios, replacing parts of a running system with updated versions.

Internally, libloading uses platform-specific APIs to load shared libraries (.so, .dylib, .dll). When you call Library::new(), it’s essentially a wrapper around functions like dlopen() on POSIX systems or LoadLibrary() on Windows. lib.get(b"symbol_name\0") then uses functions like dlsym() or GetProcAddress() to find the address of a specific function or variable within that loaded library.

The most surprising true thing about this is how much manual memory management and type coercion you have to do. Rust’s safety guarantees are largely bypassed when you step into the unsafe world of FFI and dynamic loading. You are responsible for ensuring that the types you define in the main application and the plugin are exactly the same in their memory representation (#[repr(C)] is key here). You also have to meticulously track memory ownership – who allocated it, who is responsible for freeing it, and that the correct freeing function is called. Forgetting to free memory, or freeing it twice, leads to crashes or undefined behavior.

The next concept you’ll run into is versioning and ABI stability. If the main application and the plugin are compiled with different versions of Rust or different compiler flags, the #[repr(C)] layout might subtly change, leading to crashes. Ensuring a stable Application Binary Interface (ABI) between your host application and its plugins is paramount for long-term maintainability.

Want structured learning?

Take the full Rust course →