Calling C from Rust: The FFI Boundary
You have a C library that does exactly what you need. Maybe it is a decades-old signal processing routine, a proprietary hardware driver, or a high-performance math library that no one has ported yet. Rewriting it in Rust takes months. You need to use the C code today. Rust lets you call C functions directly, but the compiler won't let you do it by accident. You have to cross a boundary where Rust's safety guarantees end and the wild west of C begins.
This boundary is called FFI, or Foreign Function Interface. Crossing it requires two things: a declaration of what the C function looks like, and a waiver telling the compiler you accept the risk.
The Contract: extern and unsafe
Think of FFI like a customs checkpoint. Rust is a country with strict security rules. Every value is inspected, types are verified, and nothing dangerous gets through. C is a neighboring country with no border control. You can carry anything, but you might also carry explosives.
To call a C function, you walk through the checkpoint. The extern block is your declaration form. You tell Rust exactly what you are bringing back and what you are sending over. The unsafe block is the waiver you sign. You are telling the compiler, "I know what I am doing, and if this crashes, it is on me."
The string "C" in extern "C" is not just a label. It specifies the calling convention. This is the ABI, or Application Binary Interface. It defines how arguments are passed to the function. Does the caller put integers in registers or on the stack? Does it clean up the stack after the call? C has a specific set of rules for this. Rust functions use a different set of rules by default. If you call a C function using Rust's calling convention, the arguments land in the wrong registers. The C function reads garbage. The program crashes or returns nonsense. The "C" string ensures the compiler generates the correct handshake.
Minimal Example: Printing to C
The simplest FFI call involves a function with basic types. The standard C library function printf is a classic example. It takes a pointer to a string and returns an integer.
// Declare the C function signature.
// This does not generate code; it only registers the promise with the compiler.
extern "C" {
/// Print formatted output to stdout.
fn printf(format: *const i8) -> i32;
}
fn main() {
// SAFETY: printf is called with a valid null-terminated string.
// The format string contains no format specifiers, so no arguments are needed.
unsafe {
// Create a byte string literal.
// The \0 ensures the string is null-terminated for C.
let msg = b"Hello from Rust\0";
// Convert the slice pointer to a raw pointer.
// C expects a pointer to i8, not a Rust slice.
printf(msg.as_ptr() as *const i8);
}
}
The extern "C" block registers a promise with the compiler. There exists a function named printf with this signature somewhere. If you call it with the wrong types, the compiler rejects you with E0308 (mismatched types). If you call it with the right types, the compiler trusts you.
The unsafe block is where the actual call happens. Rust allows unsafe blocks anywhere, but the community convention is to keep them as small as possible. Wrap only the call, not the whole function. This isolates the danger. If the unsafe block is tiny, it is easier to verify that the code inside is correct.
At runtime, the CPU jumps to the C function's address. The linker must have resolved the symbol printf to an actual memory location. If you are linking against a custom library, you need to tell the linker where to find it. The #[link(name = "mylib")] attribute on the extern block handles this.
Keep the unsafe block tight. Isolate the boundary crossing.
Realistic Example: Structs and Safe Wrappers
Real C libraries use structs. You need to map C types to Rust types. int usually becomes i32. char* becomes *const i8. Pointers are raw pointers. The tricky part is structs. Rust packs structs to optimize alignment. C compilers might pad fields differently. If the layouts differ, the C function reads the wrong bytes. This is a silent bug. The compiler won't catch it. The program just returns wrong numbers.
The #[repr(C)] attribute forces the Rust struct to match C padding rules. Without it, FFI with structs is broken.
// Force the struct layout to match C conventions.
// Without this, field ordering and padding may differ.
#[repr(C)]
struct Point {
x: f64,
y: f64,
}
// Link against a hypothetical C library named "geometry".
#[link(name = "geometry")]
extern "C" {
/// Calculate distance from origin.
/// Returns -1.0 on error.
fn point_distance(p: *const Point) -> f64;
}
/// A safe wrapper that checks preconditions before calling C.
/// This function exposes a safe API to the rest of the Rust codebase.
fn safe_distance(x: f64, y: f64) -> Result<f64, &'static str> {
let p = Point { x, y };
// SAFETY: point_distance is called with a valid pointer to a Point.
// The C function only reads the struct and does not store the pointer.
// The pointer remains valid for the duration of the call.
let result = unsafe { point_distance(&p as *const Point) };
// Map the C error code to a Rust Result.
if result < 0.0 {
Err("C function returned an error code")
} else {
Ok(result)
}
}
The safe_distance function demonstrates the wrapper pattern. The unsafe block is inside a safe function. The safe function checks preconditions. In this case, the pointer is valid because p is a local variable. The wrapper returns a Result, mapping the C error convention to Rust's idiomatic error handling.
Never expose unsafe to the world. Create a safe function that calls the unsafe block. The safe function checks preconditions. If checks fail, return Err. If checks pass, enter unsafe. This isolates the risk. Other Rust code calls safe_distance and never sees unsafe.
Convention aside: For large C headers, writing extern blocks by hand is error-prone. The community standard is to use bindgen. This tool parses C headers and generates Rust extern blocks and #[repr(C)] structs automatically. It handles platform-specific types and complex macros. Use bindgen for anything more than a handful of functions.
The SAFETY Comment: Your Proof
The compiler does not read comments. The next developer reads them. Every unsafe block needs a // SAFETY: comment. This comment lists the invariants that make the code safe. It is a proof. If you cannot write the proof, the code is not safe.
A good SAFETY comment answers specific questions:
- Is the pointer non-null?
- Is the data valid for the required duration?
- Are there aliasing violations?
- Does the C function have side effects that break Rust assumptions?
Bad comments say "This is safe." Good comments say "The pointer is non-null because it comes from Box::into_raw. The data is valid because the Box is not dropped until the end of the function."
Treat the SAFETY comment as a proof. If you can't write it, you don't have one.
Pitfalls and Compiler Errors
FFI introduces bugs that Rust usually prevents. The compiler catches some mistakes, but many slip through.
If you try to dereference a raw pointer without unsafe, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe block or function). This is a helpful error. It stops you from accidentally using raw pointers in safe code.
The bigger danger is passing a null pointer to a C function that expects a valid pointer. Rust has no null values. C does. If you pass a null pointer, the C function dereferences it and segfaults. Rust won't warn you. You must check for null explicitly.
Lifetimes are another trap. C functions might store pointers you give them. If the C function stores a pointer to Rust data, and Rust drops the data, the C function has a dangling pointer. This causes use-after-free bugs. You must ensure the Rust data lives as long as the C code needs it. Sometimes this means leaking memory with Box::leak or using Arc to manage lifetime across the boundary.
Calling conventions can also cause subtle bugs. On Windows, the C calling convention differs from the system calling convention. Some libraries expect extern "system" instead of extern "C". Using the wrong one corrupts the stack. Check the library documentation.
The compiler trusts you. Don't make it regret that.
Decision Matrix
Use extern "C" blocks when you have a small number of functions and want full control over the signature. Use bindgen when you are wrapping a large C header file with many structs and enums. Use #[repr(C)] on every struct that crosses the FFI boundary; omitting it is a recipe for silent memory corruption. Reach for a safe wrapper function when you want to expose the C functionality to other Rust code without leaking unsafe. Avoid raw pointers in the public API; convert them to &T or Box<T> inside the safe wrapper. Use ctypes types when you need platform-specific integer sizes that match C's int or long. Pick extern "system" on Windows when the C library documentation specifies the system calling convention.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Isolate the FFI calls behind a clean safe API.