Calling C From Rust With Extern and Bindgen Step by Step
Rust calls C through extern declarations that you write or generate. bindgen reads a header and produces them, leaving you to wrap the unsafe calls in a safe API.

Rust does not need to rewrite the world. Operating systems, databases, codecs, GPU drivers and decades of numerical code are written in C, and a Rust program can call all of it directly, with no runtime and no overhead beyond the call itself. The mechanism is the foreign function interface, and the cost is that every call crosses a boundary the compiler cannot see through. This guide shows how to cross it deliberately: first by hand, so you understand what is being promised, then with bindgen, which writes the declarations for you.
The running example is a small C library, a key-value store with an opaque handle, string arguments, a returned pointer you must not free, and a callback. It is deliberately the same shape as the C libraries you will meet in practice. It was built with cc 1.11.1 and bindgen 0.73.2 on Rust 1.99, edition 2024, on an Apple silicon Mac, and the outputs below are what it printed.
The C library
#ifndef KV_H
#define KV_H
#include <stddef.h>
typedef struct kv kv_t;
/* Create an empty store. Returns NULL if memory runs out. */
kv_t *kv_open(const char *name);
/* Copy key and value into the store. Returns 0 on success, -1 on bad arguments or no memory. */
int kv_set(kv_t *store, const char *key, const char *value);
/* Returns a pointer owned by the store, valid until the next kv_set of that key or kv_close.
Returns NULL if the key is absent. */
const char *kv_get(const kv_t *store, const char *key);
size_t kv_len(const kv_t *store);
typedef void (*kv_visit_fn)(const char *key, const char *value, void *user_data);
/* Call visit once per entry, in insertion order. */
void kv_foreach(const kv_t *store, kv_visit_fn visit, void *user_data);
/* Free the store and everything in it. Passing NULL is allowed. */
void kv_close(kv_t *store);
#endifRead the header the way you will read every C header you bind: for ownership. kv_open returns a handle you must eventually pass to kv_close. kv_set copies its arguments, so the caller keeps ownership of what it passed. kv_get returns a pointer the store owns, valid only until the next kv_set of the same key or until kv_close. kv_foreach takes a function pointer and an opaque void *user_data that it passes back untouched. None of those rules is visible in a C type signature. They live in comments, and your Rust has to honor them.
The implementation, csrc/kv.c, is a hundred lines of linked-list code. It is not reproduced here, because the point of FFI is that you do not need to read it. The build compiles it with the cc crate in a build script:
fn main() {
cc::Build::new().file("csrc/kv.c").include("csrc").warnings(true).compile("kv");
println!("cargo:rerun-if-changed=csrc/kv.c");
println!("cargo:rerun-if-changed=csrc/kv.h");
}[build-dependencies]
cc = "1"cc finds the platform's C compiler, compiles the file, archives it into a static library named libkv.a, and tells Cargo to link it into your crate. That is all the build setup a vendored C source file needs. For a library already installed on the system, you would instead emit cargo:rustc-link-lib=kv and a search path, usually via the pkg-config crate.
Declaring the C API by hand
Before using a generator, write the declarations yourself once. It takes five minutes and teaches you what the generator is promising for you.
use std::ffi::{c_char, c_int, c_void, CStr, CString};
// A hand-written declaration of the C API. Each line is a promise that the C side matches.
#[repr(C)]
pub struct kv_t {
_private: [u8; 0], // an opaque type: Rust never looks inside
}
unsafe extern "C" {
fn kv_open(name: *const c_char) -> *mut kv_t;
fn kv_set(store: *mut kv_t, key: *const c_char, value: *const c_char) -> c_int;
fn kv_get(store: *const kv_t, key: *const c_char) -> *const c_char;
fn kv_len(store: *const kv_t) -> usize;
fn kv_foreach(store: *const kv_t, visit: extern "C" fn(*const c_char, *const c_char, *mut c_void), user: *mut c_void);
fn kv_close(store: *mut kv_t);
}
extern "C" fn print_entry(key: *const c_char, value: *const c_char, _user: *mut c_void) {
let (k, v) = unsafe { (CStr::from_ptr(key), CStr::from_ptr(value)) };
println!(" {} = {}", k.to_string_lossy(), v.to_string_lossy());
}
fn main() {
let name = CString::new("settings").unwrap();
let store = unsafe { kv_open(name.as_ptr()) };
assert!(!store.is_null());
for (k, v) in [("host", "example.test"), ("port", "8080"), ("host", "db.example.test")] {
let (k, v) = (CString::new(k).unwrap(), CString::new(v).unwrap());
let rc = unsafe { kv_set(store, k.as_ptr(), v.as_ptr()) };
assert_eq!(rc, 0);
}
println!("{} entries", unsafe { kv_len(store) });
let key = CString::new("host").unwrap();
let value = unsafe { kv_get(store, key.as_ptr()) };
println!("host -> {}", unsafe { CStr::from_ptr(value) }.to_str().unwrap());
let missing = CString::new("nope").unwrap();
println!("missing is null: {}", unsafe { kv_get(store, missing.as_ptr()) }.is_null());
println!("all entries:");
unsafe { kv_foreach(store, print_entry, std::ptr::null_mut()) };
unsafe { kv_close(store) };
}2 entries
host -> db.example.test
missing is null: true
all entries:
host = db.example.test
port = 8080The store ends with two entries even though three kv_set calls ran, because the third set host a second time and the C code replaced the value, so host now maps to db.example.test. The insertion order is preserved, host before port, as the header promised. Every piece of the file is a decision worth understanding.
The extern block and the types
unsafe extern "C" { ... } declares functions that exist elsewhere and follow the C calling convention. In edition 2024 the unsafe before extern is mandatory, and it means something: you assert that every declaration in the block is accurate. The compiler cannot check it. If you declare kv_len as returning u32 while C returns size_t, the program compiles and reads half a register. Omit the keyword and the compiler refuses:
error: extern blocks must be unsafe
--> src/main.rs:9:1
|
9 | extern "C" {
| ^
| |
| _help: needs `unsafe` before the extern keyword: `unsafe`Types map to the std::ffi aliases rather than to Rust's fixed-width types, because C's int and char vary by platform. This table is the one to keep:
| C type | Rust type |
|---|---|
int, unsigned int | c_int, c_uint |
char, signed char, unsigned char | c_char, c_schar, c_uchar |
long, size_t, ssize_t | c_long, usize, isize |
float, double | f32, f64 |
void * | *mut c_void |
const char * | *const c_char |
struct foo * where foo is opaque | *mut Foo with #[repr(C)] struct Foo { _private: [u8; 0] } |
The opaque type deserves its own sentence. kv_t is declared in the header but its fields are private to the C file, so Rust must never see its size or layout. A zero-length array in a #[repr(C)] struct is the idiom: you can name the type and hold pointers to it, but you cannot construct one or take its size. That prevents accidentally copying the handle by value, which would be undefined behavior.
Strings: the part everyone gets wrong
A Rust &str is a pointer and a length, with no terminator. A C string is a pointer to bytes ending in a NUL. They are not interchangeable, and passing "host".as_ptr() to C compiles and reads past the end of the string looking for a NUL.
The tool is CString, which allocates a copy with a terminator. Creating one can fail if the text contains a NUL byte of its own, since C would read it as the end of the string. CString::new returns a Result for that reason:
nul byte found in provided data at position: 4
nul at byte 4That is the output for CString::new("sess\0ion"). In FFI code, unwrap() on that call is a bug waiting for user input; map the error into your own error type. The reverse direction uses CStr::from_ptr, which measures a C string up to its NUL and borrows it as &CStr. From there to_str() returns a Result because C text need not be UTF-8, and to_string_lossy() always succeeds by substituting replacement characters. Choose the one that matches what you want to do with a malformed byte.
Mind the lifetimes, because the compiler will not. key.as_ptr() is valid only while the CString lives. Writing CString::new("k").unwrap().as_ptr() creates the CString, takes a pointer into it, and drops it at the end of the statement, leaving a dangling pointer. Bind the CString to a variable and keep it alive across the call, as the example does.
Ownership of the returned pointer
kv_get returns a const char * that the store owns. The Rust code reads it immediately inside the same expression and never frees it. Two mistakes follow from getting this wrong. Calling Rust's deallocator on a pointer from C's malloc corrupts the heap, because they can be different allocators. And holding the pointer across a later kv_set of the same key is a use after free, since the C code frees the old value string when it overwrites it. A comment in the header documented that rule; the Rust compiler cannot. The fix in a safe wrapper is to copy the string into an owned String at the call, which is the main thing the wrapper guide does.
Callbacks
kv_foreach takes a function pointer. The Rust side defines extern "C" fn print_entry(...), an ordinary function with the C calling convention, and passes it as the argument. It cannot be a closure that captures variables, because a C function pointer has no place to store captured state. The user_data parameter exists for that purpose: pass a pointer to your state, and the callback casts it back. The example passes null_mut() because it needs no state. One more rule: a panic must not unwind out of an extern "C" function into C code. Since Rust 1.81, an unwinding panic that reaches an extern "C" boundary aborts the process, which is the defined behavior, and the safe fix is to wrap the callback body in std::panic::catch_unwind.
Linking errors
If the build script does not compile the C file, the link fails, and the message is the one to recognize:
error: linking with `cc` failed: exit status: 1
= note: Undefined symbols for architecture arm64:
"_kv_open", referenced from:
ld: symbol(s) not found for architecture arm64The compiler was happy because the extern block promised that kv_open exists. The linker looked for it and found nothing. On Linux the same error reads undefined reference to 'kv_open'. The causes, in order of likelihood: the build script did not run or did not compile the right file, the library name in cargo:rustc-link-lib is wrong, the library is a C++ one whose symbols are mangled, or a dynamic library is installed but not on the search path.
Let bindgen write the declarations
Hand-written declarations drift. The header changes, the extern block does not, and nobody notices until a crash. bindgen reads the header and generates the block, so the two cannot disagree. Run it once to see what it makes:
bindgen csrc/kv.h --rust-target 1.85 --rust-edition 2024 \
--allowlist-function 'kv_.*' --allowlist-type 'kv_.*' --no-layout-tests#[repr(C)]
#[derive(Debug)]
pub struct kv {
_unused: [u8; 0],
}
pub type kv_t = kv;
unsafe extern "C" {
pub fn kv_open(name: *const ::std::os::raw::c_char) -> *mut kv_t;
}
unsafe extern "C" {
pub fn kv_set(
store: *mut kv_t,
key: *const ::std::os::raw::c_char,
value: *const ::std::os::raw::c_char,
) -> ::std::os::raw::c_int;
}
unsafe extern "C" {
pub fn kv_get(
store: *const kv_t,
key: *const ::std::os::raw::c_char,
) -> *const ::std::os::raw::c_char;
}
unsafe extern "C" {
pub fn kv_len(store: *const kv_t) -> usize;
}
pub type kv_visit_fn = ::std::option::Option<
unsafe extern "C" fn(
key: *const ::std::os::raw::c_char,
value: *const ::std::os::raw::c_char,
user_data: *mut ::std::os::raw::c_void,
),
>;(The listing is trimmed after the callback type; the real output also declares kv_foreach and kv_close.) Compare it with the hand-written version. It found the opaque struct and generated the same zero-length-array idiom. It mapped size_t to usize. And it made the callback type an Option of an unsafe extern "C" fn, because a C function pointer may be NULL. The hand-written version missed that, which means passing a null callback was not expressible; the generated one is more accurate.
The --allowlist-* flags matter. Without them bindgen also emits every type from stddef.h, and from any system header the file includes, which bloats the output and breaks on platform differences. List what you intend to use.
Running it from build.rs
For a real project, generate at build time so the bindings track the header:
use std::{env, path::PathBuf};
fn main() {
cc::Build::new().file("csrc/kv.c").include("csrc").warnings(true).compile("kv");
let bindings = bindgen::Builder::default()
.header("csrc/kv.h")
.allowlist_function("kv_.*")
.allowlist_type("kv_.*")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen could not read csrc/kv.h");
let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
bindings.write_to_file(out.join("kv_bindings.rs")).unwrap();
}#![allow(non_camel_case_types, dead_code)]
mod sys {
include!(concat!(env!("OUT_DIR"), "/kv_bindings.rs"));
}
use std::ffi::{CStr, CString};
fn main() {
let name = CString::new("settings").unwrap();
let store = unsafe { sys::kv_open(name.as_ptr()) };
let (k, v) = (CString::new("port").unwrap(), CString::new("8080").unwrap());
unsafe { sys::kv_set(store, k.as_ptr(), v.as_ptr()) };
let got = unsafe { CStr::from_ptr(sys::kv_get(store, k.as_ptr())) };
println!("port -> {}", got.to_str().unwrap());
unsafe { sys::kv_close(store) };
}port -> 8080bindgen as a build dependency needs libclang installed on the building machine, which is the cost of this approach: contributors and CI runners must have it. Many -sys crates avoid that by running bindgen once, checking the generated file into the repository, and regenerating only when the header changes. That is a trade of a build dependency for a file to maintain, and it is the usual choice for a crate that other people depend on.
Passing a closure through user_data
A bare extern "C" fn cannot capture variables, so the printing callback above could only write to the terminal. Real code wants to collect results, and the C API provides the escape hatch it always provides: a void *user_data that comes back untouched. The standard technique is a trampoline, a generic C-callable function that casts user_data back to a pointer to the Rust closure and calls it. Because it is generic over the closure type F, the compiler stamps out one trampoline per closure, and each is an ordinary function pointer that C can store.
use std::ffi::{c_char, c_int, c_void, CStr, CString};
#[repr(C)]
pub struct kv_t {
_private: [u8; 0],
}
unsafe extern "C" {
fn kv_open(name: *const c_char) -> *mut kv_t;
fn kv_set(store: *mut kv_t, key: *const c_char, value: *const c_char) -> c_int;
fn kv_foreach(
store: *const kv_t,
visit: Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void)>,
user: *mut c_void,
);
fn kv_close(store: *mut kv_t);
}
/// One C-callable function that forwards to whatever Rust closure `user` points at.
unsafe extern "C" fn trampoline<F: FnMut(&str, &str)>(key: *const c_char, value: *const c_char, user: *mut c_void) {
// SAFETY: `user` is the `&mut F` that `for_each` passed in below, and it outlives the C call.
let closure = unsafe { &mut *(user as *mut F) };
let (k, v) = unsafe { (CStr::from_ptr(key), CStr::from_ptr(value)) };
// A panic must not cross into C: swallow it here and let the caller decide what to do.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
closure(&k.to_string_lossy(), &v.to_string_lossy());
}));
}
fn for_each<F: FnMut(&str, &str)>(store: *const kv_t, mut f: F) {
unsafe { kv_foreach(store, Some(trampoline::<F>), &mut f as *mut F as *mut c_void) };
}
fn main() {
let name = CString::new("demo").unwrap();
let store = unsafe { kv_open(name.as_ptr()) };
for (k, v) in [("a", "1"), ("b", "22"), ("c", "333")] {
let (k, v) = (CString::new(k).unwrap(), CString::new(v).unwrap());
unsafe { kv_set(store, k.as_ptr(), v.as_ptr()) };
}
// The closure captures local state, which a bare `extern "C" fn` could not do.
let mut total_len = 0;
let mut keys = Vec::new();
for_each(store, |k, v| {
total_len += v.len();
keys.push(k.to_string());
});
println!("keys {keys:?}, total value length {total_len}");
// What C does with bad arguments is part of the contract too.
let rc = unsafe { kv_set(store, std::ptr::null(), std::ptr::null()) };
println!("kv_set with NULL arguments returned {rc}");
unsafe { kv_close(store) };
}keys ["a", "b", "c"], total value length 6
kv_set with NULL arguments returned -1The closure pushed into keys and added to total_len, both locals of main, which proves the state travelled through C and back. Four details make the trampoline sound, and each is something you would otherwise get wrong. &mut f as *mut F as *mut c_void turns the closure into a thin pointer; it is valid only for the duration of kv_foreach, which is exactly how long C may use it, and the comment on the unsafe block says so. The Some(trampoline::<F>) is the Option that bindgen generated for a nullable function pointer. The catch_unwind swallows any panic from the closure so it does not unwind through C frames, which is undefined behavior in older Rust and an abort in current Rust. And AssertUnwindSafe is the honest statement that you accept the closure's captured state may be half-updated after a panic.
The last line of output is a reminder that the C library's behavior on bad input is part of its contract. kv_set with NULL arguments returned -1 rather than crashing, as the header said it would. A library that does not promise that, and many do not, means your wrapper must validate arguments before they reach C. Pass NULL to a function that dereferences it and the result is a segmentation fault at best.
Testing across the boundary
Tests of FFI code are ordinary tests, with one extra tool worth the setup. A Rust test that calls the C library catches wrong declarations, wrong ownership and wrong lifetimes only if the bug causes a visible symptom, and a use after free often does not. Run the test binary under a memory checker. On Linux, valgrind --error-exitcode=1 target/debug/deps/ckv-* reports reads of freed memory and leaks in the C code and in your use of it. On macOS and Linux, building with RUSTFLAGS="-Zsanitizer=address" on nightly, and compiling the C with -fsanitize=address (set through CFLAGS for the cc crate), instruments both sides. Miri cannot run foreign code, so it does not help across the boundary; it remains useful for the pure-Rust parts of your wrapper. Write at least these cases: an empty store, a key that is absent, overwriting a key, a string with a non-ASCII character, and the cleanup path. They cover the ownership rules the header documented only in comments.
The conventions of a -sys crate
If you plan to publish the bindings, the ecosystem has a naming convention worth following. The crate with the raw, generated declarations is named foo-sys, contains no logic, and declares links = "foo" in its Cargo.toml so Cargo can ensure that only one version of the native library is linked into a build. A separate crate, foo, builds the safe API on top. The split lets many safe wrappers share one set of bindings, and it keeps the unsafe surface in a place with a small, auditable job. The raw bindings are never meant for application code; they are the foundation for a safe wrapper.
What can still go wrong
Even with correct declarations, a handful of mistakes remain, each of which compiles.
- Calling a C function from several threads when the library is not thread-safe. Check the library's documentation, and wrap the handle in a
Mutexor leave it!Sendif in doubt. - Forgetting to call the cleanup function, which leaks, or calling it twice, which crashes. Cleanup belongs in a
Dropimpl on a wrapper type, not in the code that uses the library. - Assuming a C
intis 32 bits and alongis 64. They are not on every platform; use thec_*aliases. - Passing a Rust struct to C without
#[repr(C)], which gives Rust the freedom to reorder fields. - Ignoring return codes. Every function that can fail returns a status, and an
assert_eq!(rc, 0)in a demo becomes a real error path in a program.
When not to call C
If a Rust crate already does the job, use it: an equivalent written in Rust avoids the entire class of problems above and gets the compiler's checks across the boundary. If the C library is large and you only need a fraction, vendoring that fraction is easier than binding it all. And if the only reason is speed, measure first, because a pure Rust implementation is often within a few percent. When none of that applies, as with a mature library such as SQLite, zlib or libcurl, binding is the right call, and the sequence in this guide, a hand-written trial, then bindgen, then a safe wrapper, is the path. The other direction, exposing Rust to C, is covered in calling Rust from C with cbindgen.
A reading order for any C header
When you face an unfamiliar C library, read its header in this order and note the answer to each question before writing any Rust. Who allocates each pointer and who frees it? Which functions return a status, and which signal failure through a special return value like NULL or -1? Is there global initialization that must run once, and teardown that must run last? Which functions may be called from several threads at the same time? And which pointers returned by the library stay valid only until another call? Write those answers as comments on the extern declarations. They become the # Safety sections of your wrapper, and they are the difference between bindings that merely compile and bindings someone can trust.


