Why you need to know this
Most production systems don't start from scratch. You inherit libraries written in C, maintain legacy codebases, or integrate with high-performance C libraries that have no Rust equivalent. FFI: calling C from Rust — practical tips and trade-offs becomes essential when you want Rust's safety guarantees without rewriting everything. We've seen teams spend weeks debugging memory issues because they skipped the safety layer, so this guide covers what actually works.
Understanding extern "C" declarations
The extern "C" keyword tells the Rust compiler to use C's calling convention and name mangling rules. Without it, your Rust function names get mangled in ways that C linkers can't find. When you declare a C function in Rust, you're creating a binding to an existing compiled library.
Let's say you have a C library that does image resizing. Your header file looks like this:
// Original C header (image_lib.h)
// int resize_image(const char* input_path, const char* output_path, int width, int height);
// void free_buffer(char* buffer);In Rust, you declare these C functions using extern "C":
extern "C" {
pub fn resize_image(
input_path: *const u8,
output_path: *const u8,
width: i32,
height: i32,
) -> i32;
pub fn free_buffer(buffer: *mut u8);
}Notice the raw pointers. C doesn't have Rust's ownership system, so you work with *const for read-only data and *mut for mutable data. These pointers bypass Rust's safety checks, which is why FFI code always lives in unsafe blocks.
The return type is i32 because we're mapping C's int. Type mapping matters. C's long on a 32-bit system differs from a 64-bit system, which is why we use fixed-size types like i32, i64, usize. The libc crate provides platform-correct type aliases for C types.
Using bindgen to generate bindings automatically
Writing extern declarations by hand is error-prone. The bindgen tool reads C headers and generates Rust bindings for you. This is how production teams avoid typos and missed function signatures.
Add bindgen to your build dependencies in Cargo.toml:
[build-dependencies]
bindgen = "0.69"Create a build.rs file at your crate root:
use bindgen::Builder;
use std::path::PathBuf;
fn main() {
let bindings = Builder::default()
.header("vendor/image_lib.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
During compilation, this generates bindings.rs` containing all C types and functions. In your main code:
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));The advantage is immediate: if a C library updates and adds new functions, you regenerate bindings without manual updates. We've used this with libraries like libssl, libcurl, and proprietary trading libraries. The generated code is verbose but correct.
Bindgen isn't magical though. Complex C macros and function pointers sometimes require manual fixes. Check the generated bindings before committing.
Wrapping unsafe calls in safe functions
Raw FFI is unsafe by necessity, but you can contain that unsafety by wrapping C functions in safe, idiomatic Rust APIs. This is how you build maintainable systems.
Let's build a safe wrapper around our image resize function:
use std::ffi::CString;
use std::path::Path;
pub struct ImageResizer;
impl ImageResizer {
pub fn resize(
input: &Path,
output: &Path,
width: i32,
height: i32,
) -> Result<(), Box> {
// Convert paths to C strings
let input_cstr = CString::new(input.to_string_lossy().as_bytes())?;
let output_cstr = CString::new(output.to_string_lossy().as_bytes())?;
// Call unsafe C function inside a safe wrapper
let result = unsafe {
resize_image(
input_cstr.as_ptr() as *const u8,
output_cstr.as_ptr() as *const u8,
width,
height,
)
};
if result == 0 {
Ok(())
} else {
Err(format!("Resize failed with code {}", result).into())
}
}
}
// Usage
fn main() {
match ImageResizer::resize(
Path::new("input.jpg"),
Path::new("output.jpg"),
800,
600,
) {
Ok(()) => println!("Resize succeeded"),
Err(e) => eprintln!("Error: {}", e),
}
}This wrapper does several things: converts Rust paths to C strings, documents error codes, and returns Result instead of raw integers. The caller never touches unsafe code directly. The unsafe block is isolated and minimal, making code review easier.
We've seen teams accidentally leak memory or create buffer overflows by calling C functions directly throughout the codebase. Centralizing FFI in a module with safe wrappers prevents this.
Managing memory carefully
This is where most bugs happen. C and Rust have different ownership models. If C allocates memory, C must free it (usually). If Rust allocates and passes to C, you need to ensure C doesn't try to free Rust-managed memory.
Let's look at a real scenario: you call a C function that returns a buffer:
extern "C" {
pub fn get_config_string() -> *const u8;
pub fn free_config_string(ptr: *const u8);
}
// Unsafe: what if you forget to free?
unsafe {
let ptr = get_config_string();
let data = std::str::from_utf8(
std::slice::from_raw_parts(ptr, 100) // Don't hardcode length!
)?;
println!("{}", data);
// Oops, never called free_config_string
}Build a safe wrapper with automatic cleanup using RAII (Resource Acquisition Is Initialization):
pub struct ConfigString {
ptr: *const u8,
}
impl ConfigString {
pub fn new() -> Result> {
unsafe {
let ptr = get_config_string();
if ptr.is_null() {
Err("Config string is null".into())
} else {
Ok(ConfigString { ptr })
}
}
}
pub fn as_str(&self) -> Result<&str, Box> {
// In real code, you'd query the actual length from C
unsafe {
let slice = std::slice::from_raw_parts(self.ptr, 256);
let nul_pos = slice.iter().position(|&b| b == 0)
.ok_or("String not null-terminated")?;
Ok(std::str::from_utf8(&slice[..nul_pos])?)
}
}
}
impl Drop for ConfigString {
fn drop(&mut self) {
unsafe {
free_config_string(self.ptr);
}
}
}
// Usage - safe and automatic
fn main() -> Result<(), Box> {
let config = ConfigString::new()?;
println!("{}", config.as_str()?);
// Drop runs automatically, freeing memory
Ok(())
}The Drop implementation guarantees cleanup even if the function returns early or panics. This is production-grade FFI.
Thread safety is another memory concern. If you're passing pointers between threads, ensure the C library is thread-safe or use Mutex to serialize access. We once had a crash in production because a "thread-safe" C library wasn't actually thread-safe under concurrent load. Read the C library's documentation carefully.
Common mistakes
Hardcoding buffer lengths: Never assume a C function's output is exactly N bytes. Query the length first or use null terminators.
Forgetting null terminators: C strings end with \0. When passing Rust strings to C, use CString which handles this automatically. Raw byte strings might not be null-terminated.
Mixing ownership models: If C allocates, C frees. If Rust allocates, Rust frees. Never let the other language try. Use wrappers to hide this complexity from callers.
Ignoring error codes: C functions often return status codes. Check every return value before using the output.
Unsafe in the wrong place: Your unsafe block should be as small as possible. Unsafe doesn't mean "ignore safety"; it means "I'm responsible for proving this is safe." Document why each unsafe block is safe.
Key takeaways
extern "C"declarations and bindgen let you call C functions from Rust, but they require raw pointers and unsafe blocks.- Always wrap C functions in safe Rust APIs to prevent memory leaks and buffer overflows spreading throughout your codebase.
- Use
CStringfor Rust-to-C string conversions and implementDropfor automatic resource cleanup when C allocates memory. - Test FFI code thoroughly, especially boundary conditions and thread safety, since errors are often silent and crash in production.
- Keep unsafe blocks small and documented. FFI unsafety is necessary but should never leak into your application logic.






Comments
Loading comments…