When your host language hits a wall
You have a Ruby on Rails app that's choking on image processing. Or a Java service where a regex-heavy validation step is eating CPU cycles. Or a C# desktop app that needs a fast, safe parser for a proprietary file format. You found a Rust crate that does exactly what you need, but your host language won't let you just import rust_crate. Rust doesn't run on the JVM. It doesn't speak Ruby's object model. It doesn't know what a .NET assembly is. You need a bridge.
The bridge is the C Application Binary Interface, or C ABI. Think of the C ABI as the universal plug standard for compiled code. Rust can compile to a shared library that looks exactly like a C library to the outside world. Ruby, Java, and C# all have ways to load C libraries and call their functions. You write Rust, you export it as C, and your host language calls it like any other native extension. The constraint is the boundary. Rust's safety guarantees stop at the FFI border. The host language sees raw memory and pointers. You have to manage the handshake carefully.
The C ABI bridge
Rust's type system is rich. It has generics, lifetimes, enums with data, and zero-cost abstractions. C has none of that. C has integers, floats, pointers, and structs with fixed layouts. When you cross the FFI boundary, you must flatten Rust's types into C-compatible primitives. A Vec<T> becomes a pointer and a length. A String becomes a pointer to a null-terminated byte array. A Result<T, E> becomes an error code or a separate error buffer.
The compiler helps you stay honest. If you try to return a Rust String from an extern "C" function, the compiler rejects you with E0277 (trait bound not satisfied). The type simply doesn't match the C ABI. You have to perform the conversion yourself. This friction is a feature. It forces you to think about memory ownership and layout before you ship the library.
Minimal example
Start with a Rust library crate. Change the crate type to cdylib in Cargo.toml. This tells cargo to produce a dynamic library instead of a static archive or an executable.
# Cargo.toml
[lib]
crate-type = ["cdylib"]
Convention aside: cdylib is the standard choice for FFI. It produces the correct file extension and linking format for the target OS. Don't use rlib for FFI consumption; that's for Rust-to-Rust dependencies.
Write a function with extern "C" and #[no_mangle]. The extern "C" attribute tells the compiler to use the C calling convention for argument passing and return values. The #[no_mangle] attribute prevents the compiler from renaming the function symbol. Without it, the host language won't find the function by name.
/// Add two integers and return the sum.
#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
Build the library with cargo build --release. The output lands in target/release/. On Linux, you get libmy_lib.so. On macOS, libmy_lib.dylib. On Windows, my_lib.dll. The filename and extension depend on the OS. The host language loader finds this file, maps it into memory, and resolves the symbol add_numbers.
Walkthrough
When the host language calls add_numbers, the runtime pushes the arguments onto the stack or into registers, following the platform's C calling convention. It jumps to the Rust code. The Rust function executes, computes the sum, and places the result in the return register. Control returns to the host. The host reads the return value and continues.
This dance works perfectly for simple types. Integers, floats, and pointers all map directly. The danger starts when you deal with memory. If you return a pointer to data that lives on the Rust stack, the data vanishes when the function returns. The host gets a dangling pointer. Dereferencing it causes undefined behavior. The compiler catches the most obvious mistakes with E0515 (cannot return value referencing local data). You can't return a reference to a local variable. But the compiler won't stop you from returning a raw pointer to stack memory if you use unsafe to cast it. That's on you.
Realistic example: Memory and structs
Real FFI involves passing data, not just numbers. You need to handle strings, arrays, and structs. Rust and C disagree on how to represent these. You have to translate.
For strings, the standard pattern is a null-terminated C string. Rust strings are UTF-8 and not null-terminated. You can return a pointer to a static string, or allocate a buffer that the host frees. Returning a static pointer is safe if the string lives for the duration of the program.
/// Return a pointer to a static C-string.
/// The caller must treat this as a null-terminated string.
#[no_mangle]
pub extern "C" fn get_greeting() -> *const i8 {
static MSG: &str = "Hello from Rust";
// Convention: *const i8 matches C's char*.
// Rust strings are u8, but C char is often signed.
MSG.as_ptr() as *const i8
}
Convention aside: *const i8 is the Rust convention for C char*. Even though bytes are u8, C strings are traditionally signed chars in many ABIs. Use *const i8 to match C expectations.
For dynamic data, you often need to allocate memory in Rust and hand the pointer to the host. The host must call a Rust function to free it. This avoids double-free errors and ensures the allocator matches.
/// Allocate a buffer. Returns null on failure.
#[no_mangle]
pub extern "C" fn allocate_buffer(size: usize) -> *mut u8 {
if size == 0 {
return std::ptr::null_mut();
}
// Layout requires alignment. 8 is safe for most types.
let layout = std::alloc::Layout::from_size_align(size, 8).unwrap_unchecked();
// SAFETY:
// 1. size is checked > 0.
// 2. alignment 8 is valid power of 2.
unsafe { std::alloc::alloc(layout) }
}
/// Free a buffer allocated by allocate_buffer.
#[no_mangle]
pub extern "C" fn free_buffer(ptr: *mut u8) {
if ptr.is_null() {
return;
}
// SAFETY:
// 1. ptr was returned by allocate_buffer.
// 2. ptr is valid and aligned.
// 3. ptr has not been freed yet.
unsafe {
let layout = std::alloc::Layout::from_size_align(1, 8).unwrap_unchecked();
std::alloc::dealloc(ptr, layout);
}
}
Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have a safe unsafe block.
Structs require #[repr(C)]. Rust can reorder struct fields for optimization. C cannot. If you pass a struct across FFI, you must lock the layout to the C representation.
/// A point in 2D space.
/// Must use repr(C) for FFI compatibility.
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
/// Create a point and return it by value.
#[no_mangle]
pub extern "C" fn make_point(x: f64, y: f64) -> Point {
Point { x, y }
}
Convention aside: #[repr(C)] is mandatory for any struct that crosses the FFI boundary. The compiler won't enforce this automatically. If you forget it, the layout might match by accident on your machine but break on another architecture. Treat #[repr(C)] as part of the function signature.
Pitfalls and compiler errors
Memory management is the biggest trap. Never return Rust objects directly. A Vec contains a pointer, length, and capacity. The host language sees three random integers. It doesn't know how to free the allocation. You must flatten the data. Return a pointer and length, or copy the data into a buffer the host controls.
Error handling is another minefield. Rust Result<T, E> is a type. C has no equivalent. You can't return a Result to Ruby or C#. You have to encode errors. Common patterns include returning an error code integer and using an output parameter for the result. Or write the error message to a global buffer and return a pointer to it.
Panicking across FFI is undefined behavior. It usually aborts the process. Don't panic in FFI functions. Handle errors and return codes. If you must call code that might panic, wrap the call in catch_unwind.
use std::panic::catch_unwind;
/// Safe wrapper around a potentially panicking function.
#[no_mangle]
pub extern "C" fn safe_add(a: i32, b: i32) -> i32 {
// Catch panics to prevent aborting the host process.
match catch_unwind(|| a + b) {
Ok(result) => result,
Err(_) => -1, // Return error code on panic.
}
}
Convention aside: Wrapping FFI functions in catch_unwind is a safety net. It turns a process crash into a recoverable error code. Use it liberally in public FFI boundaries.
Threading requires care. If the host language calls your Rust function from multiple threads, the Rust code must be thread-safe. Global mutable state needs Mutex or Atomic. The compiler won't stop you from writing unsafe code that races, but the runtime will tear itself apart. Ensure your library is Send and Sync where appropriate.
Decision matrix
Use extern "C" with #[no_mangle] when you need to export a function from a Rust library to a host language. Use cdylib crate type when you are building a shared library for FFI consumption. Use *const i8 for C strings when you need to pass text across the boundary. Use a struct with ptr and len fields when you need to pass a slice or vector. Use an error code return value when you need to signal failure to a host that doesn't support exceptions. Use catch_unwind when you want to protect the host process from Rust panics. Reach for jni-rs or jni-sys when you are targeting Java and need full JNI integration. Reach for cbindgen when you have a large Rust API and want to generate C headers automatically. Reach for plain references when lifetimes are simple; the unsafe alternative is rarely worth it.
Trust the C ABI. It's the only contract you have with the other side.