The C-Rust Boundary
You have a C application that has been running for years. It's stable, it's fast, and rewriting it is not an option. Now you need a feature that Rust handles better: a memory-safe data structure, a complex parser, or a math routine you already wrote in Rust. You don't want to rewrite the C app. You want to call your Rust function from C as if it were just another C function.
C and Rust speak different assembly dialects. C uses a simple calling convention and plain function names. Rust mangles names by default and uses a different stack layout. To bridge the gap, you have to teach Rust to wear a C costume. You mark your functions with specific attributes so the linker can find them and the CPU knows how to pass arguments.
Name Mangling and the Linker
Rust compilers rename functions to encode type information and prevent collisions. A function named add might become _ZN3add3add17h8f9a2b3c4d5e6f7gE in the binary. This is name mangling. It helps Rust link correctly but breaks C. C expects the symbol to be exactly add.
You stop mangling with #[no_mangle]. This attribute tells the compiler to keep the symbol name exactly as written. Without it, the C linker will fail with a "undefined reference" error because it can't find the symbol C is looking for.
You also need to align the calling convention. The calling convention defines how arguments are passed, where return values go, and who cleans up the stack. C uses the "C" convention. Rust uses a different one by default. You align this with extern "C". This changes the function signature to match C's expectations.
Combine them, and you get a function C can call.
// lib.rs
/// Adds two integers and returns the result.
/// Exposed to C with no name mangling and C calling convention.
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
The pub makes the function visible outside the crate. The extern "C" sets the ABI. The #[no_mangle] preserves the name. All three are required for a clean bridge.
Convention aside: The community often wraps FFI functions in a module named ffi or c_api. This keeps the boundary explicit and separates safe Rust logic from the unsafe interface. It's not required by the compiler, but it saves future maintainers from hunting down which functions cross the boundary.
Building the Library
C can't link against a standard Rust library file. You need to compile Rust into a format C understands. The two common targets are shared libraries and static libraries.
Shared libraries produce .so on Linux, .dylib on macOS, or .dll on Windows. The C program loads the library at runtime. Static libraries produce .a archives. The C program links the archive at build time, and the Rust code becomes part of the final binary.
Configure Cargo.toml to produce the right artifact.
# Cargo.toml
[lib]
# cdylib produces a shared library (e.g., libmyrust.so).
# Use staticlib if you want a .a archive for static linking.
crate-type = ["cdylib"]
Run cargo build. Cargo outputs the library in target/debug/ or target/release/. The filename follows the platform convention, usually prefixed with lib and suffixed with the extension.
Convention aside: Use cdylib for shared libraries, not dylib. The dylib crate type produces a Rust dynamic library that other Rust crates can depend on. It includes Rust metadata and expects the Rust runtime. C cannot link against dylib. cdylib strips the metadata and produces a pure C-compatible shared object.
The Minimal Bridge in Action
Here is the complete flow. You write the Rust function, compile it, write a C header, and call it from C.
The Rust side:
// lib.rs
#[no_mangle]
pub extern "C" fn multiply(a: i32, b: i32) -> i32 {
a * b
}
The C header:
// myrust.h
#ifndef MYRUST_H
#define MYRUST_H
// Declare the function with C linkage.
// The signature must match the Rust side exactly.
int multiply(int a, int b);
#endif
The C program:
// main.c
#include <stdio.h>
#include "myrust.h"
int main() {
int result = multiply(6, 7);
printf("Result: %d\n", result);
return 0;
}
Compile and link:
# Build the Rust library
cargo build --release
# Compile C and link against the Rust library
# -L points to the directory with the library
# -l specifies the library name without lib prefix and extension
gcc main.c -L target/release -lmyrust -o main
Run the binary. If the library path isn't in the system search path, you may need to set LD_LIBRARY_PATH on Linux or DYLD_LIBRARY_PATH on macOS. The output shows Result: 42. The call crossed the boundary, executed Rust code, and returned to C seamlessly.
Trust the linker. If the symbol names and signatures match, the connection works. If they don't, you get a link error or a crash. There is no middle ground.
Structs and Memory Layout
Passing primitives is straightforward. Structs introduce layout risks. Rust packs struct fields to optimize access speed, which can insert padding bytes between fields. C has its own packing rules. If Rust and C disagree on where a field lives in memory, C reads garbage.
You force Rust to use C's layout with #[repr(C)]. This attribute tells Rust to lay out the struct exactly as C would. Fields appear in declaration order, and padding follows C rules.
// lib.rs
/// A 2D point with C-compatible memory layout.
#[repr(C)]
pub struct Point {
x: f64,
y: f64,
}
/// Calculates the distance between two points.
/// Takes raw pointers because C cannot pass Rust references.
#[no_mangle]
pub extern "C" fn distance(p1: *const Point, p2: *const Point) -> f64 {
// SAFETY: The caller must provide valid, non-null pointers to Point structs.
// The pointers must remain valid for the duration of this call.
unsafe {
// Dereference the raw pointers to get references.
// This is safe only if the pointers are valid.
let p1_ref = &*p1;
let p2_ref = &*p2;
let dx = p1_ref.x - p2_ref.x;
let dy = p1_ref.y - p2_ref.y;
(dx * dx + dy * dy).sqrt()
}
}
The unsafe block is necessary because you are dereferencing raw pointers. Rust cannot verify that the pointers are valid at compile time. The // SAFETY: comment documents the invariants. The caller must ensure the pointers point to valid memory.
Convention aside: Keep unsafe blocks small. Isolate the dereference and the calculation. The smaller the unsafe surface, the easier it is to audit. If the unsafe block grows, consider wrapping the logic in a safe helper function that takes references, then call that helper from the unsafe block.
Strings and the Null Terminator
C strings are arrays of char terminated by a null byte \0. Rust strings are UTF-8 sequences with a length. They are not null-terminated. You cannot pass a &str to C. You cannot pass a C string to Rust and treat it as a &str directly.
Use std::ffi::CStr to read C strings in Rust. Use std::ffi::CString to send Rust strings to C.
// lib.rs
use std::ffi::CStr;
use std::os::raw::c_char;
/// Greets a name provided as a C string.
/// Returns 0 on success, -1 on error.
#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> i32 {
// SAFETY: The caller must provide a valid pointer to a null-terminated C string.
unsafe {
// Convert the raw pointer to a CStr.
// This scans for the null terminator.
let c_str = match CStr::from_ptr(name).to_str() {
Ok(s) => s,
Err(_) => return -1, // Invalid UTF-8
};
println!("Hello, {}", c_str);
}
0
}
The c_char type is an alias for the C char type. On most platforms, it's i8. Using c_char instead of i8 signals intent and improves portability.
If you need to return a string to C, you must allocate memory that C can free. This is tricky. Rust allocators and C allocators are different. If Rust allocates and C frees, you get undefined behavior. The standard pattern is to have C allocate the buffer, pass a pointer and size to Rust, and let Rust fill it. Or use a callback where Rust calls a C function to handle the string.
Don't return *const c_char from Rust unless you document exactly who owns the memory. Memory leaks and double frees are the norm when ownership crosses the boundary without a clear contract.
Panics are Undefined Behavior
Rust panics unwind the stack. C does not expect unwinding. If a Rust function panics while called from C, the panic propagates across the FFI boundary. The C runtime sees a stack walk it doesn't understand. The program aborts immediately. This is undefined behavior.
You must catch panics before they escape. Use std::panic::catch_unwind. This function runs a closure and captures any panic, returning a Result. You can then convert the panic into an error code that C can handle.
// lib.rs
use std::panic::catch_unwind;
use std::panic::AssertUnwindSafe;
/// Safely calls a Rust function that might panic.
/// Returns 0 on success, -1 on panic.
#[no_mangle]
pub extern "C" fn safe_divide(a: i32, b: i32) -> i32 {
// Wrap the logic in a closure.
// AssertUnwindSafe is needed because catch_unwind requires UnwindSafe.
let result = catch_unwind(AssertUnwindSafe(|| {
if b == 0 {
panic!("Division by zero");
}
a / b
}));
match result {
Ok(value) => value,
Err(_) => -1, // Signal error to C
}
}
The catch_unwind call prevents the panic from crossing the boundary. The C code receives -1 and can handle the error gracefully. Without this wrapper, the C program crashes with no chance to recover.
Convention aside: Many projects define a macro or helper function for FFI wrappers to avoid repeating the catch_unwind boilerplate. The pattern is consistent: wrap, catch, return error code. Automate it.
Pitfalls and Compiler Errors
FFI introduces pitfalls that the compiler can't always catch.
If you pass a pointer to a Rust function and dereference it without unsafe, the compiler rejects the code with E0133 (dereference of raw pointer requires unsafe). You must explicitly mark the dereference as unsafe.
If you pass the wrong type, the compiler rejects the code with E0308 (mismatched types). C's int is usually i32, but not always. Use libc::c_int for portability. If you mix i32 and u32, the compiler flags the mismatch.
If you forget #[no_mangle], the linker fails with "undefined reference". The symbol name in the binary doesn't match what C expects. Check the symbol table with nm or objdump to verify the name.
If you forget #[repr(C)] on a struct, the layout may differ. The compiler won't warn you. You'll get subtle bugs where fields read wrong values. Always use #[repr(C)] for structs crossing the boundary.
If you return a Rust reference or pointer to a local variable, the pointer becomes dangling when the function returns. C dereferences invalid memory. The compiler might warn, but not always. Ensure lifetimes are valid across the boundary.
Treat the FFI boundary as a firewall. Every pointer, every type, every layout decision must be verified. The compiler helps, but the burden is on you.
Decision Matrix
Use extern "C" when you expose a function to C code. It aligns the calling convention so arguments and return values pass correctly.
Use #[no_mangle] when C needs to find the symbol by name. Without it, the linker cannot resolve the reference.
Use #[repr(C)] when passing structs across the boundary. Rust packs fields differently by default. This attribute forces C-compatible layout.
Use std::ffi::CStr when reading C strings in Rust. It handles null termination and UTF-8 validation safely.
Use std::panic::catch_unwind when your Rust code might panic. Panics crossing FFI are undefined behavior. Catch them and return error codes.
Use cdylib crate type when building a shared library for dynamic loading. It produces a platform-appropriate shared object.
Use staticlib crate type when linking a static archive into a C binary. It produces an .a file.
Use bindgen when you have a large C header file and don't want to write Rust bindings by hand. It generates Rust FFI code automatically.
Use cbindgen when you want to generate C headers from Rust code. It keeps the C interface in sync with your Rust definitions.
Keep the unsafe block small. The boundary is the danger zone. Isolate the risk.