How to Use cbindgen to Generate C Headers from Rust

Use `cbindgen` to automatically generate C-compatible header files from your Rust code by configuring a `cbindgen.toml` file and running the tool with your crate path.

The header mismatch nightmare

You write a high-performance image filter in Rust. Your C++ team wants to integrate it. You spend an afternoon hand-writing the header file. You define the structs. You list the function prototypes. You get the padding right. You ship it.

Two weeks later, you add a field to a Rust struct to support a new feature. You update the Rust code. You forget to update the header. The C++ code compiles without warnings. At runtime, the struct size differs between Rust and C. The C++ code writes past the end of the buffer. Memory gets trashed. The crash happens three layers deep in a callback, and the stack trace points to nowhere useful.

This is the classic FFI trap. The header file drifts from the implementation. Manual maintenance introduces errors. cbindgen eliminates the drift by generating the header directly from your Rust source code. The Rust code becomes the single source of truth. The header is just a reflection.

Treat the header as a derivative artifact. If you have to edit it by hand, you're fighting the tool.

What cbindgen actually does

cbindgen is a code generator that reads your Rust crate and produces a C header file. It parses the abstract syntax tree of your code. It looks for items marked for export. It respects Rust's memory layout attributes. It writes C declarations that match the Rust ABI.

Think of it like a compiler for C headers. You provide the Rust source. The tool emits the contract C can read. If you change the Rust code, the contract updates automatically. No more manual sync. No more mismatched structs. No more forgotten extern "C" blocks.

The tool relies on three Rust mechanisms to know what to export and how to lay it out:

  • extern "C" sets the calling convention and name mangling rules.
  • #[no_mangle] prevents the linker from renaming the symbol.
  • #[repr(C)] forces the struct layout to match C rules.

cbindgen scans for these markers. It ignores everything else. If a function isn't marked pub extern "C" with #[no_mangle], it won't appear in the header. If a struct isn't #[repr(C)], cbindgen warns you that the layout is undefined.

The header is a mirror. If the Rust code is messy, the header is messy. Clean up the Rust first.

Minimal setup

Start with a Rust library crate. Add cbindgen to your development workflow. The community convention is to install the binary globally via cargo install cbindgen or use it as a subcommand. This keeps your Cargo.toml clean and lets you run the tool from any directory.

Create a cbindgen.toml file in your project root. This configuration file controls the output format and export rules.

# cbindgen.toml

# Target language for the generated header.
language = "C"

# Add extern "C" blocks for C++ compatibility.
cpp_compat = true

# Export all public items by default.
export = { include = ["*"] }

# Expand macros during parsing to catch generated code.
parse = { expand = true }

Define your FFI interface in lib.rs. Every function must be pub extern "C" and marked with #[no_mangle]. Every struct must be #[repr(C)].

// src/lib.rs

/// Add two integers and return the sum.
#[no_mangle] // Keep the symbol name exactly as written for C linkage.
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

/// A point in 2D space with C-compatible memory layout.
#[repr(C)] // Force layout to match C struct rules for padding and alignment.
#[derive(Debug, Clone, Copy)]
pub struct Point {
    x: f64,
    y: f64,
}

/// Calculate the Euclidean distance between two points.
#[no_mangle]
pub extern "C" fn point_distance(p1: &Point, p2: &Point) -> f64 {
    let dx = p1.x - p2.x;
    let dy = p1.y - p2.y;
    (dx * dx + dy * dy).sqrt()
}

Run the generator from the project root.

cargo cbindgen --output bindings.h

The tool produces bindings.h. The file contains the function prototypes and the struct definition. It includes #pragma once and #ifdef __cplusplus guards automatically. The cpp_compat setting wraps declarations in extern "C" { ... } so C++ compilers don't mangle the names.

Inspect the output. The header should match your expectations. If a function is missing, check the #[no_mangle] attribute. If the struct looks wrong, check #[repr(C)].

Run the tool. Check the output. Commit the header. Don't edit the header by hand.

How the pieces fit together

When you run cbindgen, the tool loads your crate. It parses the source files. It expands macros if parse.expand = true is set. This is important because many projects use procedural macros to generate FFI bindings. Without expansion, cbindgen sees the macro call but not the generated code.

The tool builds a model of your public API. It filters items based on the export configuration. By default, include = ["*"] exports everything. You can exclude internal helpers with exclude = ["internal_*"].

For each exported function, cbindgen checks the signature. It converts Rust types to C types. i32 becomes int32_t. f64 becomes double. &T becomes const T*. &mut T becomes T*. If you use a type that has no C equivalent, like String or Vec<T>, the tool warns you. C doesn't understand Rust's smart pointers. You need to use raw pointers or opaque handles for those.

For structs, cbindgen respects #[repr(C)]. It calculates field offsets based on C alignment rules. It adds padding where necessary. If you use #[repr(C, packed)], it emits #pragma pack directives. The generated header matches the binary layout of the Rust struct.

The tool also handles enums. Rust enums are i32 by default. cbindgen generates C enums with the correct discriminants. If you use #[repr(u8)] on an enum, the header uses uint8_t constants.

Convention aside: The community standard is to commit the generated header to version control. C consumers should not need the Rust toolchain to build your library. The header is part of the distribution. If the header changes, bump the version.

