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.

Diagram of the Rust and C boundary. Rust strings (&str, String: UTF-8 with a length and no NUL) become CString with a terminating NUL and are passed as const char pointers that C may only borrow for the call. C strings arrive as const char pointers owned by C, are borrowed with the unsafe CStr::from_ptr and converted with to_str or to_string_lossy, copying if they must be kept. A Rust object is allocated with Box::into_raw and handed to C as an opaque pointer that C only passes back; it is freed on the Rust side with Box::from_raw in a shelf_catalog_free function, never with C’s free. Panics must not unwind across the extern C boundary.

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");
}
// 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()) }
}

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
}

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

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
/* 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);

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 *_free function, C allocations with C’s free (or the library’s own destructor). CString::into_raw/from_raw and Box::into_raw/from_raw transfer ownership explicitly.

  • Lifetimes over borrowed C data: a &CStr or 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 with std::panic::catch_unwind and turn them into error codes, or, when C++ or another unwinding language is involved and you want unwinding to cross, use the extern "C-unwind" ABI (1.71).

  • Threads: C libraries document which calls are thread-safe; reflect that with Send/Sync on 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.

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.