You need to call C code
You're building a high-performance image processor in Rust. You've optimized the hot loops, but you need a specific JPEG decoding routine that's been battle-tested in C for twenty years. Rewriting it in Rust takes weeks. Calling the existing C function takes minutes. You reach for extern "C".
Or maybe you're writing a system tool that needs to talk to the OS kernel via libc. Or a game engine that delegates physics simulation to a C library. The scenario is always the same: the work is done in C, and you need to cross the border to use it.
Rust doesn't forbid this. It just makes you explicit about the danger. The border between Rust and C is where safety guarantees end. Crossing it requires extern "C" and unsafe.
The bridge between two worlds
Rust and C don't speak the same language. They pack arguments into CPU registers and stack slots differently. They handle function names differently. They even disagree on the size of some basic types.
The extern "C" block is a declaration that tells the Rust compiler to switch rules. It says: "For this function, stop using Rust's calling convention. Use the C calling convention. Don't mangle the name. Trust me that this function exists somewhere in the final binary."
Think of it like a diplomatic translator. Rust speaks Rust, C speaks C. The extern "C" block is the translator's contract. It defines exactly how to hand arguments across the table so the C side understands them. If the contract is wrong, the C side receives garbage, and your program crashes or corrupts memory. The compiler won't catch this. The compiler trusts the contract.
There's a hidden trap here that catches everyone on their first FFI attempt. Rust renames functions during compilation to encode type information. This is called name mangling. A function named add might become _ZN3foo3add17h1234567890abcdefE. C doesn't do this. C expects the symbol to be exactly add.
The "C" in extern "C" tells Rust to disable name mangling for this block. If you forget the "C", the linker won't find your function because Rust will be looking for a mangled name that doesn't exist.
Minimal example
Here is the smallest working pattern. You declare the signature in an extern block, then call it inside unsafe.
// Declare the C function signature.
// This is a promise to the compiler: "This function exists."
// No function body is allowed here.
extern "C" {
fn c_add(a: i32, b: i32) -> i32;
}
fn main() {
// Calling an extern function is unsafe.
// The compiler cannot verify the C code won't crash or corrupt memory.
unsafe {
let sum = c_add(10, 20);
println!("Sum from C: {}", sum);
}
}
The extern block is just a declaration. It generates no code for the function itself. It only tells the compiler how to call it. The actual implementation must come from a compiled C object file or a shared library linked into your binary.
Convention aside: Keep extern blocks in a dedicated module, often named ffi or bindings. This isolates the unsafe surface. The rest of your codebase stays safe, and you can review all FFI interactions in one place.
What happens under the hood
When you compile this code, two distinct phases matter.
First, the Rust compiler processes the extern block. It records the signature and generates a symbol reference for c_add. Because of "C", the symbol name remains c_add. No mangling. The compiler also inserts a check: any call to c_add must be inside an unsafe block. If you try to call it safely, you get a compiler error.
Second, the linker runs. It scans all object files and libraries for a symbol named c_add. If it finds it, the build succeeds. If not, you get a linker error like undefined reference to 'c_add'. This error happens after compilation, which can be confusing. The compiler was happy; the linker is not.
At runtime, the unsafe block lets the call proceed. The CPU switches to the C calling convention. Arguments are placed in registers or on the stack according to C rules. The instruction pointer jumps to the C code. The C code executes, returns a value, and control comes back to Rust. Rust assumes the C code played nice. It assumes the return value is valid. It assumes no memory was corrupted.
The compiler trusts you. If you lie about the signature, the binary lies.
Realistic example: structs and pointers
Real FFI rarely involves just integers. You'll pass structs and pointers. This is where things get tricky.
Rust structs can reorder fields or add padding to optimize alignment. C structs follow a different layout rule. If you pass a Rust struct to C without telling the compiler to use C layout, the fields will be in the wrong place. C reads garbage.
You must use #[repr(C)] on every struct that crosses the FFI boundary. This forces Rust to lay out the struct exactly as C would.
You also need to handle pointers. C functions often take raw pointers. Rust references are safe and cannot be null. You must convert references to raw pointers using as. And you should use std::os::raw types for C primitives to document intent.
use std::os::raw::c_char;
// Force C memory layout.
// Without this, field order or padding might differ from C.
#[repr(C)]
struct Point {
x: f64,
y: f64,
}
// Declare C function that takes a pointer to Point.
extern "C" {
fn point_distance(p: *const Point) -> f64;
}
fn main() {
let p = Point { x: 3.0, y: 4.0 };
unsafe {
// Convert reference to raw pointer.
// Rust gives up ownership checks here.
let dist = point_distance(&p as *const Point);
println!("Distance: {}", dist);
}
}
Notice *const Point. The C function expects a pointer, not a reference. If you pass &p directly, the compiler rejects it with E0308 (mismatched types). You must cast to a raw pointer. The cast tells the compiler you accept the risk.
Convention aside: Use std::os::raw types like c_int, c_char, and c_void instead of i32 or u8. C types vary by platform. A C int might be 16 bits on some embedded systems. Rust i32 is always 32 bits. Using c_int documents that you're matching a C type, and it handles platform differences automatically.
Pitfalls and errors
FFI is where undefined behavior lives. The compiler helps, but it can't save you from everything.
Struct layout mismatch. Forgetting #[repr(C)] is the most common mistake. The code compiles. The linker succeeds. The program runs. Then C reads the wrong field and crashes three seconds later. There is no warning. Treat #[repr(C)] like a seatbelt. Forgetting it won't stop the car, but it will kill you in the crash.
Type size mismatch. C long is 32 bits on Windows and 64 bits on Linux. Rust i32 is always 32 bits. If you map C long to Rust i32 on Linux, you truncate the value. Use std::os::raw::c_long to match the platform size.
Null pointers. C functions often return null on error. Rust references cannot be null. If you convert a null pointer to a reference, you get undefined behavior. The safe pattern is to return Option<&T>. Rust's Option uses null as the None representation, so it maps perfectly to C null pointers.
extern "C" {
fn get_config() -> *const c_char;
}
fn safe_get_config() -> Option<&'static str> {
unsafe {
let ptr = get_config();
// Convert raw pointer to Option.
// If ptr is null, this returns None safely.
// If ptr is valid, this returns Some with a str slice.
std::ffi::CStr::from_ptr(ptr)
.to_str()
.ok()
.map(|s| s.to_string())
}
}
Dereferencing raw pointers. If you try to dereference a raw pointer outside an unsafe block, the compiler stops you with E0133 (dereference of raw pointer requires unsafe). This is a good error. It forces you to acknowledge the risk.
Linker errors. If the symbol isn't found, the linker fails. This usually means you forgot to link the library. You can add #[link(name = "mylib")] to the extern block to tell the linker where to look. Or use cargo dependencies if the library is available as a crate.
Undefined behavior doesn't announce itself. It just corrupts your heap three seconds later.
When to use extern "C"
Use extern "C" when you need to call a function implemented in C or another language that follows the C ABI. Use #[repr(C)] on every struct that crosses the FFI boundary to guarantee memory layout matches C expectations. Reach for the libc crate when you need standard C functions like malloc or printf instead of declaring them manually. Pick bindgen when you have a large C header file and don't want to write the extern block by hand. Use unsafe blocks to wrap the call, keeping the unsafe surface as small as possible.
Isolate the FFI. Keep the unsafe block small. Keep the rest of your code safe.