How to Call C Functions from Rust
You're building a high-performance image processor in Rust. The core logic is fast, safe, and idiomatic. Then you hit a wall. There's a battle-tested C library for JPEG compression that's been optimized for decades. Rewriting it in Rust would take months and might not match the performance. You need to call that C code directly from your Rust program.
This is Foreign Function Interface, or FFI. Rust handles this by letting you declare external functions and crossing a boundary into unsafe territory. You get the speed and ecosystem of C without giving up Rust's safety for the rest of your codebase.
The border crossing
Rust and C speak different languages. Not just syntax, but rules. Rust checks every pointer, every borrow, and every lifetime. C assumes you know what you're doing and gives you raw memory access. Calling C from Rust is like sending a package across a border where the customs rules are completely different.
You need a declaration that tells the Rust compiler, "I'm handing this off to C. Use C's rules for how arguments go into registers and how the return value comes back. Don't try to enforce Rust's safety checks on this specific call."
That declaration is extern "C". The "C" part specifies the calling convention. It's the handshake protocol for how data moves between the two worlds. If you get the convention wrong, arguments end up in the wrong registers and the program crashes. Rust provides extern "C" to match the standard C ABI, ensuring the compiler generates the right machine code for the call.
Minimal example
Here is the bare minimum to call a C function. You declare the signature and wrap the call in unsafe.
/// Declare the external C function signature.
/// The "C" string specifies the C calling convention.
extern "C" {
fn printf(format: *const i8, ...) -> i32;
}
fn main() {
// C strings must be null-terminated.
// Rust byte strings don't add this automatically.
let c_str = b"Hello\0";
// FFI calls are unsafe because Rust cannot verify
// the safety of code running outside its control.
unsafe {
// Convert the Rust byte slice pointer to a C pointer type.
// The cast is necessary because types differ between languages.
printf(c_str.as_ptr() as *const i8);
}
}
What happens under the hood
When you write extern "C", you're not linking the code yet. You're just telling the compiler the shape of the function. The compiler generates a stub that expects the function to exist somewhere. At link time, the linker resolves the symbol to the actual C library. If the symbol isn't found, you get a linker error, not a compiler error. This separation is important. The compiler checks your Rust code. The linker checks that your promises match reality.
The unsafe block is the gatekeeper. The compiler allows the call only because you explicitly opted in. Inside the block, you're responsible for everything. If you pass a dangling pointer, Rust won't catch it. If the C function writes past the buffer, Rust won't stop it. The unsafe keyword doesn't mean the code is dangerous. It means the safety invariants are not checked by the compiler. You have to prove they hold.
If you forget the unsafe block, the compiler rejects you with E0133 (unsafe function call requires unsafe function or block). You can't call external functions from safe code. This rule prevents accidental FFI calls and forces you to acknowledge the risk.
Realistic usage
In real projects, you rarely write extern "C" blocks for standard C functions. The community uses the libc crate. It provides pre-declared bindings for thousands of C functions across different platforms. Using libc saves you from guessing types and handles platform differences automatically.
Convention aside: libc is the standard way to access C functions. Rolling your own bindings for standard libraries invites bugs and wastes time. Reach for libc first.
Here is how you use libc with a wrapper function. Wrapping FFI calls in safe functions is a best practice. It isolates the unsafe code and lets you document the safety contract.
/// Use the libc crate for standard C bindings.
/// This avoids manual extern blocks and handles platform differences.
use libc::printf;
use std::ffi::CString;
/// Call printf from Rust with a safe interface.
/// This wrapper isolates the unsafe block and documents the safety contract.
fn print_c_string(message: &str) {
// CString ensures the string is null-terminated and contains no internal nulls.
// This is safer than manually appending \0.
let c_message = CString::new(message)
.expect("CString::new failed due to internal null byte");
// FFI calls require an unsafe block.
// The compiler cannot verify the safety of the C function.
unsafe {
// SAFETY:
// 1. c_message is a valid CString, so it is null-terminated.
// 2. c_message.as_ptr() returns a valid pointer to the string data.
// 3. printf is a standard C function with a known signature.
// 4. The pointer remains valid for the duration of the call.
printf(c_message.as_ptr());
}
}
fn main() {
print_c_string("Rust calling C safely!\n");
}
The // SAFETY: comment lists the invariants that make the unsafe block safe. This is a community convention. It helps reviewers verify that you've considered all the risks. If you can't write the safety comment, you probably don't have a safety proof.
Convention aside: CString is the standard way to create C strings. It checks for null bytes and manages the lifetime. Using b"..." works for literals, but CString is safer for dynamic data.
Passing structs
C functions often take structs. Rust and C lay out structs differently. Rust might reorder fields or add padding for optimization. C uses a fixed layout. If you pass a Rust struct to C without telling the compiler to match C's layout, you'll read garbage data.
You fix this with #[repr(C)]. This attribute tells Rust to lay out the struct exactly like C does. Field order is preserved, and padding matches C's rules.
/// Tell Rust to lay out the struct exactly like C does.
/// Without this, field order and padding might differ.
#[repr(C)]
struct Point {
x: f64,
y: f64,
}
extern "C" {
fn draw_point(p: Point);
}
fn main() {
let p = Point { x: 10.0, y: 20.0 };
unsafe {
// Pass the struct by value.
// The repr(C) ensures the bytes match C's expectation.
draw_point(p);
}
}
Always annotate FFI structs with #[repr(C)]. The compiler won't warn you if you forget. You'll get subtle bugs that are nearly impossible to debug.
Pitfalls and errors
FFI introduces risks that Rust's borrow checker can't catch. Here are the common traps.
Type mismatches are frequent. C uses int, Rust uses i32. On most platforms, they match. On some embedded systems, they might not. The compiler will flag E0308 (mismatched types) if you pass a Rust String where a *const i8 is expected. You have to convert explicitly. Use libc types like c_int or c_char to match C's types exactly.
Lifetimes are another hazard. C functions might store pointers you pass them. If the Rust value drops while C still holds the pointer, you get a use-after-free. Rust won't prevent this across the FFI boundary. You have to manage the lifetime manually. Keep Rust values alive as long as C needs them.
Linker errors are common for beginners. extern "C" is a declaration, not a definition. If you don't link the C library, the linker fails. You need to tell cargo where the library is. For system libraries, this usually happens automatically. For custom libraries, you might need a build.rs script or a Cargo.toml configuration. If you see "undefined reference" errors, check your linking setup.
Error handling differs too. C functions often return -1 on error and set a global errno. Rust doesn't know about errno. You have to check the return value and read errno manually. The libc crate provides errno access. Wrap C calls in functions that return Result to bring Rust's error handling into the mix.
FFI is a bridge, not a tunnel. Data can leak, pointers can dangle, and types can shift. Verify every boundary.
When to use what
Use extern "C" when you declare functions from a custom C library that lacks existing Rust bindings. Use the libc crate when you call standard C library functions like malloc, printf, or strlen to avoid manual declarations. Use bindgen when you face a large C header file and need to generate Rust bindings automatically from the C definitions. Use unsafe blocks to wrap every FFI call, keeping the block minimal to isolate the unsafe surface area. Use CString when you pass string data to C, ensuring null-termination and preventing internal null bytes. Use #[repr(C)] when you define structs that cross the FFI boundary, guaranteeing compatible memory layout.
Keep the unsafe surface small. The rest of your code stays safe.