Rust FFI Guide

repository·master·Indexed 43 days ago

https://github.com/michael-f-bryan/rust-ffi-guide

A guide on traversing the Foreign Function Interface (FFI) boundary between Rust and other languages. It specifically demonstrates building a REST client using Rust (reqwest) for business logic and C++ (Qt) for the GUI, covering topics such as C interface creation, C++ wrapper classes, CMake configuration, automated header generation with cbindgen, and dynamic loading with libloading.

Tokens
16.2K
Snippets
54
Records
61
Agent score
52%

What's inside Rust FFI Guide

  1. Overview of the Rust FFI Guide objectives

    master

    This guide provides a deep dive into advanced Foreign Function Interface (FFI) tasks, moving beyond simple function calls to complex integrations. The primary use case demonstrated is building a REST client where a Qt GUI (C++) calls into a Rust library for business logic.

    Key learning objectives include:

    • Integrating cargo into a larger build system (e.g., cmake).
    • Calling Rust functions from C++ or other languages.
    • Passing complex data types like strings, structs, and arrays between Rust and C++.
    • Implementing robust error handling and exception safety.
    • Creating a C-compatible interface for a Rust library.
    • Handling multithreading and asynchronous programming to prevent UI blocking.
    • Creating abstractions to encapsulate common FFI patterns.

    For developers looking to implement these patterns immediately, the ffi-helpers crate is available to provide the abstractions discussed in this guide.

  2. Concept: Safe array passing between Rust and C++

    master

    When passing arrays (like byte buffers) between languages via FFI, follow these principles to avoid memory corruption:

    1. Avoid returning language-specific dynamic types: Do not return a Vec<u8> (Rust) or std::vector (C++) directly across the FFI boundary. The caller must return the object to the original language to be freed correctly.
    2. Use the 'Caller-Provided Buffer' pattern: The safest way to pass data is to have the caller allocate a buffer and pass a pointer and a length to the provider. The provider then copies the data into that buffer.
    3. Prevent Use-After-Free: If you return a pointer to memory managed by Rust, the C++ side must not hold onto that pointer after the Rust Response object is destroyed. Copying the data into a C++-managed buffer (like std::vector) is the recommended way to ensure data longevity.
  3. Expose Rust functions to C++ using #[no_mangle] and extern "C"

    master

    To call a Rust function from C++, you must prevent name mangling and specify the correct calling convention.

    1. #[no_mangle]: This attribute tells the Rust compiler not to change the function name during compilation, allowing the C++ linker to find the exact symbol name.
    2. extern "C": This specifies the C calling convention, which defines how arguments are passed between functions and is the standard for FFI (Foreign Function Interface).

    Rust implementation:

    #[no_mangle]
    pub extern "C" fn hello_world() {
        println!("Hello World!");
    }

    C++ declaration: In your C++ code, wrap the function declaration in an extern "C" block to match the Rust side:

    extern "C" {
        void hello_world();
    }
  4. Handle Rust panics in C++

    master

    When a Rust function called from C++ triggers a panic!, it can lead to undefined behavior or immediate process termination. Use #[no_mangle] to ensure the symbol name is preserved for the C++ linker.

    // foo.rs
    #[no_mangle]
    pub extern "C" fn foo() {
        panic!("Oops...");
    }
    // main.cpp
    extern "C" {
        void foo();
    }
    
    int main() {
        foo();
    }
  5. Understand the `errno`-style error handling mechanism

    master

    The project uses a thread-local error handling pattern inspired by errno and libgit2. Instead of returning complex error types across the FFI boundary, fallible functions return an "obviously invalid" value (such as 0, -1, or null). If a function fails, the caller can then query a thread-local storage to retrieve the specific error details.

    Key Workflow:

    1. A function fails and returns an invalid value (e.g., null).
    2. The caller checks the return value.
    3. If invalid, the caller calls error retrieval functions to get the error message.
    4. The error retrieval functions clear the most recent error from the thread-local storage.
  6. Define the Request and Response data structures

    master

    The library uses simplified, public data containers for HTTP interactions to facilitate easy access by a C++ GUI.

    Request

    A Request object contains:

    • destination: A reqwest::Url.
    • method: A reqwest::Method.
    • headers: reqwest::header::Headers.
    • cookies: A cookie::CookieJar.
    • body: An Option<Vec<u8>>.

    Response

    A Response object contains:

    • headers: reqwest::header::Headers.
    • body: Vec<u8>.
    • status: reqwest::StatusCode.
  7. How dynamic loading works with `libloading`

    master

    Dynamic loading allows an application to load a shared library (like a .so or .dll) at runtime and call functions exported by that library.

    To use this pattern:

    1. Compile the library as a cdylib: You must use the cdylib crate type so the compiler produces a compatible dynamic library.
    2. Export symbols: Use #[no_mangle] and pub extern "C" on functions you want to access from the host application.
    3. Load the library: Use the libloading crate to load the file and retrieve symbols.
    4. Cast symbols: Use a type alias for the function signature to ensure type safety when calling the loaded symbol via libloading::Symbol.
    # Compile a Rust file as a dynamic library
    rustc --crate-type cdylib adder.rs
    // Example of loading a symbol
    type AddFunc = unsafe fn(isize, isize) -> isize;
    
    fn main() {
        let library_path = "../libadder.so";
        let lib = libloading::Library::new(library_path).unwrap();
    
        unsafe {
            let func: libloading::Symbol<AddFunc> = lib.get(b"add").unwrap();
            let answer = func(1, 2);
            println!("1 + 2 = {}", answer);
        }
    }
  8. Integrate Cargo into CMake via custom targets

    master

    To build the Rust library transparently during the CMake build process, create a client/CMakeLists.txt that invokes cargo build.

    Implementation details:

    1. Build Type Handling: Use an if statement on CMAKE_BUILD_TYPE to switch between cargo build (Debug) and cargo build --release (Release).
    2. Target Directory Management: Set the CARGO_TARGET_DIR environment variable to a path within the CMake build directory to keep build artifacts organized.
    3. Custom Target: Use add_custom_target to run the cargo command and cp the resulting .so file into the CMake binary directory.
    4. Location Property: Set the LOCATION property on the client target so other CMake targets (like gui) can find the library path using get_target_property.
    # client/CMakeLists.txt
    
    if (CMAKE_BUILD_TYPE STREQUAL "Debug")
        set(CARGO_CMD cargo build)
        set(TARGET_DIR "debug")
    else ()
        set(CARGO_CMD cargo build --release)
        set(TARGET_DIR "release")
    endif ()
    
    set(CLIENT_SO "${CMAKE_CURRENT_BINARY_DIR}/${TARGET_DIR}/libclient.so")
    
    add_custom_target(client ALL
        COMMENT "Compiling client module"
        COMMAND CARGO_TARGET_DIR=${CMAKE_CURRENT_BINARY_DIR} ${CARGO_CMD} 
        COMMAND cp ${CLIENT_SO} ${CMAKE_CURRENT_BINARY_DIR}
        WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
    set_target_properties(client PROPERTIES LOCATION ${CMAKE_CURRENT_BINARY_DIR})
    
    add_test(NAME client_test 
        COMMAND cargo test
        WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
  9. Build the application using Docker

    master

    If you prefer not to install dependencies locally, you can use the michaelfbryan/ffi-guide Docker image to compile the Rust and Qt application. This command mounts your current directory and cargo home to the container and executes the test script.

    $ docker run \
        -v $(pwd):/code \
        -v ~/.cargo:$HOME/.cargo \
        -e CARGO_HOME=$HOME/.cargo \
        --user $UID \
        michaelfbryan/ffi-guide ci/test.sh
  10. Integrate generated headers into a CMake project

    master

    Once cbindgen generates a header file (e.g., client.hpp) in your Rust target/ directory, you must configure CMake to include that directory in its search path so your C++ code can #include it.

    In your CMakeLists.txt, set the include directory to the location where the header is generated:

    set(CLIENT_BUILD_DIR ${CMAKE_BINARY_DIR}/client)
    include_directories(${CLIENT_BUILD_DIR})

    After configuring CMake, you can update your C++ source files to:

    1. #include "client.hpp".
    2. Remove manual extern "C" blocks.
    3. Prefix Rust function calls with the namespace defined in your build.rs (e.g., ffi::initialize_logging()).
    set(CMAKE_INCLUDE_CURRENT_DIR ON)
    find_package(Qt5Widgets)
    
    set(CLIENT_BUILD_DIR ${CMAKE_BINARY_DIR}/client)
    include_directories(${CLIENT_BUILD_DIR})
    
    set(SOURCE main_window.cpp main_window.hpp wrappers.cpp wrappers.hpp main.cpp)
    
    add_executable(gui ${SOURCE})
  11. Wrap Rust FFI in a C++ class

    master

    To avoid using non-idiomatic and error-prone raw C-style bindings in your C++ application, create a wrapper class. This allows you to use C++ features like destructors for automatic memory management and std::string for easier input handling.

    1. Header (wrappers.hpp): Define a class that holds a void* (or a pointer to the opaque type) representing the raw Rust object.
    2. Implementation (wrappers.cpp):
      • Use extern "C" blocks to declare the raw Rust functions.
      • In the constructor, call the Rust creation function and convert the input to a C-style string using .c_str().
      • In the destructor, call the Rust destruction function to prevent memory leaks.
    3. Error Handling: Check if the raw pointer returned by Rust is nullptr and throw a C++ exception if it is.
    // gui/wrappers.hpp
    #include <string>
    
    class Request {
    public:
      Request(const std::string);
      ~Request();
    
    private:
      void *raw;
    };
    
    // gui/wrappers.cpp
    #include "wrappers.hpp"
    
    extern "C" {
    void *request_create(const char *);
    void request_destroy(void *);
    }
    
    Request::Request(const std::string url) {
      raw = request_create(url.c_str());
      if (raw == nullptr) {
        throw "Invalid URL";
      }
    }
    
    Request::~Request() { request_destroy(raw); }
  12. Create a C interface for a Rust struct

    master

    To expose a Rust struct (like Request) to C++, you must hide it behind a raw pointer because the C interface cannot handle Rust-specific features like generics.

    1. Constructor: Use Box::new() to move the struct to the heap, then call Box::into_raw() to return a raw pointer to the caller.
    2. Destructor: Accept the raw pointer, convert it back to a Box using Box::from_raw(), and allow the Box to be dropped to ensure memory is freed.
    3. Naming Convention: Prefix functions with the type name (e.g., request_create, request_destroy) to simulate method ownership.
    4. Error Handling: When converting C strings (*const c_char) to Rust types (like Url), return a null pointer (ptr::null_mut()) if the conversion fails.
    // client/src/ffi.rs
    
    #[no_mangle]
    pub unsafe extern "C" fn request_create(url: *const c_char) -> *mut Request {
        if url.is_null() {
            return ptr::null_mut();
        }
    
        let raw = CStr::from_ptr(url);
    
        let url_as_str = match raw.to_str() {
            Ok(s) => s,
            Err(_) => return ptr::null_mut(),
        };
    
        let parsed_url = match Url::parse(url_as_str) {
            Ok(u) => u,
            Err(_) => return ptr::null_mut(),
        };
    
        let req = Request::new(parsed_url, Method::Get);
        Box::into_raw(Box::new(req))
    }
    
    #[no_mangle]
    pub unsafe extern "C" fn request_destroy(req: *mut Request) {
        if !req.is_null() {
            drop(Box::from_raw(req));
        }
    }