FFI with C
Rust can call C code and C can call Rust. This interoperability is essential for integrating with existing libraries, operating system APIs, and embedded systems. The FFI boundary is inherently unsafe because C makes no guarantees about memory safety, but Rust gives you the tools to wrap it safely.
Calling C from Rust
Use extern "C" to declare C functions:
use std::ffi::c_int;
extern "C" {
fn abs(input: c_int) -> c_int;
fn sqrt(input: f64) -> f64;
}
fn main() {
let x = -42;
let result = unsafe { abs(x) };
println!("abs({}) = {}", x, result);
let y = 2.0;
let result = unsafe { sqrt(y) };
println!("sqrt({}) = {:.6}", y, result);
}
abs(-42) = 42
sqrt(2) = 1.414214
The extern "C" block tells the compiler these functions use the C calling convention. Every call is unsafe because the compiler cannot verify the function's behavior.
Linking to C Libraries
To use a C library, tell Cargo how to link it:
// build.rs
fn main() {
println!("cargo:rustc-link-lib=z"); // links libz (zlib)
}
Then declare the functions:
use std::ffi::{c_int, c_ulong, c_uchar};
extern "C" {
fn compress(
dest: *mut c_uchar,
dest_len: *mut c_ulong,
source: *const c_uchar,
source_len: c_ulong,
) -> c_int;
}
fn zlib_compress(data: &[u8]) -> Vec<u8> {
let mut dest_len: c_ulong = (data.len() + data.len() / 1000 + 13) as c_ulong;
let mut dest = vec![0u8; dest_len as usize];
let result = unsafe {
compress(
dest.as_mut_ptr(),
&mut dest_len,
data.as_ptr(),
data.len() as c_ulong,
)
};
assert_eq!(result, 0, "zlib compress failed");
dest.truncate(dest_len as usize);
dest
}
The safe wrapper zlib_compress hides all the raw pointer manipulation. Callers deal with &[u8] and Vec<u8>.
CString and CStr
C strings are null-terminated. Rust strings are not. You need conversion types:
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
extern "C" {
fn puts(s: *const c_char) -> i32;
fn getenv(name: *const c_char) -> *const c_char;
}
fn print_to_stdout(msg: &str) {
// Rust str -> C string (adds null terminator)
let c_msg = CString::new(msg).expect("String contains null byte");
unsafe {
puts(c_msg.as_ptr());
}
}
fn get_env_var(name: &str) -> Option<String> {
let c_name = CString::new(name).expect("String contains null byte");
let ptr = unsafe { getenv(c_name.as_ptr()) };
if ptr.is_null() {
return None;
}
// C string -> Rust str (borrows, does not allocate)
let c_str = unsafe { CStr::from_ptr(ptr) };
Some(c_str.to_string_lossy().into_owned())
}
fn main() {
print_to_stdout("Hello from Rust");
match get_env_var("HOME") {
Some(home) => println!("HOME = {}", home),
None => println!("HOME not set"),
}
}
Hello from Rust
HOME = /home/user
CString — owned, null-terminated string. Created from Rust strings. Allocates. Use when passing strings to C.
CStr — borrowed, null-terminated string. Created from C pointers. Does not allocate. Use when receiving strings from C.
CString::new fails if the input contains a null byte, because C would interpret that as the end of the string.
Calling Rust from C
Mark Rust functions with extern "C" and #[no_mangle]:
// lib.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn rust_greet(name: *const c_char) -> *mut c_char {
let c_str = unsafe {
assert!(!name.is_null());
CStr::from_ptr(name)
};
let name = c_str.to_str().unwrap_or("unknown");
let greeting = format!("Hello, {}!", name);
CString::new(greeting).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn rust_free_string(s: *mut c_char) {
if !s.is_null() {
unsafe {
drop(CString::from_raw(s));
}
}
}
The C code calls rust_greet, receives a pointer, uses it, then calls rust_free_string to deallocate:
// C code
extern int rust_add(int a, int b);
extern char* rust_greet(const char* name);
extern void rust_free_string(char* s);
int main() {
int sum = rust_add(3, 4);
printf("Sum: %d\n", sum);
char* greeting = rust_greet("World");
printf("%s\n", greeting);
rust_free_string(greeting);
return 0;
}
Build the Rust code as a C dynamic library:
// Cargo.toml
[lib]
crate-type = ["cdylib"]
#[no_mangle] prevents the compiler from changing the function name. extern "C" uses the C calling convention. Together, they make the function callable from C.
Memory Ownership Across the FFI Boundary
The most dangerous aspect of FFI is unclear ownership. Who allocates? Who frees?
Rule 1: Whoever allocates must free. If Rust allocates a string, Rust must free it. If C allocates memory, C must free it.
// Rust allocates, Rust frees
#[no_mangle]
pub extern "C" fn create_buffer(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
buf.resize(size, 0);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf); // prevent Rust from freeing
ptr
}
#[no_mangle]
pub extern "C" fn free_buffer(ptr: *mut u8, size: usize) {
if !ptr.is_null() {
unsafe {
// Reconstruct the Vec so Rust's allocator frees it
let _ = Vec::from_raw_parts(ptr, size, size);
}
}
}
Rule 2: Document the contract. Every FFI function that returns a pointer should document who is responsible for freeing it and how.
Rule 3: Never mix allocators. Memory allocated by Rust's allocator must be freed by Rust's allocator. Memory allocated by C's malloc must be freed by C's free. Crossing allocators is undefined behavior.
Bindgen for Auto-Generated Bindings
Writing extern "C" declarations by hand is tedious and error-prone. bindgen generates Rust bindings from C header files:
cargo install bindgen-cli
bindgen wrapper.h -o bindings.rs
Or use it as a build dependency:
// build.rs
fn main() {
println!("cargo:rustc-link-lib=mylib");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Could not write bindings");
}
Include the generated bindings:
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
Bindgen translates C types to Rust types, structs to structs, enums to constants, and function declarations to extern blocks. It handles most C headers correctly, including macros, typedefs, and nested structs.
Struct Layout Compatibility
For structs shared between Rust and C, use #[repr(C)]:
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
extern "C" {
fn distance(a: *const Point, b: *const Point) -> f64;
}
fn calculate_distance(a: &Point, b: &Point) -> f64 {
unsafe { distance(a as *const Point, b as *const Point) }
}
Without #[repr(C)], Rust may reorder fields for optimization. #[repr(C)] guarantees the same memory layout as a C compiler would produce.
Common Pitfalls
- Forgetting
#[repr(C)]on shared structs. Without it, the memory layout may differ between Rust and C, causing silent data corruption. - Null pointer dereference. C functions can return null. Always check before converting to a reference.
- Mixing allocators. Freeing Rust-allocated memory with C's
freeor vice versa is undefined behavior. Keep allocation and deallocation in the same language. - Passing Rust strings directly to C. Rust strings are not null-terminated. Use
CStringto add the terminator. - Forgetting
#[no_mangle]. Without it, the compiler mangles the function name and C code cannot find it. - Leaking memory across the boundary.
CString::into_rawtransfers ownership. If the C side does not call the corresponding free function, the memory leaks.
Key Takeaways
- Use
extern "C"to declare C functions and make Rust functions callable from C. CStringconverts Rust strings to null-terminated C strings.CStrborrows C strings without allocation.#[no_mangle]andextern "C"make Rust functions visible to C with stable names.- Memory must be freed by the same allocator that created it. Document ownership contracts clearly.
- Use
bindgento auto-generate Rust bindings from C headers instead of writing them by hand. - Use
#[repr(C)]on any struct that crosses the FFI boundary.