Why this matters
An enormous amount of software is written in C: operating system interfaces, compression and crypto libraries, databases, drivers. Rust can call C functions directly and expose Rust functions to C, with no wrapper layer or runtime cost. This is the foreign function interface (FFI). It is how Rust gets into existing systems: a Rust library inside a C program, or a Rust program using a battle-tested C library. Since the compiler cannot check code on the other side of the boundary, FFI is also the most common reason for unsafe.
The ABI: agreeing on how to call
When one function calls another, both sides must agree on where arguments go (which registers, which stack slots), who cleans up, and how values are laid out. That agreement is the ABI (application binary interface). Rust's own ABI is unspecified and may change between compiler versions. extern "C" selects the platform's C ABI, which every language can speak.
Calling C functions
Declare foreign functions in an extern "C" block, with Rust types that match the C signatures:
use std::ffi::{c_int, c_long};
extern "C" {
fn abs(x: c_int) -> c_int;
fn labs(x: c_long) -> c_long;
fn getpid() -> c_int;
}
fn main() {
// SAFETY: abs and labs are defined for all inputs except the minimum value.
let (a, b) = unsafe { (abs(-42), labs(-7_000_000_000)) };
println!("abs(-42) = {a}, labs = {b}");
// SAFETY: getpid has no preconditions.
let pid = unsafe { getpid() };
println!("pid is positive: {}", pid > 0);
}
abs(-42) = 42, labs = 7000000000
pid is positive: true
- The declarations are promises to the compiler about functions that exist somewhere at link time. Here they come from the C standard library (libc), which every Rust program on Linux links anyway. Other libraries are linked with a
#[link(name = "z")]attribute or by cargo build scripts. - Calling a foreign function is
unsafe: the compiler cannot check that the declaration matches the real function, or that the function upholds Rust's rules. - Use the C type aliases from
std::ffi:c_int,c_long,c_char,c_double,c_void. A Cintis not always a Rusti32on every platform, andlongdiffers between Linux and Windows.size_tmaps tousize.
A wrong declaration is not caught by anyone: declaring abs as taking an f64 compiles and links, then passes garbage. Get signatures from the C header, and in real projects generate them with tools like bindgen.
Strings across the boundary
C strings are nul-terminated: a pointer to bytes ending with a zero byte, with no stored length. Rust strings are pointer plus length, not nul-terminated, and may contain zero bytes. So conversion is explicit:
CString(owned) builds a nul-terminated copy of Rust text to pass to C.CString::newfails if the text contains a zero byte.CStr(borrowed) views a nul-terminated string received from C.
use std::ffi::{c_char, CStr, CString};
extern "C" {
fn strlen(s: *const c_char) -> usize;
fn strchr(s: *const c_char, c: i32) -> *const c_char;
}
fn main() {
let owned = CString::new("hello, world").unwrap();
// SAFETY: owned is a valid nul-terminated string that lives until the end of main.
unsafe {
println!("strlen = {}", strlen(owned.as_ptr()));
let p = strchr(owned.as_ptr(), b',' as i32);
if !p.is_null() {
let rest = CStr::from_ptr(p);
println!("from the comma: {:?}", rest.to_str().unwrap());
}
}
println!("{:?}", CString::new("bad\0string"));
}
strlen = 12
from the comma: ", world"
Err(NulError(3, [98, 97, 100, 0, 115, 116, 114, 105, 110, 103]))
Keep the CString alive while C uses its pointer. A classic bug is CString::new(s).unwrap().as_ptr() as one expression: the temporary CString is dropped at the end of the statement, and the pointer dangles (the compiler warns about this specific pattern).
Callbacks: passing Rust functions to C
C APIs often take function pointers. A Rust function declared extern "C" fn has the C ABI and can be passed directly. The standard example is qsort, which the second exercise uses: you write the comparison function in Rust, and C calls it. Rules for callbacks: they must not panic across the boundary (a panic unwinding into C code is undefined behavior; Rust aborts the process instead in an extern "C" function), and they receive raw pointers that must be cast back carefully.
Matching memory layout: #[repr(C)]
Rust may reorder struct fields (module 7). To pass a struct to C, ask for C's layout:
use std::mem::{align_of, size_of};
#[repr(C)]
struct Point {
tag: u8,
x: f64,
y: u8,
}
#[allow(dead_code)]
struct RustPoint {
tag: u8,
x: f64,
y: u8,
}
fn main() {
println!("repr(C): {} bytes, align {}", size_of::<Point>(), align_of::<Point>());
println!("Rust: {} bytes", size_of::<RustPoint>());
let p = Point { tag: 1, x: 2.5, y: 3 };
println!("{} {} {}", p.tag, p.x, p.y);
}
repr(C): 24 bytes, align 8
Rust: 16 bytes
1 2.5 3
#[repr(C)] keeps fields in declaration order with C's padding rules, so the struct matches the C definition struct Point { uint8_t tag; double x; uint8_t y; } exactly. The default Rust layout reorders to save space.
Calling Rust from C
The other direction: mark the function extern "C" (C calling convention) and #[no_mangle] (keep its symbol name exactly as written; Rust normally "mangles" names to encode module paths and generics), and build the crate as a library C can link:
// lib.rs
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }
$ rustc --crate-type staticlib -o libadd.a lib.rs # or cdylib for a .so
$ gcc main.c libadd.a -lpthread -ldl -lm -o main # main.c declares: int add(int, int);
In a cargo project, set crate-type = ["staticlib"] or ["cdylib"] under [lib] in Cargo.toml. The third exercise does this with plain rustc and gcc in the sandbox. Functions exposed to C should validate their inputs (null pointers!) and never panic.
Common mistakes
- Wrong signatures: no tool checks your
externblock against the C header. Copy carefully or usebindgen. - Dangling
CStringpointers from temporaries: bind theCStringto a variable first. - Forgetting the nul terminator: passing
"text".as_ptr()to a C function expecting a C string reads past the end. - Panicking across FFI: catch errors and return error codes instead.
- Freeing memory with the wrong allocator: memory allocated by C must be freed by C (
free), memory from Rust by Rust.
Recap
extern "C" { fn name(...) -> ...; }declares C functions; calling them isunsafe.- Use
std::ffitypes (c_int,c_char,c_void) to match C exactly;size_tisusize. CStringpasses Rust text to C (nul-terminated);CStrreads C strings.extern "C" fngives a Rust function the C ABI for callbacks; never let panics cross the boundary.#[repr(C)]gives structs C's layout;#[no_mangle] pub extern "C" fnplusstaticlib/cdylibexposes Rust to C.
Going further: read the Rust Book section 20.1 on extern functions and the Rustonomicon chapter "FFI". The bindgen and cbindgen tools generate the declarations for each direction automatically.