When C needs a speed boost

You're staring at a C codebase that's been running for a decade. It works, mostly. But there's that one module handling cryptographic hashes that keeps tripping up on buffer overflows. Or maybe you just want the performance of a Rust vector without rewriting the whole application. You don't need to port everything. You can write the new logic in Rust, compile it down, and drop it into your C project like a drop-in replacement. The bridge between the two is extern "C".

The ABI contract

C and Rust don't share a native conversation protocol. Rust functions have complex names generated by the compiler, often including type information and module paths. This is called name mangling. C doesn't understand mangled names. C expects simple, flat symbols. To make them talk, you tell Rust to export a function using the C Application Binary Interface, or ABI. This strips away the Rust-specific naming and ensures arguments and return values are laid out in memory exactly how C expects them. You're essentially wrapping your Rust logic in a C-compatible shell.

The ABI dictates three things: how the function is named, how arguments are passed (registers versus stack), and who cleans up the stack frame after the call returns. If the ABI doesn't match, the compiler might compile both sides, but the runtime will crash or return garbage data. Treat the ABI as a contract. If the layout doesn't match, the data is garbage.

Minimal example

Here is the smallest working bridge. You write a Rust function, compile it to a static library, and link it from C.

// lib.rs
/// Exports a simple addition function with C-compatible naming and calling convention.
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    // Return the sum. The types match C's int exactly on most platforms.
    a + b
}
# Build the Rust library as a static archive.
cargo build --release --lib
# Link the C program against the generated static library.
gcc main.c -L target/release -lmy_rust_lib -o my_app
// main.c
#include <stdio.h>

// Declare the external function signature so the compiler knows the types.
extern int add(int a, int b);

int main() {
    // Call the Rust function as if it were a standard C function.
    int result = add(2, 3);
    printf("Result: %d\n", result);
    return 0;
}

What happens under the hood

When you compile lib.rs, Cargo produces a static library file, usually named libmy_rust_lib.a on Linux or libmy_rust_lib.lib on Windows. The #[no_mangle] attribute stops the Rust compiler from renaming add to something like _ZN3my5add17h1234567890.rs. Without this, the linker would fail to find the symbol. The linker rejects the build with an "undefined reference to add" error if the symbol names don't match.

The extern "C" part ensures the function uses the C calling convention. This dictates how arguments are passed and who cleans up the stack frame. If you omit extern "C", the compiler might use a Rust calling convention that C doesn't recognize. The result is usually a stack corruption crash the moment you call the function.

Structs and memory layout

Functions are easy. Structs are where FFI breaks if you aren't careful. Rust is allowed to reorder struct fields or change padding to optimize memory access. C cannot see reordered fields. If you pass a Rust struct to C without telling the compiler to respect C's layout, C will read the wrong values.

/// A struct with C-compatible memory layout for passing complex data.
#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

/// Creates a new Point and returns it by value.
#[no_mangle]
pub extern "C" fn make_point(x: f64, y: f64) -> Point {
    Point { x, y }
}

The #[repr(C)] attribute forces Rust to lay out fields in declaration order and use C padding rules. This guarantees that sizeof(Point) and the offset of x match what C expects. Add #[repr(C)] to every struct that crosses the boundary. The compiler won't warn you if you forget; it will just corrupt your data.

Strings and ownership

Real code rarely just adds integers. You'll likely pass strings. Rust strings are UTF-8 with length metadata. C strings are null-terminated byte arrays. You can't pass a &str directly to C. You need to convert.

use std::ffi::CStr;
use std::os::raw::c_char;

/// Greets a user by name, handling the conversion from C string to Rust string.
#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> i32 {
    // SAFETY: The caller must provide a valid, null-terminated C string.
    // We trust the C side to uphold this contract.
    let result = unsafe {
        if name.is_null() {
            // Return an error code if the pointer is null.
            return -1;
        }
        // Convert the raw pointer to a CStr, then to a Rust &str.
        // This panics if the bytes aren't valid UTF-8, which is a safe failure mode.
        let c_name = CStr::from_ptr(name);
        let rust_name = c_name.to_str().unwrap_or("Unknown");
        println!("Hello, {}!", rust_name);
    };
    0
}

Notice the use of std::os::raw::c_char. On most platforms this is i8, but on Windows it can be u8. Using the raw type alias ensures your FFI code works across targets without conditional compilation. The community convention is to always use c_char for C string pointers, never i8 or u8 directly.

Returning strings is trickier. If you allocate a string in Rust and give the pointer to C, you must tell C how to free it. Never let C call free on Rust memory. The allocators might differ, leading to silent heap corruption. Provide a dedicated deallocator function.

use std::ffi::CString;
use std::os::raw::c_char;

/// Returns a heap-allocated string that C must free using `free_string`.
#[no_mangle]
pub extern "C" fn get_message() -> *mut c_char {
    // Create a C-compatible string from Rust.
    let message = CString::new("Hello from Rust").unwrap();
    // Leak the string to give ownership to C.
    // C is responsible for calling free_string to reclaim memory.
    message.into_raw()
}

/// Frees a string allocated by `get_message`.
#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
    // SAFETY: The pointer must have been returned by `get_message`.
    // We reconstruct the CString to drop it and free the memory.
    unsafe {
        if !ptr.is_null() {
            let _ = CString::from_raw(ptr);
        }
    }
}

Pitfalls and panics

If you panic in Rust while called from C, the program aborts. Rust panics try to unwind the stack, but C doesn't know how to handle Rust's unwind metadata. The result is a hard crash. You must catch panics before they cross the FFI boundary.

use std::panic;

/// Wraps a Rust function to catch panics and return an error code to C.
#[no_mangle]
pub extern "C" fn safe_compute() -> i32 {
    // Set a catch_unwind hook to prevent stack unwinding across the FFI boundary.
    let result = panic::catch_unwind(|| {
        // Perform the actual work here.
        do_risky_work()
    });

    match result {
        Ok(value) => value,
        Err(_) => -1, // Return error code on panic.
    }
}

fn do_risky_work() -> i32 {
    // Simulate work that might panic.
    42
}

Catch panics at the border. Letting a panic cross FFI is a guaranteed abort.

Another pitfall is assuming pointers are valid. C code often passes null pointers or dangling pointers without checking. Rust's unsafe block assumes the invariants hold. If you dereference a null pointer in unsafe, you get undefined behavior. Always check for null before converting to safe types. Validate pointers before dereferencing. C lies about nulls; Rust assumes truth.

Decision matrix

Use extern "C" with #[no_mangle] when you need to expose Rust functions to C codebases or any language that supports C FFI. Use #[repr(C)] on every struct that crosses the boundary to guarantee memory layout compatibility. Use std::ffi::CStr and std::ffi::CString when passing strings across the boundary; never pass Rust String or &str directly. Use panic::catch_unwind when your Rust code might panic and you want to return a controlled error code instead of aborting the process. Use std::os::raw types like c_int and c_char for all FFI signatures to guarantee type compatibility across platforms. Reach for cbindgen when you are exporting Rust to C and want to generate the header file automatically; it keeps your .h file in sync with your Rust code. Pick bindgen when you are calling C from Rust and need to generate Rust bindings automatically; manual bindings are error-prone for large headers.

Automate the bindings. Manual headers rot faster than code.

Where to go next