The tool reads your code. It doesn't guess. If the header is missing a function, the function isn't marked for export.

Realistic workflow: build.rs and CI

In production projects, you rarely run cbindgen manually. You automate it. The standard pattern is to use a build.rs script that regenerates the header whenever the Rust source changes.

Add cbindgen as a build dependency. Note the distinction: the binary is cbindgen, the crate is also cbindgen. You can use the crate in build.rs to generate bindings programmatically.

# Cargo.toml

[build-dependencies]
cbindgen = "0.27"

Create build.rs in the project root.

// build.rs

fn main() {
    // Generate bindings only if source or config changes.
    // cbindgen handles incremental builds automatically.
    cbindgen::Builder::new()
        .with_crate(".")
        .with_config(cbindgen::Config::from_file("cbindgen.toml").unwrap())
        .generate()
        .expect("Unable to generate bindings")
        .write_to_file("bindings.h");
}

Now cargo build regenerates bindings.h automatically. The header stays in sync with the code. You can add a CI step to verify that the committed header matches the generated one. This catches accidental manual edits.

# CI script snippet
cargo cbindgen --output bindings_check.h
diff bindings.h bindings_check.h

If the diff is empty, the header is up to date. If not, someone edited the header by hand or the Rust code changed without regenerating. Fail the build.

Automate regeneration in build.rs. Commit the result. Keep the C team happy.

Pitfalls and warnings

cbindgen catches many errors, but it can't fix bad design. Watch out for these common traps.

If you expose a struct without #[repr(C)], cbindgen emits a warning about undefined layout. The generated header might still compile, but the memory layout could differ between Rust and C. This leads to silent data corruption. Always use #[repr(C)] for FFI structs.

If you use Rust-only types in function signatures, the tool warns you. String, Vec<T>, Option<T>, and Result<T> have no C representation. You need to convert them to C-compatible types. Use *const c_char for strings. Use raw pointers and length pairs for vectors. Use error codes or output parameters for results.

Generic types are another issue. C doesn't have generics. cbindgen cannot generate a header for fn process<T>(data: &T). You must monomorphize the function or use a trait object with a vtable. The opaque pointer pattern solves this.

If you forget #[no_mangle], the header contains the function prototype, but the library doesn't export the symbol. The C linker fails with an undefined reference error. The symbol name in the library is mangled by Rust's linker. C can't find it. Always pair extern "C" with #[no_mangle].

Convention aside: Use cbindgen's export.body configuration to add custom documentation or comments to the header. This is useful for adding usage examples or warnings for C consumers.

If cbindgen warns about layout, fix the Rust. C doesn't care about your Rust optimizations.

Advanced patterns: Opaque handles and config

When your Rust library has complex state, don't expose the internals. Expose opaque handles. This keeps the ABI stable and hides implementation details.

Define a struct in Rust that holds the complex state. Mark it #[repr(C)] but don't expose the fields. In the header, cbindgen generates an incomplete struct declaration. C can use pointers to the struct but can't access the fields.

// src/lib.rs

/// Opaque handle to a Rust processor.
/// C code can only hold a pointer to this.
#[repr(C)]
pub struct Processor {
    // Internal fields are hidden from C.
    // cbindgen generates an incomplete struct in the header.
    _private: [u8; 0],
}

/// Create a new processor.
#[no_mangle]
pub extern "C" fn processor_new() -> *mut Processor {
    // Allocate and initialize the processor.
    // Return a raw pointer.
    let proc = Box::new(Processor { _private: [] });
    Box::into_raw(proc)
}

/// Drop the processor.
#[no_mangle]
pub extern "C" fn processor_free(proc: *mut Processor) {
    // Safety: The caller must pass a valid pointer returned by processor_new.
    // The pointer must not be used after this call.
    if !proc.is_null() {
        unsafe {
            let _ = Box::from_raw(proc);
        }
    }
}

The generated header contains struct Processor; without fields. C code can declare Processor* handle. It can pass the handle to functions. It cannot access the internals. This pattern is essential for maintaining ABI stability. You can change the Rust struct layout without breaking C code.

Configure cbindgen to match your project's style. The line_length, tab_width, and bracing options control formatting. Set them to match your C codebase. This reduces friction when C developers review the header.

# cbindgen.toml

# Match the C project's formatting style.
line_length = 100
tab_width = 4
bracing = "SameLine"

Convention aside: Use sys_includes to add standard C headers like <stdint.h> or <stdbool.h>. This ensures the generated header compiles standalone without extra includes.

Expose handles, not internals. Keep the ABI stable. Version the library, not the header.

Decision matrix

Use cbindgen when you are writing a Rust library and need to expose a C API. Use cbindgen when your FFI surface changes frequently and you want to avoid manual header maintenance. Use cbindgen when you need to generate headers for both C and C++ consumers with cpp_compat.

Use manual headers when your interface is tiny and stable, and you prefer total control over every pragma and include. Use manual headers when you are prototyping and don't want to add a tool dependency.

Use bindgen when you are consuming a C library from Rust and need to generate Rust bindings from existing C headers. bindgen and cbindgen solve opposite problems. bindgen goes C to Rust. cbindgen goes Rust to C.

Pick the tool that matches the direction of data flow. Rust to C needs cbindgen. C to Rust needs bindgen.

Where to go next