FFI
Advanced · Runtime & ecosystem
What & why
FFI stands for Foreign Function Interface — it’s how Rust talks to code written in another language, almost always C. You use it to call an existing C library (image decoders, databases, the operating system) from Rust, or to let a C program call your Rust code. The hard part isn’t the syntax; it’s agreeing with the other language about how data is shaped, who frees memory, and what happens when things go wrong.
The idea, slowly
Two languages meeting at a border
Imagine two countries that speak different languages, meeting at a border crossing. Inside Rust, everyone follows Rust’s strict rules: the borrow checker watches every value, memory is freed automatically, strings know their length. Inside C, none of that is true — C strings are just bytes that end in a zero, nobody checks anything, and you free memory by hand.
FFI is the border crossing between them. At that border, Rust’s guarantees stop. The compiler
can check your Rust up to the edge, but once a value crosses into C, Rust has no idea what happens
to it. That’s why everything about FFI is marked unsafe: you are telling the compiler “I’ve
checked this by hand, trust me.”
The extern block: declaring foreign functions
To call a C function, you first declare it so Rust knows its name and its signature. You do that
in an extern "C" block. The "C" part is the ABI — the “application binary interface,” the
low-level agreement about how arguments are passed in registers and on the stack. C and Rust don’t
naturally agree on this, so you spell it out.
// This declares a function that lives in the C standard library.
// Rust does NOT define it here — it just promises "this exists somewhere."
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
// Calling a foreign function is ALWAYS unsafe, because Rust can't verify
// that `abs` actually behaves the way we claimed.
let result = unsafe { abs(-5) };
println!("abs(-5) = {result}");
}
This example links against the C standard library, so it will not run on the Playground’s Run
button reliably. Put it in a real project and run cargo run. The point to absorb: you declare
the function’s shape, and every call sits inside unsafe { }.
Going the other way: exposing Rust to C
Sometimes you want C (or Python, or Node, or a game engine) to call your Rust function. Two things have to happen:
extern "C"on the function tells Rust to use the C calling convention so C knows how to call it.#[unsafe(no_mangle)]stops Rust from “mangling” the name. Normally Rust scrambles function names into long unique symbols; C wouldn’t be able to findaddif it were renamed to something like_ZN3add17h9f....no_manglekeeps the name exactlyadd.
#![allow(unused)]
fn main() {
// In a real library crate (a `cdylib` or `staticlib`), C can now call `add`.
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
}
On older Rust you’ll see plain
#[no_mangle]; modern editions prefer the explicit#[unsafe(no_mangle)]because exporting a raw symbol is itself an unsafe promise. Both compile; theunsafe(...)form is the current recommendation.
The real challenge: data layout and ownership
Simple numbers like i32 cross the border fine — C and Rust agree on what a 32-bit integer is.
The trouble starts with anything bigger:
- Strings. A Rust
Stringknows its length and is not zero-terminated. A C string is just bytes ending in a\0. They are not interchangeable. You convert withstd::ffi::CString(Rust → C) andCStr(C → Rust). - Structs. Rust is free to reorder struct fields for efficiency. C never does. If you share a
struct across the border, you must add
#[repr(C)]so Rust lays it out exactly the way C expects. - Ownership. This is the big one. If Rust allocates memory and hands a pointer to C, who frees it? If both free it, you get a crash. If neither frees it, you leak. FFI has no borrow checker to sort this out — you decide the rule and document it loudly.
The golden pattern: wrap the unsafe part
The professional move is to keep all the scary unsafe FFI calls in one small private module, and
wrap them in a normal, safe Rust function that the rest of your program uses. The unsafe code is
tiny and auditable; everyone else gets a friendly, checked interface.
Common mistakes
- Forgetting
#[repr(C)]on shared structs. Rust may reorder or pad fields differently than C, so the two sides read each other’s data at the wrong offsets. It compiles, then corrupts data at runtime — the worst kind of bug. - Assuming a Rust
Stringis a C string. It isn’t zero-terminated and can contain interior nulls, so passing its bytes straight to C reads past the end or stops early. Convert withCString/CStr. - Getting the ABI wrong (
extern "C"missing). Without the right ABI, arguments land in the wrong places and the call quietly produces garbage or crashes. - Confusing ownership across the border. Freeing memory on the wrong side (or on both sides) causes double-frees and use-after-free. There’s no compiler to catch it — you must define and document who owns what.
- Skipping
unsafementally. FFI compiles to real machine calls with zero checking. Treat every boundary as a place a bug can hide.
More examples
Computing a square root via the C math library
Not every C function needs a custom library — sqrt already lives in the C standard library, so declaring it is enough to borrow it instead of reimplementing it in Rust.
// Links against the C math library — run in a real project, not the Playground.
extern "C" {
fn sqrt(x: f64) -> f64;
}
fn main() {
let result = unsafe { sqrt(64.0) };
println!("sqrt(64.0) = {result}");
}
Measuring a C string’s length safely
Handing a C function a raw Rust String would read garbage past the end, since C expects a zero-terminated string — CString builds one that strlen can safely walk.
// Links against the C standard library — run in a real project, not the Playground.
use std::ffi::CString;
extern "C" {
fn strlen(s: *const i8) -> usize;
}
fn main() {
let greeting = CString::new("hello from rust").expect("no interior nulls");
let length = unsafe { strlen(greeting.as_ptr()) };
println!("C measured the string at {length} bytes");
}
Exposing a checksum function for a C caller to link against
A C program validating downloaded files needs a fast checksum — exporting one from Rust with no_mangle lets it call straight into compiled Rust code instead of a slower C implementation.
#![allow(unused)]
fn main() {
// In a real library crate (a `cdylib`), a C program could link against this and call `checksum`.
#[unsafe(no_mangle)]
pub extern "C" fn checksum(data: *const u8, len: usize) -> u32 {
let bytes = unsafe { std::slice::from_raw_parts(data, len) };
bytes.iter().map(|&b| b as u32).sum()
}
}
Wrapping an unsafe sensor-reading call in a safe API
A C sensor driver returns readings through an out-pointer and a status code — wrapping that in a function returning Option<SensorReading> means the rest of the program never touches unsafe directly.
// Links against a C sensor library — run in a real project, not the Playground.
#[repr(C)]
struct SensorReading {
temperature_c: f32,
humidity_pct: f32,
}
extern "C" {
fn read_sensor(out: *mut SensorReading) -> i32;
}
fn read_sensor_safe() -> Option<SensorReading> {
let mut reading = SensorReading { temperature_c: 0.0, humidity_pct: 0.0 };
let status = unsafe { read_sensor(&mut reading) };
if status == 0 {
Some(reading)
} else {
None
}
}
fn main() {
match read_sensor_safe() {
Some(r) => println!("{}C, {}%", r.temperature_c, r.humidity_pct),
None => println!("sensor read failed"),
}
}
Your turn
This one is a spot-the-bug, because FFI needs a real toolchain and can’t run on the Playground. Here is a struct a beginner wants to share with a C library. Two things are wrong for FFI. What are they, and why do they bite?
#![allow(unused)]
fn main() {
// Meant to be passed by pointer into a C function.
struct Point {
x: i32,
y: i32,
}
extern {
fn draw_point(p: *const Point);
}
}
Show solution
Two fixes:
#[repr(C)] // 1. force C-compatible field layout
struct Point {
x: i32,
y: i32,
}
extern "C" { // 2. name the ABI explicitly
fn draw_point(p: *const Point);
}
fn main() {
let p = Point { x: 3, y: 4 };
unsafe { draw_point(&p); } // and every call is unsafe
}
Why each matters:
#[repr(C)]— without it, Rust is allowed to reorder or pad the fields however it likes, so C might readxwhere Rust puty. It compiles cleanly and then corrupts data at runtime.extern "C"— a bareexterndoesn’t state the ABI clearly. Naming"C"guarantees Rust and C agree on how arguments and pointers are passed.
And notice the call itself is wrapped in unsafe { } — dereferencing a raw pointer in C code is
something only you can vouch for.
Quick check
Remember this
- FFI is the border between Rust and another language (usually C); Rust’s safety guarantees stop at that border.
extern "C" { ... }declares foreign functions; calling them is alwaysunsafe.#[unsafe(no_mangle)] pub extern "C" fnexposes a Rust function to C with an unscrambled name.- Put
#[repr(C)]on any struct that crosses the boundary, and convert strings withCString/CStr. - Decide explicitly who owns and frees memory — there is no borrow checker across FFI.
- Wrap the tiny unsafe FFI core in a safe Rust API for everyone else to use.
Go deeper
- Rust Reference - FFI — Extern blocks and ABIs.
Next: