FFI & C Interop
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust 1.99.0, the 2024 edition, cc 1.6, bindgen 0.73 and cbindgen 0.29, checked on
2026-10-06. Rust can call C functions and be called from C with no runtime in between: both sides agree on the C ABI
(calling convention and data layout). That makes Rust a practical language for extending C programs, replacing one
module of a C or C++ code base at a time, or wrapping native libraries for safe use. Every example here comes from one
scratch crate, ffitest, that compiles a small C library, wraps it, and exposes Rust functions back to a C program.
The C library
#include <stddef.h>
/* Weighted ISBN-13 checksum of the first n ASCII digits; -1 on a non-digit. */
int isbn_checksum(const char *digits, size_t n);
/* Calls cb(user_data, title) for each title in a fixed list. */
typedef void (*title_cb)(void *user_data, const char *title);
void for_each_title(title_cb cb, void *user_data);
Calling C from Rust
Foreign functions are declared in an extern "C" block. In the 2024 edition the block itself must be marked unsafe
(the declarations are promises you make about foreign code), and individual items can be marked safe — callable
without unsafe — when they have no preconditions, or unsafe (the default) when they do. Use the C-compatible types in
core::ffi (c_char, c_int, c_long, c_void, …), never libc aliases or guessed Rust integer types:
mod sys {
use std::ffi::{c_char, c_int, c_void};
pub type TitleCb = Option<unsafe extern "C" fn(user_data: *mut c_void, title: *const c_char)>;
// Edition 2024: extern blocks are `unsafe extern`, and items can be marked `safe`.
unsafe extern "C" {
pub fn isbn_checksum(digits: *const c_char, n: usize) -> c_int;
pub fn for_each_title(cb: TitleCb, user_data: *mut c_void);
pub safe fn abs(x: c_int) -> c_int; // from libc: no preconditions, so callable without `unsafe`
}
}
Raw declarations are then wrapped in safe functions that check or guarantee the preconditions, so the rest of the
program never touches unsafe. Strings are the usual friction point: Rust’s &str is UTF-8 with a length and no
terminator, while C uses NUL-terminated char *. CString builds an owned NUL-terminated copy (failing if the text
contains a NUL byte); CStr borrows a C string; c"..." literals (1.77) create a &'static CStr directly.
/// Safe wrapper: validates an ISBN-13 using the C checksum.
pub fn isbn_is_valid(isbn: &str) -> bool {
let digits: String = isbn.chars().filter(|c| *c != '-').collect();
if digits.len() != 13 {
return false;
}
// SAFETY: `digits` is a live UTF-8 buffer of exactly `digits.len()` bytes; the C function
// reads n bytes and does not keep the pointer.
let sum = unsafe { sys::isbn_checksum(digits.as_ptr().cast::<c_char>(), digits.len()) };
sum >= 0 && sum % 10 == 0
}
/// Safe wrapper around a C callback API: collects the titles into a Vec.
pub fn all_titles() -> Vec<String> {
unsafe extern "C" fn push(user_data: *mut c_void, title: *const c_char) {
// SAFETY: `user_data` is the &mut Vec passed below; `title` is a NUL-terminated string
// that stays valid for the duration of the callback.
let titles = unsafe { &mut *user_data.cast::<Vec<String>>() };
let title = unsafe { CStr::from_ptr(title) };
titles.push(title.to_string_lossy().into_owned());
}
let mut titles: Vec<String> = Vec::new();
// SAFETY: `push` matches the callback signature and `titles` outlives the call.
unsafe { sys::for_each_title(Some(push), (&raw mut titles).cast::<c_void>()) };
titles
}
pub fn magnitude(x: i32) -> i32 {
sys::abs(x) // a `safe` foreign item: no unsafe block needed
}
The callback example shows the standard pattern for C APIs that take a function pointer plus a void *user_data: pass
a pointer to Rust state as the user data and cast it back inside an extern "C" trampoline function.
Building and linking: build.rs, cc and bindgen
The C code is compiled and linked by a build script. The cc crate drives the system C compiler and emits the right
cargo::rustc-link-lib instructions; for an existing system library you would print them yourself (or use
#[link(name = "isbn")] on the extern block, or the pkg-config crate). bindgen generates the extern declarations
from a header with libclang, so they cannot drift from the C code:
[package]
name = "ffitest"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"] # cdylib: a C-loadable .so/.dylib/.dll; rlib: usable from Rust too
[build-dependencies]
cc = "1.6"
bindgen = "0.73"
// build.rs: compile the C library and generate Rust declarations for its header.
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo::rerun-if-changed=c/isbn.c");
println!("cargo::rerun-if-changed=c/isbn.h");
cc::Build::new().file("c/isbn.c").compile("isbn"); // builds libisbn.a and links it
let bindings = bindgen::Builder::default()
.header("c/isbn.h")
.allowlist_function("isbn_checksum|for_each_title")
.wrap_unsafe_ops(true)
.generate()
.expect("generate bindings");
let out = PathBuf::from(env::var("OUT_DIR").expect("set by cargo"));
bindings.write_to_file(out.join("isbn_bindings.rs")).expect("write bindings");
}
Source: The bindgen User Guide
// in src/lib.rs: use the generated declarations
#[allow(non_camel_case_types, dead_code)]
mod generated {
include!(concat!(env!("OUT_DIR"), "/isbn_bindings.rs"));
}
pub fn checksum_via_bindgen(digits: &str) -> i32 {
// SAFETY: as in `isbn_is_valid`.
unsafe { generated::isbn_checksum(digits.as_ptr().cast(), digits.len()) }
}
Source: The bindgen User Guide
Current bindgen generates unsafe extern "C" blocks and, with wrap_unsafe_ops(true), code that is clean under the
2024 edition’s unsafe_op_in_unsafe_fn lint. Mara & Holmes (Refactoring to Rust, chapters 3-4) use bindgen 0.56 and
the single-colon cargo: instructions; the build script above uses the current forms.
Exposing Rust to C
A Rust function becomes callable from C when it uses the C ABI (extern "C") and has an unmangled symbol name. In the
2024 edition, no_mangle and export_name are unsafe attributes — two functions exporting the same symbol is
undefined behaviour — so they are written #[unsafe(no_mangle)]. A function that dereferences pointers received from C
must itself be an unsafe extern "C" fn with a # Safety section; Clippy’s not_unsafe_ptr_arg_deref lint (deny by
default) rejects a safe pub extern "C" fn that dereferences a raw-pointer argument.
/// Returns 1 if `isbn` is a valid ISBN-13, 0 otherwise.
///
/// # Safety
///
/// `isbn` must be a valid pointer to a NUL-terminated string that stays alive for the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn shelf_isbn_valid(isbn: *const c_char) -> c_int {
if isbn.is_null() {
return 0;
}
// SAFETY: guaranteed by the caller (see # Safety).
let isbn = unsafe { CStr::from_ptr(isbn) };
c_int::from(isbn.to_str().is_ok_and(isbn_is_valid))
}
Build with crate-type = ["cdylib"] for a shared library or ["staticlib"] for a .a/.lib to link into a C program.
The Rust standard library is linked in either way; a staticlib may require linking the system libraries Rust uses
(cargo rustc — --print native-static-libs lists them).
Opaque handles
Rust types with Rust layout must not be accessed by C. Hand C an opaque pointer to a boxed Rust value, give it functions to operate on it, and a function to free it — which must run on the Rust side, with the Rust allocator:
/// An opaque handle: C only ever sees a pointer to it.
pub struct ShelfCatalog {
titles: Vec<String>,
}
#[unsafe(no_mangle)]
pub extern "C" fn shelf_catalog_new() -> *mut ShelfCatalog {
Box::into_raw(Box::new(ShelfCatalog { titles: all_titles() }))
}
/// Returns a newly allocated copy of the title at `index`, or NULL. Free it with `shelf_string_free`.
///
/// # Safety
///
/// `catalog` must come from `shelf_catalog_new` and not have been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn shelf_catalog_title(catalog: *const ShelfCatalog, index: usize) -> *mut c_char {
// SAFETY: guaranteed by the caller.
let Some(catalog) = (unsafe { catalog.as_ref() }) else { return std::ptr::null_mut() };
match catalog.titles.get(index).and_then(|t| CString::new(t.as_str()).ok()) {
Some(c) => c.into_raw(), // ownership passes to C ...
None => std::ptr::null_mut(),
}
}
/// # Safety
///
/// `s` must come from `shelf_catalog_title` (or be NULL) and must not be used afterwards.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn shelf_string_free(s: *mut c_char) {
if !s.is_null() {
// SAFETY: ... and comes back here, to be freed by the allocator that created it.
drop(unsafe { CString::from_raw(s) });
}
}
/// # Safety
///
/// `catalog` must come from `shelf_catalog_new` (or be NULL) and must not be used afterwards.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn shelf_catalog_free(catalog: *mut ShelfCatalog) {
if !catalog.is_null() {
// SAFETY: guaranteed by the caller.
drop(unsafe { Box::from_raw(catalog) });
}
}
Structs that C does read field by field must be #[repr(C)] (see
Memory Layout & repr); enums passed to C should be
#[repr(C)] or #[repr(u32)]-style fieldless enums, and Option<&T>, Option<Box<T>> and Option<extern "C" fn>
are guaranteed to be ABI-compatible with nullable C pointers.
C-variadic functions (1.99)
Since Rust 1.99, Rust can define C-variadic functions (like printf), not only call them. The ... parameter is a
VaList, read with next_arg::<T>(), which is unsafe because nothing checks the caller’s argument types:
/// A C-variadic function defined in Rust (1.99): `shelf_sum(3, 1, 2, 3)` returns 6.
///
/// # Safety
///
/// The caller must pass exactly `count` further arguments, each of type `int`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn shelf_sum(count: c_int, mut args: ...) -> c_int {
let mut total = 0;
for _ in 0..count {
// SAFETY: the caller promises `count` int arguments.
total += unsafe { args.next_arg::<c_int>() };
}
total
}
Source: std::ffi::VaList
The C side, and generating the header with cbindgen
#include <stdio.h>
#include <stddef.h>
typedef struct ShelfCatalog ShelfCatalog; /* opaque */
int shelf_isbn_valid(const char *isbn);
ShelfCatalog *shelf_catalog_new(void);
char *shelf_catalog_title(const ShelfCatalog *catalog, size_t index);
void shelf_string_free(char *s);
void shelf_catalog_free(ShelfCatalog *catalog);
int shelf_sum(int count, ...);
int main(void) {
printf("valid: %d %d\n", shelf_isbn_valid("978-0-441-01359-3"), shelf_isbn_valid("978-0-441-01359-4"));
ShelfCatalog *c = shelf_catalog_new();
char *t = shelf_catalog_title(c, 1);
printf("title 1: %s\n", t);
shelf_string_free(t);
shelf_catalog_free(c);
printf("sum: %d\n", shelf_sum(3, 1, 2, 3));
return 0;
}
cargo build # builds target/debug/libffitest.so (cdylib)
cc c/main.c -L target/debug -lffitest -o c_main
LD_LIBRARY_PATH=target/debug ./c_main
# valid: 1 0
# title 1: Emma
# sum: 6
Source: The Rust Reference: Linkage
cbindgen generates the C (or C++) header from the Rust source, including the doc comments, so the declarations above need not be maintained by hand:
cargo install cbindgen
cbindgen --config cbindgen.toml --crate ffitest --output include/shelf.h
Source: cbindgen documentation
/* excerpt of the generated include/shelf.h */
typedef struct ShelfCatalog ShelfCatalog;
int shelf_isbn_valid(const char *isbn);
struct ShelfCatalog *shelf_catalog_new(void);
char *shelf_catalog_title(const struct ShelfCatalog *catalog,
uintptr_t index);
Source: cbindgen documentation
Ownership, panics and the C-unwind ABI
-
Ownership: document for every pointer who owns it and how long it lives. Free memory with the allocator that allocated it: Rust allocations with a Rust
*_freefunction, C allocations with C’sfree(or the library’s own destructor).CString::into_raw/from_rawandBox::into_raw/from_rawtransfer ownership explicitly. -
Lifetimes over borrowed C data: a
&CStror slice created from a C pointer must not outlive the C data; wrap such views in types with lifetime parameters tied to the owning handle. -
Panics: unwinding out of an
extern "C"function aborts the process (it used to be undefined behaviour). Catch panics at the boundary withstd::panic::catch_unwindand turn them into error codes, or, when C++ or another unwinding language is involved and you want unwinding to cross, use theextern "C-unwind"ABI (1.71). -
Threads: C libraries document which calls are thread-safe; reflect that with
Send/Syncon your wrapper types (see Shared State, Send/Sync & Atomics).
/// Parses a year; returns -1 for invalid input and -2 if Rust code panicked.
/// Panics must not unwind into C: catch them at the boundary, or use the "C-unwind" ABI.
///
/// # Safety
///
/// `year` must be NULL or a valid pointer to a NUL-terminated string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn shelf_parse_year(year: *const c_char) -> c_int {
std::panic::catch_unwind(|| {
if year.is_null() {
return -1;
}
// SAFETY: guaranteed by the caller (see # Safety).
let text = unsafe { CStr::from_ptr(year) }.to_string_lossy();
text.trim().parse().unwrap_or(-1)
})
.unwrap_or(-2)
}
A case study: an NGINX module
Mara & Holmes (Refactoring to Rust, chapter 4) walk through replacing part of an NGINX module written in C with Rust:
bindgen generates declarations for NGINX’s headers, a build.rs links against the server’s objects, and Rust functions
exported with the C ABI implement a request handler while all memory owned by NGINX stays under NGINX’s pool allocator.
The lessons generalise: keep the C-facing layer thin and unsafe, convert to safe Rust types immediately, and never let
Rust free memory it did not allocate. The book’s code uses the 2018 edition; with current Rust, declare the bindings in
unsafe extern blocks and export with #[unsafe(no_mangle)] as shown above.
FFI idioms
The community Rust Design Patterns collects FFI idioms worth following: accept strings as *const c_char and convert
with CStr, return errors as integer codes plus an accessor for a message, expose objects through opaque handles with
explicit constructors and destructors ("object-based APIs"), and consolidate wrapper types so each C resource has exactly
one Rust owner.
Source: Rust Design Patterns: FFI idioms
FFI in other languages on this site
The same boundary from the other side: C: Storage
Duration, Scope & Linkage for symbols and linkage, C++:
Program Structure & Compilation for extern "C" in C++, C#: Native
Interop for P/Invoke, and Swift:
Interoperability with C, Objective-C & C++. Python extensions have their own page:
Python Interop with PyO3.
Where the books are outdated
Mara & Holmes (chapters 3-4) and the Crossley et al. book (chapter 11) use bare extern "C" {} blocks and #[no_mangle],
which the 2024 edition rejects in favour of unsafe extern "C" {} and #[unsafe(no_mangle)]; use core::ffi::c_char
instead of libc::c_char. Mara & Holmes also show a safe extern "C" fn that dereferences pointers passed by the
caller, and print from inside the FFI layer; an exported function that dereferences caller pointers must be an
unsafe extern "C" fn with a # Safety section, and side effects such as printing belong in the caller.
References
-
The Rust Reference: Extern function qualifier and Application binary interface
-
The Edition Guide: Unsafe extern blocks and Unsafe attributes
-
std::ffi (CStr, CString, c_char, VaList)
-
Rust Design Patterns: FFI idioms and patterns (community)
-
Lily Mara and Joel Holmes, Refactoring to Rust (Manning, 2025)