Inside FFI: How Rust Talks to C Without Losing Safety
There’s a moment every Rust developer experiences — the moment you realize: “C runs everything. Rust runs everywhere. So eventually… they need to talk.” Whether you’re embedding Rust inside a game engine, calling C libraries like SQLite, or exposing Rust to Python through FFI, the moment you cross that boundary, the training wheels come off. Now it’s you, the CPU, and the ABI. Rust’s safety net shrinks, and suddenly you’re holding a loaded pointer wondering: “How do I not blow my foot off?” This is the honest, fully human-written story about how Rust talks to C — safely, aggressively, and with just enough rules to keep you from burning down the system. We’ll dive into:
- What FFI actually is
- The exact risks
- The architecture of Rust’s FFI layer
- Why Rust can be safe around unsafe code
- Examples from both sides: Rust → C and C → Rust
- Diagrams, code flows, and real-life mistakes developers make
Let’s open this black box. Why Rust Needs FFI (Real Reason) Here’s the truth: Rust cannot exist alone. Everything around us — Linux syscalls, OpenSSL, graphics drivers, the web browser engines, TensorFlow kernels — is written in C or C++. Rust isn’t here to replace them overnight. Rust is here to wrap them, stabilize them, and protect developers from their undefined-behavior landmines. That’s where FFI comes in: C gives the raw power. Rust gives the seatbelt. What FFI Actually Means (In Human Words) FFI = Foreign Function Interface It is simply: A way for one language to call functions written in another. Rust’s FFI is built around ABI compatibility — the binary-level rules that describe:
- how arguments are passed
- how structs are laid out
- how functions are called
- how return values work
Rust chooses the C ABI because: The C ABI is the lingua franca of system programming. Everything understands C. Architecture of Rust FFI (Diagram) Here’s the mental model you need: Rust Code
(safe + unsafe)
│
│
┌──────┴──────┐
│ FFI Layer │
│ (extern "C") │
└──────┬──────┘
│
▼
C Code
(raw pointers, no rules)
Rust’s job: Make this boundary explicit. C’s job: Not explode. (And it fails… often.) Step 1: Calling a C Function from Rust (Rust → C) Let’s say you have a simple C function: // add.c int add(int a, int b) {
return a + b;
} And you compile it: gcc -c add.c -o add.o Now in Rust, you declare it: extern "C" {
fn add(a: i32, b: i32) -> i32;
} Then you call it: fn main() {
unsafe {
println!("3 + 4 = {}", add(3, 4));
}
} Why unsafe? Because Rust cannot verify:
- if add actually exists
- if it accepts two integers
- if it returns an integer
- if it follows the C ABI
- if it will corrupt memory
Rust puts its foot down: “I cannot protect you beyond this line. Step inside with your own responsibility.” That’s why FFI is wrapped in unsafe. This is honesty. Not weakness. Step 2: C Calling Into Rust (C → Rust) This is where it gets emotional. You’re letting C call your Rust code — meaning the unsafe world enters Rust’s home. Example:
- [no_mangle]
pub extern "C" fn multiply(a: i32, b: i32) -> i32 {
a * b
} And in C: int multiply(int a, int b);
int main() {
printf("%d\n", multiply(6, 7));
} Why #[no_mangle]? Because Rust mangles names: multiply → _ZN4test8multiply17h39f0f2a90c6e6e41E C would never find it. #[no_mangle] says: “Expose this exactly as multiply.” Why extern "C"? Because C expects:
- call-by-register rules
- stack layout
- return value placement
Without ABI compatibility, the CPU misinterprets everything. Struct Layout: Rust vs C (The Biggest Mistake Beginners Make) C struct: typedef struct {
int x; float y;
} Point; Rust struct:
- [repr(C)]
struct Point {
x: i32, y: f32,
} Why #[repr(C)]? Because by default, Rust can reorder fields for optimization. C cannot. Without #[repr(C)], passing this struct across FFI is Russian roulette. Code Flow Diagram: Passing Structs Across FFI Rust struct ▶ repr(C) ▶ predictable memory layout
│
▼
extern "C" boundary
│
▼
C struct ▶ identical offsets
Struct offsets must match byte for byte. If they don’t? Welcome to segfault city. How Rust Maintains Safety Around Unsafe FFI Here’s the emotional brilliance of Rust: Rust does not try to make C safe. Rust makes C containable. Rust enforces safety through: ✔ Safe wrappers pub fn safe_add(a: i32, b: i32) -> i32 {
unsafe { add(a, b) }
} ✔ Ownership rules Rust ensures no aliasing — even if C doesn’t. ✔ Lifetimes Rust ensures references never outlive their data before they cross the boundary. ✔ Borrow checking Rust prevents C from modifying memory while Rust is using it. ✔ Smart pointers Rust often converts FFI pointers into NonNull<T>, Box<T>, or Arc<T>. Rust’s philosophy: Unsafe at the boundary. Safe everywhere else. This is how Rust wraps libraries like:
- OpenSSL
- SQLite
- Cairo
- TensorFlow
- Vulkan
- FFmpeg
- libc
- tens of thousands of C APIs
And turns them into safe Rust crates. Real Example: Handling C Strings Safely C string: char* ptr; Rust’s safe wrapper: pub fn c_to_rust(ptr: *const c_char) -> &'static str {
unsafe {
CStr::from_ptr(ptr)
.to_str()
.expect("invalid UTF-8")
}
} FFI is full of sharp edges, and Rust wraps every one of them in a sheath. Allocation Rules: Who Owns the Memory? This rule saves careers: The side that allocates must free. If C gives us memory: char* make_name(); void free_name(char*); Then Rust must call free_name. If Rust allocates a String and gives it to C, C must never free it using free(). This is why FFI design forces conversations like: “Who owns this buffer?” “Who frees it?” “What happens on error?” Rust does not hide these questions. It makes them explicit. Advanced: Rust Calling C Function Pointers extern "C" {
fn apply(value: i32, f: extern "C" fn(i32) -> i32) -> i32;
} Rust can pass function pointers as long as they match the ABI. Closures? No — not safe, not stable. You must wrap them. Architecture Diagram — Safe Wrapper Around Unsafe C
┌──────────────────────────────┐
│ Your App │
└──────────────┬───────────────┘
│ safe Rust
▼
┌───────────────────┐
│ Wrapper │ ← safety restored
└───────────────────┘
│ unsafe Rust
▼
┌───────────────┐
│ FFI Layer │
└───────────────┘
│ C ABI
▼
┌─────────────┐
│ C Code │
└─────────────┘
This is Rust’s gift: unsafe is isolated. safe remains safe. Emotional Truth: FFI Is Where Trust & Fear Meet FFI is intimate. You’re letting Rust hold hands with a language that has no rules, no memory safety, and no guardrails. But Rust doesn’t panic. Rust says: “I’ll let you play with fire, but you must label the matches, and carry the fire extinguisher yourself.” This is the honesty I wish other languages had. Rust doesn’t hide danger. Rust doesn’t pretend everything is safe. Rust says exactly when you must take responsibility. And that is why Rust + C works so well. Final Thoughts: FFI Is The Bridge Between Past and Future C is the world that was. Rust is the world that will be. FFI is how we cross that bridge safely. Rust is not here to rewrite 40 years of C code overnight. Rust is here to wrap it, stabilize it, and inherit it responsibly. Through:
- explicit unsafe
- predictable ABI
- lifetime guarantees
- smart wrappers
- struct layout control
- ownership clarity
Rust makes C code safer than C itself ever could. That’s magic. That’s engineering. And that’s why Rust feels like the future — even when it’s talking to the oldest language we still rely on.