Skip to content

Latest commit

 

History

History
402 lines (306 loc) · 11.8 KB

File metadata and controls

402 lines (306 loc) · 11.8 KB

Rust FFI - Calling C++ from Rust

Rust can call C and C++ code through its Foreign Function Interface (FFI). Since C++ has no stable ABI, C++ functions must be exposed with extern "C" linkage to be callable from Rust. This chapter covers the fundamentals of binding C++ libraries, handling different data types across the FFI boundary, and creating safe Rust wrappers.

The key challenge in FFI is that Rust's safety guarantees don't extend across the boundary - all FFI calls are inherently unsafe. The common pattern is to write thin unsafe FFI declarations, then wrap them in safe Rust functions that enforce proper usage.

Basic FFI Setup

Source:src/rust/ffi

Setting up Rust to call C++ code requires three components working together: the C++ source code with C-compatible function signatures, a build script that compiles and links the C++ code into your Rust project, and Rust declarations that tell the compiler about the foreign functions. The cc crate handles the compilation complexity, automatically detecting the system's C++ compiler and configuring the build correctly.

Project structure:

ffi/
├── Cargo.toml      # build-dependencies = { cc = "1.0" }
├── build.rs        # Compiles cpp-lib.cc
├── cpp-lib.cc      # C++ library with extern "C"
└── main.rs         # Rust FFI declarations and wrappers

Cargo.toml:

The cc crate is specified as a build dependency since it's only needed during compilation, not at runtime. This keeps your final binary free of unnecessary dependencies.

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

[build-dependencies]
cc = "1.0"

build.rs:

The build script runs before your Rust code compiles. It invokes the cc crate to compile the C++ source file into a static library, which Cargo then automatically links into your final executable. The .cpp(true) flag tells cc to use the C++ compiler instead of the C compiler.

fn main() {
    cc::Build::new()
        .cpp(true)           // Compile as C++
        .file("cpp-lib.cc")
        .compile("cpp_lib"); // Output library name
}

Calling Simple Functions

The simplest FFI case involves functions with primitive types like integers and floats. These types have identical memory representations in both Rust and C++, so no conversion is needed. The extern "C" block in Rust declares the function signature, and the unsafe block is required because the compiler cannot verify the C++ implementation is correct.

C++ (cpp-lib.cc):

extern "C" {
    int32_t cpp_add(int32_t a, int32_t b) {
        return a + b;
    }
}

Rust:

extern "C" {
    fn cpp_add(a: i32, b: i32) -> i32;
}

// Safe wrapper
pub fn add(a: i32, b: i32) -> i32 {
    unsafe { cpp_add(a, b) }
}

fn main() {
    println!("Result: {}", add(10, 20)); // 30
}

C++ equivalent (calling C from C++):

// In C++, you'd use extern "C" to call C functions
extern "C" int c_function(int x);

int main() {
    int result = c_function(42);
}

Passing Arrays and Pointers

When passing arrays across the FFI boundary, C and C++ represent them as raw pointers with a separate length parameter. Rust's slice type (&[T] or &mut [T]) combines the pointer and length into a single fat pointer, but this representation isn't compatible with C. The safe wrapper pattern extracts the raw pointer and length from the slice, passes them to the C++ function, and ensures the slice remains valid for the duration of the call.

C++:

extern "C" {
    void cpp_fill_array(int32_t* arr, size_t len, int32_t value) {
        for (size_t i = 0; i < len; ++i) {
            arr[i] = value;
        }
    }
}

Rust:

extern "C" {
    fn cpp_fill_array(arr: *mut i32, len: usize, value: i32);
}

// Safe wrapper using slices
pub fn fill_array(arr: &mut [i32], value: i32) {
    unsafe {
        cpp_fill_array(arr.as_mut_ptr(), arr.len(), value)
    }
}

fn main() {
    let mut arr = [0i32; 5];
    fill_array(&mut arr, 42);
    println!("{:?}", arr); // [42, 42, 42, 42, 42]
}

C++ equivalent:

#include <span>  // C++20

void fill_array(std::span<int32_t> arr, int32_t value) {
    for (auto& x : arr) x = value;
}

String Handling

Strings are one of the trickiest types to pass across FFI boundaries because Rust and C use fundamentally different string representations. Rust's String is a UTF-8 encoded, length-prefixed, heap-allocated buffer without a null terminator. C strings are null-terminated byte arrays with no length field. The CString type creates an owned, null-terminated string suitable for passing to C, while CStr provides a borrowed view of a C string for reading data returned from C functions.

C++:

