Rust in Depth

FFI, extern "C" and #[repr(C)]

/ FFI -> "F-F-I", repr -> "REP-er" /

No language lives alone. Vast amounts of working code — the operating system's APIs, graphics and crypto libraries, decades of battle-tested C — exist only as C. To use them, or to let C call into Rust, the two languages must agree on exactly how to pass arguments, return values, and lay out data in memory. That agreement is the ABI (application binary interface), and a foreign function interface (FFI) is the machinery for crossing it. Rust's FFI rests on two pieces: extern "C" to make functions follow the C calling convention, and #[repr(C)] to make data structures match C's memory layout.

Take them in turn. A block written extern "C" { fn strlen(s: *const c_char) -> usize; } declares a function that lives in some C library, telling Rust to call it using the platform's C ABI — the same register and stack conventions a C compiler would use. Because Rust cannot verify anything about that foreign function, every call to it is unsafe: you are crossing out of Rust's checked world into code it knows nothing about, and the soundness contract is now yours to keep. Going the other way, you mark a Rust function pub extern "C" fn (often with #[no_mangle] so its name is not rewritten) so C code can call it. The data side is #[repr(C)]. By default Rust does NOT promise any particular field order or padding for a struct — it may reorder fields to pack them tightly, which is great for Rust but means the layout will not match a C struct. Putting #[repr(C)] on a struct forces it to use C's layout rules (fields in declaration order, C's alignment and padding), so a value can be passed between the two languages and each side sees the same bytes. You also use C-compatible primitive types (the std::os::raw / std::ffi types like c_int, c_char) so the widths agree. Two tools automate the tedious, error-prone job of writing these declarations: bindgen reads a C header and generates the Rust extern declarations for you, and cbindgen reads your Rust and generates a C header so C code can call your Rust.

Why this matters: FFI is how Rust slots into a world built on C — calling system libraries, wrapping a C codec, or exposing a Rust core to a C or Python caller. The honest warning is that the FFI boundary is exactly where Rust's guarantees stop. The C side has none of Rust's checks, a mismatched #[repr] or a wrong type width silently misinterprets bytes, and a foreign function can do anything at all including corrupt memory Rust thought it owned. So the boundary is unsafe by definition, and the discipline is to wrap each foreign call in a small, audited safe Rust API that upholds the soundness contract, rather than letting raw extern calls spread through your codebase. bindgen and cbindgen reduce the transcription mistakes but cannot make the boundary safe — that part is on you.

use std::os::raw::c_char; // Borrow a function from C's standard library: extern "C" { fn strlen(s: *const c_char) -> usize; } // A struct C and Rust both agree on, byte for byte: #[repr(C)] struct Point { x: i32, y: i32 } // C layout, no field reordering let s = b"hello\0"; let n = unsafe { strlen(s.as_ptr() as *const c_char) }; // FFI call: unsafe println!("{n}"); // 5

extern "C" matches the C calling convention; #[repr(C)] matches C's struct layout. Every foreign call is unsafe — the boundary is where Rust's guarantees end.

The FFI boundary is unsafe by definition: the C side has none of Rust's checks, a wrong #[repr] or type width silently misreads bytes, and a foreign function can corrupt anything. bindgen/cbindgen cut transcription errors but cannot make the boundary safe — wrap each call in an audited safe API.

Also called
foreign function interfaceextern Crepr Cbindgencbindgen外部函式介面C 介面