Declaring an extern "C" Function and Getting the Linker to Resolve It
An extern block is a set of promises about other people's code: these symbols exist, they follow the C calling convention, and their signatures are exactly as written. Nothing is type-checked against the real library — the compiler trusts your declaration, and the linker is the first component that can tell you that you lied, usually with undefined reference to 'deflateInit_' at the end of a build that otherwise succeeded.
use std::ffi::{c_char, CStr, CString};
unsafe extern "C" {
fn strlen(s: *const c_char) -> usize;
fn getenv(name: *const c_char) -> *const c_char; // returns memory C owns
}
fn main() {
let key = CString::new("PATH").unwrap(); // Rust bytes -> NUL-terminated
unsafe {
let raw = getenv(key.as_ptr()); // null on a miss — check before use
if !raw.is_null() {
let value = CStr::from_ptr(raw); // borrow C's memory, do not free it
println!("{}", value.to_string_lossy());
let _len = strlen(raw);
}
}
}
Four details in that snippet are the whole discipline. The unsafe on the block (mandatory in edition 2024, accepted everywhere since 1.82) means the caller upholds the C contract: pointer arguments really point at NUL-terminated data, return values may be null, and C functions that set errno or return error codes are your problem to interpret. Each call is wrapped in its own unsafe { } rather than the whole function. Strings cross the boundary as CString on the way out and CStr on the way back, because C has no idea what a Rust String is — its bytes may lack the trailing NUL, and its memory must not be freed by the wrong allocator. And getenv returns a pointer C owns, so you read it and leave it alone.
The classic self-inflicted wound looks like this:
let p = CString::new("hello").unwrap().as_ptr(); // dangles at the semicolon
unsafe { register(p); } // use-after-free, no warning
CString::new(...) produces a temporary; .as_ptr() borrows it; the temporary drops at the end of the statement, so register receives a freed pointer. The fix is to bind the CString to a variable that lives across the call — let c = CString::new("hello")?; unsafe { register(c.as_ptr()) }.
Wiring the symbols up is a build concern, not a source concern. For a system library you either declare it:
#[link(name = "z")]
unsafe extern "C" {
fn zlibVersion() -> *const c_char;
}
or instruct Cargo from build.rs, which is what generated bindings prefer:
// build.rs
fn main() {
println!("cargo:rustc-link-lib=static=z");
println!("cargo:rustc-link-search=native=/opt/zlib/lib");
println!("cargo:rerun-if-changed=vendor/zlib.h");
}
Direction matters when you export instead of import. A Rust function callable from C needs the C ABI and a stable symbol name — #[unsafe(no_mangle)] extern "C" fn free_buffer(p: *mut u8, len: usize) (in editions ≤2021, plain #[no_mangle]). extern "C" here also controls unwinding: a panic reaching that boundary would run C code with Rust unwinding through it, which is undefined behavior, so Rust aborts the process instead. extern "C-unwind" is the deliberate opt-in for callbacks C is allowed to call back into.
One asymmetry to hold onto: the linker validates names, never signatures. Declaring fn strlen(s: *const u8) -> usize, or transposing two i32 parameters, links perfectly and then reads or writes the wrong bytes. Nothing on the Rust side can catch that, which is why declarations should come from the header itself or be pinned by a C-side compile-time assertion. Returning borrowed data through this boundary — a &str handed to C — is precisely the pointer-lifetime contract from Raw Pointers, now enforced by a party who cannot read Rust. The signature you declare is half the contract; the memory layout is the other half, and that is where the default Rust representation silently disqualifies you.
repr(C) Versus repr(Rust): Field Order Is the Contract
You can declare a function correctly and still corrupt memory, because an extern "C" signature says nothing about struct layout. Rust's default representation is deliberately unspecified: the compiler may reorder fields, insert padding, or skip trailing padding entirely, and it may change that decision between releases. C's representation is fixed by the platform ABI: fields in declaration order, padding inserted to satisfy alignment, a stable size. Only #[repr(C)] opts you into the second one.
#[repr(C)]
struct Rect { w: u8, h: u32 } // C layout: w at 0, 3 pad bytes, h at 4, size 8
struct Snek { w: u8, h: u32 } // repr(Rust): may reorder h before w — size and
// offsets are NOT what a C compiler will assume
A C function taking Rect by value reads bytes 4..8 as the height. If you pass Snek and the compiler chose to place h first, C reads your padding as data. The same rule covers every shape that crosses the boundary:
| Rust side | Crosses FFI as | Rule |
|---|---|---|
| struct fields | #[repr(C)] |
declaration order, ABI padding — required, not stylistic |
enum Foo { A, B } |
#[repr(u32)] or #[repr(C)] |
default discriminant layout is unspecified |
| enum with data | #[repr(C)] tagged union |
C sees { tag, payload }, never a Rust enum directly |
newtype struct Meters(f64) |
#[repr(transparent)] |
exactly one field, same layout as it |
bool |
1 byte, values 0/1 only |
a C int of 2 read as bool is UB — convert with x != 0 |
Option<extern "C" fn()> |
pointer-sized | the guaranteed niche: None is the null pointer |
C long |
std::ffi::c_long |
8 bytes on Linux/macOS, 4 bytes on Windows |
Two entries deserve emphasis because they are interview favorites. First, Option<extern "C" fn()> is the niche optimization the language guarantees — None is represented as a null function pointer — so C callbacks can be optional with no wrapper. Do not assume the same for your own Option<YourStruct>: niche elision for user types is an implementation detail, and #[repr(C)] can even remove the freedom that made it possible. Second, never write i64 for a C long: Windows uses the LLP64 model where long is 32 bits, and your i64 argument will be silently misread. c_int, c_long, c_char, c_void from std::ffi exist precisely so the declaration compiles to the right width on every target.
Also check whether a C char is signed on your target — it is signed on x86 Linux and macOS, unsigned on ARM — which is why c_char is its own type rather than a rebranded i8. Packed structs, bitfields, and unions are handled with #[repr(C, packed(n))], #[repr(align(n))], and explicit union declarations; when the C header does something exotic, this is exactly the code you should stop writing by hand.
Layout claims deserve a check that runs without invoking C at all: put the assertion next to the type so a refactor that moves a field fails the build instead of a production request.
#[repr(C)]
struct Rect { w: u8, h: u32 }
const _: () = assert!(std::mem::size_of::<Rect>() == 8);
const _: () = assert!(std::mem::offset_of!(Rect, h) == 4);
Because hand-written layouts are a maintenance trap, the working setup generates them from the header. That is the next chapter — and once the declarations exist, the invariants you must document live in Encapsulating Unsafe with Invariants.
Generating Declarations with bindgen, Then Auditing the Contract It Cannot See
A real C header carries hundreds of typedefs, macros, and calling conventions. Hand-transcribing them is how you end up with i32 where the header said long, and a wrong signature across FFI is undefined behavior that compiles happily. bindgen reads the header with the same parser family Clang uses and emits Rust declarations, so the mechanical half of the job is generated:
// build.rs — bindgen as a build-dependency
fn main() {
let bindings = bindgen::Builder::default()
.header("vendor/zlib.h")
.allowlist_function("inflate.*|deflate.*")
.allowlist_type("z_stream|gz_header")
.allowlist_var("ZLIB_VERSION|Z_.*")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen failed to parse vendor/zlib.h");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out.join("bindings.rs"))
.expect("couldn't write bindings");
}
// src/ffi.rs
#![allow(dead_code, non_upper_case_globals, non_camel_case_types)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
The allowlist is not a nicety: without it bindgen will happily emit every type reachable from the header — platform-specific unions, variadic functions it cannot represent, va_list — and your build breaks on constructs Rust has no equivalent for. Generation also tracks header edits via cargo:rerun-if-changed, so a signature change in C fails your build instead of surviving as a stale declaration.
What bindgen generates is shape, never contract. It will faithfully emit pub fn inflate(strm: z_streamp, flush: c_int) -> c_int; and tell you nothing about who frees strm->next_out, whether the function may return Z_STREAM_END twice, or that the return code must be checked before you touch the output buffer. Three categories of obligation always remain human:
- Ownership and lifetime. Does the callee allocate? Does it retain the pointer after returning? Every retained pointer is a promise that the Rust buffer will not move, reallocate, or drop — the reallocation trap from Raw Pointers applies verbatim.
- Callbacks. A function pointer you hand to C must be
extern "C", cannot capture state, and must not unwind. The standard shape is a trampoline: passextern "C" fn(*mut c_void, u32)plus auser_datapointer back into Rust, then castuser_datato&mut Contextinside — with the aliasing obligation, once again, on you. - Error paths. C signals failure with
NULL, negative codes, orerrno. A wrapper that unwraps aNULLconverts a recoverable error into UB-adjacent panic; a wrapper that ignores a return code converts it into silent corruption.
Two practical notes before you rely on it in CI. bindgen parses with libclang, not with Rust, so the build machine needs Clang installed: without it build.rs dies with Unable to find libclang, and a pinned Docker image must install libclang-dev (or set LIBCLANG_PATH) or the first clean build fails. Second, generated code is not automatically correct code — pin the bindgen version in build-dependencies, review its diff when the header changes, and keep the output in one module so cargo expand or a reviewer can see exactly what was emitted.
The strongest check is the one that lets C contradict you. Compile a tiny assertion file against the same header and run it from the build script:
/* build/layout_check.c — compiled by build.rs via the cc crate */
#include <stddef.h>
_Static_assert(sizeof(struct Rect) == 8, "Rect size drifted from Rust's #[repr(C)]");
_Static_assert(offsetof(struct Rect, h) == 4, "Rect.h moved");
If a C refactor reorders a field, the build fails at the source of truth rather than at runtime. Layout is now pinned on both sides; ownership, lifetimes, and error paths are not — they stay human.
The wrap-up pattern: bindgen declares inside one private module, and you hand the crate a safe, idiomatic API whose methods do the null checks, own the CStrings, and return Result. At that point the unsafe surface is small enough for a reviewer to certify — which is the subject of Encapsulating Unsafe with Invariants.
Quiz
1. A C function is declared with a wrong parameter order in your extern "C" block. What happens at build time?
2. Why must a struct passed to C be #[repr(C)]?
3. C headers use `long`, which is 8 bytes on Linux and 4 bytes on Windows. Which Rust type must you declare it as?
4. You write `register(CString::new("PATH").unwrap().as_ptr());` — what is wrong?
Flashcards
Question
What does an extern "C" block actually guarantee, and what does it not?
Click to reveal answer
Answer
It guarantees the C calling convention for the declared symbols and nothing else about their bodies: no signature checking against the real header (the linker matches names only), no ownership or lifetime information, no error semantics. The unsafe marker means the caller upholds that undocumented C contract.
Question
Which Rust type spellings are required for scalars crossing FFI?
Click to reveal answer
Answer
std::ffi aliases: c_int, c_uint, c_long, c_char, c_void, c_double — never i32/i64 for C integers. c_long is 8 bytes on LP64 Unix and 4 bytes on LLP64 Windows, c_char's signedness follows the target, and getting them wrong compiles while corrupting arguments.
Question
What are the repr(C)-family rules for types crossing FFI?
Click to reveal answer
Answer
Structs: #[repr(C)] (default Rust repr may reorder fields). Fieldless enum: #[repr(u32)] or #[repr(C)]. Enum with data: #[repr(C)] tagged union. Newtype: #[repr(transparent)]. bool: 1 byte holding only 0/1 — converting a C int with != 0 first. Option<extern "C" fn()>: guaranteed pointer-sized, None = null.
Question
What does bindgen generate, and what does it leave to you?
Click to reveal answer
Answer
It generates faithful declarations (functions, types, constants, layout) from the C header, driven by an allowlist and written to OUT_DIR. It never generates the contract: who owns or frees each buffer, whether C retains a pointer after returning, callback aliasing rules, or how return codes and NULL signal failure.
Question
What happens if a Rust panic reaches an extern "C" boundary?
Click to reveal answer
Answer
Unwinding into C would run C frames with Rust's unwinding, which is undefined behavior, so Rust aborts the process at the boundary instead. extern "C-unwind" is the explicit opt-in for functions C may legitimately unwind through (such as callbacks registered with C).
Revision Notes
Key Takeaways
- 1. extern blocks are trusted declarations: the linker resolves names, never signatures, so a wrong declaration links and then corrupts data
- 2. Layout is half the contract — repr(C) (or repr(u32)/repr(transparent)) pins field order and padding to the platform ABI, and const assertions keep it pinned
- 3. Use std::ffi scalar aliases instead of concrete widths; c_long differs between Windows and Unix, and c_char's signedness is target-dependent
- 4. Strings cross as CString/CStr, and the CString must be a named binding that outlives the call — temporaries dangle at the semicolon
- 5. bindgen generates shape, not contract: ownership, callback aliasing, and error handling remain unsafe obligations you document in a safe wrapper
Interview Tips
- • "What does extern \"C\" do?" — selects the C calling convention for both the extern block you declare and the Rust functions you export; add that it changes panic behavior (abort at the boundary) and pairs with #[unsafe(no_mangle)] for exported symbols
- • "How do you pass a struct to C correctly?" — #[repr(C)] plus a size/offset assertion; explain that repr(Rust) may reorder fields so the offsets C computes are simply wrong
- • "Why use the c_long/c_int types instead of i64?" — LP64 versus LLP64: long is 8 bytes on Linux and 4 on Windows, so only the alias survives cross-compilation
- • "How do you maintain FFI declarations?" — generate with bindgen from the header, allowlist the surface, keep output in one module, rerun on header change, and wrap it in a safe API that checks NULL and returns Result
Cheat Sheet
FFI with C Libraries — Cheat Sheet
Declaring and calling:
unsafe extern "C" { // edition 2024 form; plain extern pre-2024
fn zlibVersion() -> *const c_char;
}
let v = unsafe { zlibVersion() }; // every call is its own unsafe block
Exporting Rust to C:
#[unsafe(no_mangle)] // #[no_mangle] in editions ≤2021
extern "C" fn free_buffer(p: *mut u8, len: usize) { /* panic here aborts */ }
Layout rules that are contracts, not style:
| Item | Rule |
|---|---|
| struct crossing FFI | #[repr(C)] — default Rust repr may reorder fields |
| fieldless enum | #[repr(u32)] / #[repr(C)] |
| enum with data | #[repr(C)] tagged union; never a bare Rust enum |
| newtype | #[repr(transparent)] |
bool |
1 byte, 0/1 only — a C int of 2 read as bool is UB |
Option<extern "C" fn()> |
pointer-sized by guarantee; None = null fn pointer |
C long |
std::ffi::c_long (8 bytes Unix, 4 bytes Windows) — never i64 |
Linking: #[link(name = "z")] on the extern block, or build.rs printing cargo:rustc-link-lib= / cargo:rustc-link-search= / cargo:rerun-if-changed=.
Strings: CString::new(x)?.as_ptr() bound to a named variable that outlives the call; CStr::from_ptr to read C-owned memory; never free memory the other side owns.
bindgen: build.rs → Builder::default().header(...).allowlist_function(...) → write to OUT_DIR → include!(concat!(env!("OUT_DIR"), "/bindings.rs")). Needs libclang (Unable to find libclang otherwise). Generates shape, never contract: ownership, callbacks (extern "C" fn + user_data, no unwinding), and error codes remain yours.
The dangle trap: register(CString::new("hi")?.as_ptr()) — temporary drops at the semicolon; bind the CString first.