extern "C" {
    // Returns heap-allocated string - caller must free
    char* cpp_create_greeting(const char* name) {
        const char* prefix = "Hello, ";
        const char* suffix = "!";
        size_t len = strlen(prefix) + strlen(name) + strlen(suffix) + 1;
        char* result = new char[len];
        strcpy(result, prefix);
        strcat(result, name);
        strcat(result, suffix);
        return result;
    }

    void cpp_free_string(char* s) {
        delete[] s;
    }
}

Rust:

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

extern "C" {
    fn cpp_create_greeting(name: *const c_char) -> *mut c_char;
    fn cpp_free_string(s: *mut c_char);
}

pub fn create_greeting(name: &str) -> String {
    // Convert Rust &str to C string
    let c_name = CString::new(name).expect("CString::new failed");

    unsafe {
        let ptr = cpp_create_greeting(c_name.as_ptr());
        // Convert C string back to Rust String
        let result = CStr::from_ptr(ptr).to_string_lossy().into_owned();
        // Free the C++ allocated memory
        cpp_free_string(ptr);
        result
    }
}

Key types:

  • CString - Owned, null-terminated string for passing to C. Allocates memory and appends a null byte.
  • CStr - Borrowed reference to a null-terminated string from C. Zero-cost wrapper around a *const c_char.
  • c_char - Platform-specific C char type (usually i8 on most platforms).

Passing Structs

Structs can be shared between Rust and C++ when they have compatible memory layouts. By default, Rust is free to reorder struct fields and add padding for optimization. The #[repr(C)] attribute forces Rust to use the same field ordering and alignment rules as C, making the struct binary-compatible. This is essential for any struct that crosses the FFI boundary, whether passed by value or by pointer.

C++:

extern "C" {
    struct Point {
        double x;
        double y;
    };

    double cpp_distance(const Point* p1, const Point* p2) {
        double dx = p2->x - p1->x;
        double dy = p2->y - p1->y;
        return sqrt(dx * dx + dy * dy);
    }

    Point cpp_midpoint(const Point* p1, const Point* p2) {
        return Point{(p1->x + p2->x) / 2.0, (p1->y + p2->y) / 2.0};
    }
}

Rust:

#[repr(C)]  // Use C-compatible memory layout
#[derive(Debug, Clone, Copy)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

extern "C" {
    fn cpp_distance(p1: *const Point, p2: *const Point) -> f64;
    fn cpp_midpoint(p1: *const Point, p2: *const Point) -> Point;
}

pub fn distance(p1: &Point, p2: &Point) -> f64 {
    unsafe { cpp_distance(p1, p2) }
}

pub fn midpoint(p1: &Point, p2: &Point) -> Point {
    unsafe { cpp_midpoint(p1, p2) }
}

fn main() {
    let p1 = Point { x: 0.0, y: 0.0 };
    let p2 = Point { x: 3.0, y: 4.0 };
    println!("Distance: {}", distance(&p1, &p2));   // 5.0
    println!("Midpoint: {:?}", midpoint(&p1, &p2)); // Point { x: 1.5, y: 2.0 }
}

Type Mapping Reference

Common type mappings between Rust and C/C++:

Rust C/C++ Notes
i8, i16, i32, i64 int8_t, int16_t, int32_t, int64_t Fixed-size integers
u8, u16, u32, u64 uint8_t, uint16_t, uint32_t, uint64_t Fixed-size unsigned
f32, f64 float, double Floating point
bool bool Boolean (1 byte)
usize size_t Pointer-sized unsigned
isize ptrdiff_t Pointer-sized signed
*const T const T* Immutable pointer
*mut T T* Mutable pointer
c_char char Platform-specific char
c_void void Opaque type

Bindgen for Automatic Bindings

For large C/C++ libraries with hundreds of functions and types, manually writing FFI declarations is tedious and error-prone. The bindgen tool solves this by parsing C/C++ header files and automatically generating the corresponding Rust extern blocks, struct definitions, and type aliases. This ensures your Rust declarations always match the actual C++ signatures, eliminating a common source of subtle bugs.

Cargo.toml:

[build-dependencies]
bindgen = "0.69"
cc = "1.0"

build.rs with bindgen:

The build script first compiles the C++ library, then runs bindgen to parse the header file and generate Rust bindings. The generated code is written to the OUT_DIR, a Cargo-managed directory for build artifacts, and included in your Rust code at compile time.

use std::env;
use std::path::PathBuf;

fn main() {
    // Compile C++ library
    cc::Build::new()
        .cpp(true)
        .file("cpp-lib.cc")
        .compile("cpp_lib");

    // Generate bindings from header
    let bindings = bindgen::Builder::default()
        .header("cpp_lib.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("Unable to generate bindings");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings");
}

Using generated bindings:

// Include the generated bindings
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

See Also