Bridging Rust and C Generating C Bindings and Headers with Cbindgen and Cargo-c
Min-jun Kim
Dev Intern · Leapcell

Introduction
Rust, with its focus on performance, memory safety, and concurrency, has rapidly gained traction in various domains, from systems programming to web assembly. However, the world still runs on C, and interoperability with existing C codebases is often a critical requirement for integrating Rust libraries into larger projects. Directly consuming Rust's Application Binary Interface (ABI) is unstable and complex due to its constant evolution. This is where the need for stable C Application Programming Interfaces (APIs) emerges. By exposing Rust functionality through C-compatible bindings, we can leverage Rust's strengths while maintaining compatibility with a vast ecosystem of C and C-compatible languages. This article will guide you through the process of generating C header files and bindings for your Rust libraries using two powerful tools: cbindgen
and cargo-c
, enabling seamless integration and unlocking new possibilities for your Rust creations.
Core Concepts for Cross-Language Interoperability
Before diving into the practical aspects, let's establish a foundational understanding of the key concepts involved in bridging Rust and C.
- Foreign Function Interface (FFI): FFI is a mechanism that allows a program written in one programming language to call functions or use services written in another programming language. In our context, Rust's FFI enables it to interact with C code and vice-versa.
- C ABI (Application Binary Interface): The C ABI defines how functions are called, how data is laid out in memory, and how parameters and return values are passed between functions written in C. When exposing Rust functions to C, we must ensure they adhere to the C ABI conventions to prevent undefined behavior and crashes.
no_mangle
attribute: In Rust, function names are "mangled" by the compiler to support features like overloading and unique symbol identification. The#[no_mangle]
attribute placed before apub extern "C"
function prevents this name mangling, ensuring that the function's name in the compiled output matches its source code name, making it discoverable by C compilers.extern "C"
calling convention: This specifies that a function should use the C calling convention, which dictates how arguments are pushed onto the stack, how the return value is handled, and how the stack is cleaned up after the call. Adhering to this is crucial for correct FFI interactions.cbindgen
: A tool that automatically generates C/C++ header files from Rust code annotated withpub extern "C"
functions and C-compatible data structures. It simplifies the manual process of writing header files, which can be error-prone and tedious.cargo-c
: A Cargo subcommand that helps create C-compatible libraries from Rust projects. It automates the build process, generating the necessary C headers, static libraries, and dynamic libraries, and can even producepkg-config
files for easier integration into C build systems.
Generating C Headers and Bindings
Let's walk through an example to illustrate how cbindgen
and cargo-c
work in practice. We'll create a simple Rust library that calculates the Fibonacci sequence and exposes it to C.
Step 1: Initialize a Rust Library
First, create a new Rust library project:
cargo new --lib fib_lib cd fib_lib
Step 2: Implement Rust Functionality
Edit src/lib.rs
to include our Fibonacci function and expose it using extern "C"
and no_mangle
. We'll also define a simple struct.
// src/lib.rs #[derive(Debug)] #[repr(C)] // Ensure C-compatible memory layout pub struct MyData { pub value: i32, pub name: *const std::os::raw::c_char, // C-compatible string pointer } /// Calculates the nth Fibonacci number. /// # Safety /// This function is safe to call for non-negative n. #[no_mangle] pub extern "C" fn fibonacci(n: i32) -> i32 { if n <= 1 { n } else { fibonacci(n - 1) + fibonacci(n - 2) } } /// Prints a message using the provided data. /// # Safety /// The `data` pointer must be valid and point to a `MyData` struct. /// The `name` field within `MyData` must be a valid C-style string pointer or null. #[no_mangle] pub extern "C" fn greet_with_data(data: *const MyData) { if data.is_null() { println!("Received null data."); return; } unsafe { let actual_data = *data; let c_str = std::ffi::CStr::from_ptr(actual_data.name); let name_str = c_str.to_string_lossy(); println!("Hello, {}! Your value is {}", name_str, actual_data.value); } }
Key Considerations for FFI:
#[repr(C)]
: This attribute is crucial for structs. It tells the Rust compiler to lay out the struct's fields in memory exactly as a C compiler would, preventing unexpected padding or reordering that could lead to ABI incompatibilities.*const std::os::raw::c_char
: Rust'sString
andstr
types are not C-compatible. For passing strings across the FFI boundary, we use C-style null-terminated strings represented by*const std::os::raw::c_char
(for immutable strings) or*mut std::os::raw::c_char
(for mutable strings). Remember that Rust ownership rules don't apply to raw pointers, so careful memory management is required on both sides. In this example, we assume the C side owns and manages the memory for thename
field.unsafe
blocks: When working with raw pointers and C FFI, you'll often encounterunsafe
blocks. These blocks signify that the code within them performs operations that the Rust compiler cannot guarantee memory safety for, such as dereferencing raw pointers or calling C functions. It's the developer's responsibility to ensure the safety of these operations.
Step 3: Integrate cbindgen
Add cbindgen
as a build dependency in Cargo.toml
:
# Cargo.toml [package] name = "fib_lib" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib", "staticlib"] # Generate dynamic and static C libraries [build-dependencies] cbindgen = "0.24" # Use the latest stable version
Create a build script build.rs
to run cbindgen
:
// build.rs extern crate cbindgen; use std::env; fn main() { let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); cbindgen::Builder::new() .with_crate(crate_dir) .with_language(cbindgen::Language::C) .generate() .expect("Unable to generate bindings") .write_to_file("target/fib_lib.h"); // Output to target directory }
Now, when you run cargo build
, cbindgen
will automatically generate target/fib_lib.h
.
Step 4: Integrate cargo-c
cargo-c
simplifies the packaging of your Rust library as a C-compatible library. Install it first:
cargo install cargo-c
Now, simply run cargo cbuild
to build your library for C. This command will:
- Compile your Rust code into a static library (
.a
) and a dynamic library (.so
on Linux,.dylib
on macOS,.dll
on Windows). - Run your
build.rs
script, which generatesfib_lib.h
. - Place these output artifacts into a structured directory (e.g.,
target/release/capi/
).
Let's modify build.rs
slightly to output the header directly into the target
directory during a regular build, and cargo-c
will then move it into its standard C-compatible output structure.
After running cargo cbuild --release
, you'll find the generated files in target/release/capi
:
target/release/capi/
├── include/
│ └── fib_lib.h
├── lib/
│ ├── libfib_lib.a
│ └── libfib_lib.so (or .dylib/.dll)
└── pkgconfig/
└── fib_lib.pc
The fib_lib.h
file will look something like this (simplified):
// fib_lib.h (generated by cbindgen) #include <stdarg.h> #include <stdbool.h> #include <stdint.h> #include <stdlib.h> typedef struct MyData { int32_t value; const char *name; } MyData; int32_t fibonacci(int32_t n); void greet_with_data(const struct MyData *data);
Step 5: Consuming the Library in C
Create a C file (e.g., main.c
) to use our Rust library:
// main.c #include <stdio.h> #include <stdlib.h> // For malloc, free #include <string.h> // For strdup #include "fib_lib.h" // Include the generated header int main() { // Test fibonacci function int n = 10; int result = fibonacci(n); printf("Fibonacci(%d) = %d\n", n, result); // Test greet_with_data function MyData my_c_data; my_c_data.value = 42; my_c_data.name = strdup("World from C"); // Allocate C-style string greet_with_data(&my_c_data); free((void*)my_c_data.name); // Free the allocated C-style string return 0; }
Compile and link the C program with the generated Rust library:
# Assuming you are in the directory containing main.c and the fib_lib.h, .a/.so files # (adjust paths as necessary to point to target/release/capi) # For dynamic linking (Linux/macOS) gcc main.c -o my_c_app -Itarget/release/capi/include -Ltarget/release/capi/lib -lfib_lib # For static linking (Linux/macOS) # gcc main.c -o my_c_app -Itarget/release/capi/include target/release/capi/lib/libfib_lib.a # Run the C application ./my_c_app
You should see output similar to:
Fibonacci(10) = 55
Hello, World from C! Your value is 42
This demonstrates successful interoperability. The C program is calling Rust functions and using Rust-defined data structures seamlessly.
Application Scenarios
- Integrating Rust into existing C/C++ codebases: Leverage Rust's memory safety and performance for critical components without rewriting the entire application.
- Creating embeddable modules: Build Rust-powered plugins or extensions that can be loaded and used by applications written in C or other languages with C FFI support.
- Developing low-level OS components: Use Rust for drivers, kernel modules, or bootloaders that need to interact with C interfaces.
- Cross-language development: Facilitate collaboration between teams working in Rust and C without requiring them to learn both languages thoroughly.
Conclusion
By mastering cbindgen
and cargo-c
, you can effortlessly generate C-compatible headers and libraries from your Rust projects. This powerful combination unlocks a world of possibilities, enabling your robust and safe Rust code to integrate seamlessly into the vast C ecosystem. This bridging capability provides a practical and efficient pathway for Rust to extend its reach and impact across diverse software landscapes.