FFI: extern "C", bindgen, and Zero-Overhead C Interop
- Last updated
- Prerequisites:
- Rust safety and unsafe blocks
- Understanding of C functions and structs
- rust
- ffi
- extern-c
- bindgen
- c-interop
- abi
Read at your depth
The practical view
FFI (foreign function interface) lets Rust call C functions: extern "C" { fn strlen(s: *const c_char) -> usize; } and call them inside unsafe blocks. The extern "C" declares the C ABI (calling convention). Use std::ffi::CString to create null-terminated strings, c_char/c_int from std::os::raw. bindgen (a tool) generates Rust declarations from C headers automatically: bindgen header.h -- -I. The generated bindings are used in a build.rs via the bindgen crate. All FFI calls are unsafe (the C side isn't checked by Rust's guarantees). For structs, use #[repr(C)] to match the C layout.
The same idea in other frameworks
N/A — Rust language feature.N/A — Rust feature.N/A — Rust feature.N/A — Rust feature.Legacy vs modern
Hand-written extern declarations vs bindgen-generated bindings
Hand-declaring FFI is error-prone (wrong types/layouts); bindgen generates correct bindings from the C headers.
extern "C" {
fn compress(input: *const u8, len: usize, out: *mut u8) -> i32; // ❌ OLD: guessed signature// ✅ NEW: generated by bindgen from the C header — correct types/layout
pub struct Header {
pub kind: u8,
pub size: u64, // ✅ NEW: #[repr(C)] layout matches C
}Interview gotchas
Context
This tests the safety boundary — interviewers want the invariants the Rust side must uphold when calling unmanaged C code, and how to make it sound.
The mechanical answer
FFI calls are unsafe because the C function operates outside Rust's guarantees: it may not follow the aliasing rules (mutating data through a shared reference), may not validate pointers (dangling/null), may assume a different lifetime, or may not be thread-safe. The compiler cannot verify any of this. A sound FFI wrapper is a SAFE function that internally does the unsafe call while upholding the invariants: the input pointers must point to valid, aligned, initialized data for the call duration; the output buffer must have sufficient capacity; the data must not be aliased in a conflicting way; and the C function must not retain the pointers beyond the call (unless documented and handled). The wrapper's doc comments should state the safety contract, and tests (including Miri where possible) should exercise the edge cases. If the C code can be given dangling pointers or overlapping buffers, the wrapper must prevent or document that.
Trap
The naive answer is 'unsafe just means the call might crash'. Unsafe in FFI is a contract: if you pass a dangling pointer or wrong lifetime, the C code may corrupt arbitrary memory (not just crash), and the corruption may surface in unrelated Rust code. Another trap: assuming the C function is 'trusted' because it's a well-known library — even well-known C libraries have edge cases (null returns, error paths) that violate your assumptions. Candidates who describe the full safety contract show real understanding.
Context
This tests the ABI model — interviewers want the calling convention and layout rules that make direct, zero-overhead calls possible, and the mismatch risks.
The mechanical answer
The C ABI defines: (1) the calling convention — how arguments are passed (registers/stack per platform: System V AMD64 on Linux/macOS, Microsoft x64 on Windows, AAPCS on ARM) and how the return value is delivered; (2) the data layout — field offsets, alignment, and padding for structs/unions per C's rules. extern "C" tells the compiler to emit calls using the C calling convention (vs the default Rust ABI, which is unspecified and can differ). #[repr(C)] pins a struct's layout to C's rules (fields in order, C alignment/padding) so the bytes in memory match what the C side expects. If either is wrong — the default Rust ABI for a call, or the default Rust struct layout for data — the interop is silently corrupt: wrong argument positions, wrong offsets, misread fields. Getting both right is what makes the call a direct, zero-overhead machine call with no marshalling.
Trap
The naive answer is 'extern C and repr(C) are just for style'. They are ABI-correctness requirements; the default Rust calling convention and struct layout are unspecified and may reorder/pack differently. Another trap: thinking FFI is slow (marshalling) — done right, it's a direct call with no overhead; the costs are the unsafe contract and the layout discipline. Candidates who describe the calling convention and layout rules show real depth.