Rust’s Foreign Function Interface (FFI) is less about creating a bridge and more about defining a shared boundary where both Rust and C agree on a common language.
Let’s say you have a C library that does some heavy lifting, perhaps image processing or complex mathematical calculations, and you want to leverage it from your Rust application. You can’t just use it like a regular Rust crate. You need to tell Rust how to talk to it.
Here’s a simple C function in math_ops.c:
// math_ops.c
int add_integers(int a, int b) {
return a + b;
}
And its header file math_ops.h:
// math_ops.h
#ifndef MATH_OPS_H
#define MATH_OPS_H
int add_integers(int a, int b);
#endif // MATH_OPS_H
To use add_integers in Rust, you’d first declare its signature in a Rust extern "C" block. This tells the Rust compiler that add_integers is a function that follows the C calling convention, takes two i32 (Rust’s 32-bit integer, which maps directly to C’s int on most platforms) arguments, and returns an i32.
// src/main.rs
// Declare the C function signature
extern "C" {
fn add_integers(a: i32, b: i32) -> i32;
}
fn main() {
let x = 10;
let y = 20;
// Call the C function
let sum = unsafe {
add_integers(x, y)
};
println!("The sum is: {}", sum); // Output: The sum is: 30
}
The unsafe block is crucial. Rust can’t guarantee the safety of calling foreign code because it doesn’t own the C compiler’s guarantees (or lack thereof). You’re telling Rust, "I, the programmer, have checked this and I promise it’s safe."
To actually compile and link this, you’d typically use cc or cargo-c crates. For a simple case with cc:
-
Create a
build.rsfile: This build script will compile your C code.// build.rs fn main() { cc::Build::new() .file("math_ops.c") .compile("math_ops"); // Creates libmath_ops.a } -
Tell Cargo to link the library: In
Cargo.toml:[build-dependencies] cc = "1.0" [dependencies] # No direct dependency needed for the C library itself if linked via build.rs -
Run
cargo build: This will compilemath_ops.cinto a static library and link it with your Rust executable.
The mental model is that Rust’s extern "C" block defines the interface to the C world. It’s like creating a contract. You specify the function names, their argument types, and their return types, all while adhering to C’s ABI (Application Binary Interface). Rust doesn’t know or care about the C code’s internal implementation; it only cares that the function at a specific memory address will behave according to the declared signature.
When you call an extern "C" function from Rust, the compiler generates assembly code that:
- Pushes arguments onto the stack or places them in registers according to the C ABI.
- Performs a direct function call to the given symbol (the C function’s name).
- Retrieves the return value from the designated register or stack location.
- Handles potential system calls or other low-level operations needed to interact with the C runtime.
The unsafe keyword is the explicit acknowledgment that you are stepping outside Rust’s memory safety guarantees. This is necessary because Rust cannot verify:
- Memory management: C code might allocate memory that Rust then tries to free, or vice-versa, leading to double-frees or memory leaks.
- Pointer validity: C pointers can be null, dangling, or uninitialized. Rust’s strong type system usually prevents these issues internally.
- Data races: If the C code is not thread-safe and is called from multiple Rust threads, data races can occur.
- Correctness of types: While
i32maps well toint, complex C types likestructs, unions, or opaque pointers (void*) require careful mapping in Rust to avoid misinterpretation.
For C++, the situation is more complex due to name mangling and C++'s features like classes, exceptions, and templates. You typically need to wrap C++ functions with extern "C" functions that act as a stable FFI boundary.
// Example for C++
extern "C" {
// Assuming a C-style wrapper function in C++
fn call_cpp_add(a: i32, b: i32) -> i32;
}
In your C++ source:
// cpp_wrapper.cpp
#include <iostream> // For std::cout (if needed for debugging)
// Assume your actual C++ class/function is defined elsewhere
// For simplicity, let's just use a C-style function for the FFI boundary
extern "C" {
int call_cpp_add(int a, int b) {
// In a real scenario, you'd call your C++ code here
// e.g., return MyCppClass().add(a, b);
return a + b; // Simple example
}
}
The most surprising aspect of Rust FFI is how it forces you to think about the abi – the Application Binary Interface. It’s not just about function signatures; it’s about how data is laid out in memory, how arguments are passed, and how functions are invoked at the machine code level. Rust takes on the burden of ensuring its own code adheres to the ABI, but it relies on you to ensure the C/C++ side does too, and that the Rust types you use in your extern "C" blocks correctly mirror the C/C++ types.
When dealing with C structs, you’ll often use #[repr(C)] in Rust. This tells the Rust compiler to lay out the fields of your struct in memory in a way that is compatible with C’s layout rules, preventing surprises when passing them across the FFI boundary.
#[repr(C)]
struct Point {
x: i32,
y: i32,
}
extern "C" {
fn process_point(p: Point); // Pass a struct
}
The next hurdle you’ll likely encounter is managing complex data structures, especially those involving pointers and dynamic allocation, and ensuring they are correctly deallocated on the correct side of the FFI boundary.