Skip to content
rusthacks.dev
Systems

Calling Rust From C With Cbindgen and Staticlib Step by Step

To expose Rust to C, mark functions extern "C" with a stable name, build a staticlib or cdylib and let cbindgen write the header. The rules are about layout, ownership and panics.

17 min read
Summarize in AI
Flat illustration for the post “Calling Rust From C With Cbindgen and Staticlib Step by Step”, composed around ffi.

The usual direction of FFI talk is Rust calling C, but the reverse is how Rust gets adopted in the real world. A team does not rewrite a 400,000 line C application. It replaces one hot or hazardous module, a parser that keeps producing security advisories, a compression routine, a protocol decoder, with Rust code that the rest of the application calls through its existing C interface. The C side sees a header and a library. The Rust side keeps its type system, its tests and its borrow checker everywhere except the thin layer at the border.

This guide builds that border. It exports a small word-counting library from Rust, generates the C header with cbindgen, compiles a C program against it and runs it. It also covers the rules the border must enforce: no panic may escape, memory must be freed by the side that allocated it, and errors must become integers. The library was built with Rust 1.99 (edition 2024) and cbindgen 0.29.4, the C program was compiled with the system cc on an Apple silicon Mac, and the outputs below are what they printed.

What the C side needs from you

C has no generics, no ownership and no exceptions. Whatever you export must be expressible in those terms, which constrains the design more than any syntax does. Five rules cover it.

  1. Functions use the C calling convention and a stable, unmangled name: extern "C" plus #[unsafe(no_mangle)].
  2. Types that cross are either primitives, pointers, or #[repr(C)] structs whose layout C can reproduce. Everything else, a String, a Vec, a trait object, stays on the Rust side behind an opaque pointer.
  3. Every object Rust creates is destroyed by a Rust function you export. C must never free it.
  4. Failure is a return code or a null pointer. Nothing unwinds across the border.
  5. Every function documents what it requires of its pointers, because C callers cannot be type-checked.

The library below follows them one at a time.

The Rust library

Start with the manifest. The crate type decides what C links against: staticlib produces a .a archive that is linked into the final executable, and cdylib produces a shared library (.so, .dylib or .dll). Build both and ship whichever your C build prefers.

Cargo.tomlTOML
[package]
name = "wordstat"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["staticlib", "cdylib"]
src/lib.rsrust
//! A word counter with a C API.

use std::ffi::{c_char, c_int, CStr, CString};
use std::panic::{catch_unwind, AssertUnwindSafe};

/// Opaque to C: it only ever sees a `WordStats *`.
pub struct WordStats {
    words: usize,
    lines: usize,
}

/// Plain data that C can receive by value.
#[repr(C)]
pub struct WordStatsSummary {
    pub words: usize,
    pub lines: usize,
}

pub const WORDSTAT_OK: c_int = 0;
pub const WORDSTAT_NULL_ARGUMENT: c_int = -1;
pub const WORDSTAT_INVALID_UTF8: c_int = -2;
pub const WORDSTAT_PANIC: c_int = -3;

/// Create a counter. Free it with `wordstat_free`.
#[unsafe(no_mangle)]
pub extern "C" fn wordstat_new() -> *mut WordStats {
    Box::into_raw(Box::new(WordStats { words: 0, lines: 0 }))
}

/// Add the words and lines in a NUL-terminated UTF-8 string.
///
/// # Safety
/// `stats` must come from `wordstat_new` and not have been freed. `text` must point to a
/// NUL-terminated string that stays valid for the duration of the call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_feed(stats: *mut WordStats, text: *const c_char) -> c_int {
    if stats.is_null() || text.is_null() {
        return WORDSTAT_NULL_ARGUMENT;
    }
    let result = catch_unwind(AssertUnwindSafe(|| {
        // SAFETY: both pointers were checked for null; validity is the caller's contract.
        let (stats, text) = unsafe { (&mut *stats, CStr::from_ptr(text)) };
        match text.to_str() {
            Ok(s) => {
                stats.words += s.split_whitespace().count();
                stats.lines += s.lines().count();
                WORDSTAT_OK
            }
            Err(_) => WORDSTAT_INVALID_UTF8,
        }
    }));
    result.unwrap_or(WORDSTAT_PANIC)
}

/// Totals so far. Returns zeros for a null pointer.
///
/// # Safety
/// `stats` must be null or a live pointer from `wordstat_new`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_summary(stats: *const WordStats) -> WordStatsSummary {
    match unsafe { stats.as_ref() } {
        Some(s) => WordStatsSummary { words: s.words, lines: s.lines },
        None => WordStatsSummary { words: 0, lines: 0 },
    }
}

/// A human-readable report. The returned string is allocated by Rust and must be
/// released with `wordstat_string_free`, never with C's `free`.
///
/// # Safety
/// `stats` must be null or a live pointer from `wordstat_new`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_report(stats: *const WordStats) -> *mut c_char {
    let Some(s) = (unsafe { stats.as_ref() }) else { return std::ptr::null_mut() };
    let text = format!("{} words on {} lines", s.words, s.lines);
    CString::new(text).map_or(std::ptr::null_mut(), CString::into_raw)
}

/// # Safety
/// `s` must be null or a pointer returned by `wordstat_report`, not yet freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_string_free(s: *mut c_char) {
    if !s.is_null() {
        drop(unsafe { CString::from_raw(s) });
    }
}

/// # Safety
/// `stats` must be null or a pointer from `wordstat_new`, not yet freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_free(stats: *mut WordStats) {
    if !stats.is_null() {
        drop(unsafe { Box::from_raw(stats) });
    }
}

/// Write the report into a buffer the caller owns, `snprintf` style.
///
/// Returns the length the full report needs, not counting the NUL, or a negative error code.
/// At most `cap` bytes are written (including the NUL), so a return value `>= cap` means
/// the output was truncated. Passing `buf = NULL` with `cap = 0` asks only for the length.
///
/// # Safety
/// `stats` must be a live pointer from `wordstat_new`. `buf` must be null or valid for
/// writes of `cap` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_report_into(stats: *const WordStats, buf: *mut c_char, cap: usize) -> isize {
    let Some(s) = (unsafe { stats.as_ref() }) else { return WORDSTAT_NULL_ARGUMENT as isize };
    let text = format!("{} words on {} lines", s.words, s.lines);
    if !buf.is_null() && cap > 0 {
        let n = text.len().min(cap - 1);
        // SAFETY: the caller promised `buf` is writable for `cap` bytes and `n + 1 <= cap`.
        unsafe {
            std::ptr::copy_nonoverlapping(text.as_ptr(), buf.cast::<u8>(), n);
            *buf.add(n) = 0;
        }
    }
    text.len() as isize
}

Read the file for the pattern, because the same shape repeats for every Rust library you expose.

An opaque handle. WordStats is an ordinary Rust struct with no #[repr(C)]. C never sees its fields, only a pointer to it. wordstat_new boxes one and returns Box::into_raw, which hands ownership to the caller as a raw pointer. wordstat_free takes that pointer back with Box::from_raw and drops it. The pair is the lifecycle, and there are no other ways in or out. Because the struct is opaque, you can add and reorder fields in the next release without recompiling a single C file.

Data by value. WordStatsSummary is #[repr(C)], which makes its layout match what a C compiler would produce for the same two fields, so it can be returned by value. Use this for small plain-data results. It is the one place where the layout becomes part of your public interface: adding a field later breaks every compiled caller, so such structs deserve more care than opaque handles.

Error codes. The four constants, WORDSTAT_OK and three negative values, are the whole error vocabulary. wordstat_feed checks its pointers for null before touching them, converts the C string with CStr::to_str, which fails for text that is not UTF-8, and returns a distinct code for each case. C callers can switch on the result. Do not return Rust's Result: it has no stable layout.

Panic containment. The body of wordstat_feed runs inside catch_unwind, and a panic becomes WORDSTAT_PANIC instead of unwinding into C. This matters more than it looks. Unwinding through frames compiled by a C compiler is undefined behavior in the older rules, and an abort in current Rust, which kills the host application. A library that crashes its host over a bug in one call is a library no one will embed twice. Wrap every exported function that can panic, which in practice means every function that does more than trivial work.

A string that Rust allocated. wordstat_report builds a String, converts it to a CString and returns into_raw, handing C a char *. The doc comment says what the type system cannot: release it with wordstat_string_free, never with free. The returned pointer was allocated by Rust's allocator, and the matching free must go through the same allocator. On many platforms Rust uses the system malloc, and calling free happens to work, which is the worst possible outcome because it teaches the wrong lesson and then fails when someone switches to a different allocator, or on Windows when the library and the application are linked against different C runtimes.

Generate the header

Writing the header by hand is how the C and Rust sides drift apart. cbindgen reads the crate's source and emits it. A small configuration file sets the language and the include guard:

cbindgen.tomlTOML
language = "C"
include_guard = "WORDSTAT_H"
cpp_compat = true
documentation = true
documentation_style = "c"
Shell
cbindgen --config cbindgen.toml --crate wordstat -o wordstat.h

Here is what it produced, trimmed to the declarations:

wordstat.hc
#ifndef WORDSTAT_H
#define WORDSTAT_H

#define WORDSTAT_OK 0
#define WORDSTAT_NULL_ARGUMENT -1
#define WORDSTAT_INVALID_UTF8 -2
#define WORDSTAT_PANIC -3

/* Opaque to C: it only ever sees a `WordStats *`. */
typedef struct WordStats WordStats;

/* Plain data that C can receive by value. */
typedef struct WordStatsSummary {
  uintptr_t words;
  uintptr_t lines;
} WordStatsSummary;

#ifdef __cplusplus
extern "C" {
#endif // __cplusplus

struct WordStats *wordstat_new(void);
int wordstat_feed(struct WordStats *stats, const char *text);
struct WordStatsSummary wordstat_summary(const struct WordStats *stats);
char *wordstat_report(const struct WordStats *stats);
void wordstat_string_free(char *s);
void wordstat_free(struct WordStats *stats);

#ifdef __cplusplus
}  // extern "C"
#endif  // __cplusplus

#endif  /* WORDSTAT_H */

The doc comments, including the # Safety sections, were carried across as C comments, so the contract travels with the header. The opaque struct became a forward-declared typedef struct WordStats WordStats, which C code can hold pointers to but cannot instantiate. usize became uintptr_t, and the constants became macros. cpp_compat wrapped everything in extern "C" so C++ code can include the same file.

One naming lesson comes from this output. The first version of this library named its types Stats and Summary, and the generated header declared global C types with those names, which collide with any application that has its own Stats. C has no namespaces, so prefix every exported name. The functions already carried wordstat_, and renaming the types to WordStats and WordStatsSummary fixed the types. The [export] prefix option in the configuration can do it automatically.

The C program

main.cc
#include <stdio.h>
#include <stdlib.h>
#include "wordstat.h"

int main(void) {
    WordStats *stats = wordstat_new();

    const char *docs[] = {"the quick brown fox", "jumps over\nthe lazy dog"};
    for (int i = 0; i < 2; i++) {
        int rc = wordstat_feed(stats, docs[i]);
        if (rc != WORDSTAT_OK) {
            fprintf(stderr, "feed failed: %d\n", rc);
            return 1;
        }
    }

    WordStatsSummary s = wordstat_summary(stats);
    printf("summary: %zu words, %zu lines\n", s.words, s.lines);

    char *report = wordstat_report(stats);
    printf("report:  %s\n", report);
    wordstat_string_free(report); /* not free(): Rust allocated this */

    /* Errors are return codes, not crashes. */
    printf("feed(NULL text) -> %d\n", wordstat_feed(stats, NULL));
    printf("feed(bad utf-8) -> %d\n", wordstat_feed(stats, "caf\xff"));

    wordstat_free(stats);
    wordstat_free(NULL); /* allowed */
    return 0;
}

Build the Rust library in release mode, then compile and link the C program against the static archive. The Rust standard library needs a few system libraries, and rustc will tell you which:

Shell
cargo build --release
cargo rustc --release --crate-type staticlib -- --print native-static-libs
cc -Wall -Wextra -o demo main.c target/release/libwordstat.a -lSystem -lc -lm
./demo

The second command prints the line note: native-static-libs: -lSystem -lc -lm on this machine; on Linux it lists -lgcc_s -lutil -lrt -lpthread -lm -ldl -lc. Copy exactly what it prints into your link command. Missing a library produces undefined-symbol errors from std internals that look unrelated to your code.

Text
summary: 9 words, 3 lines
report:  9 words on 3 lines
feed(NULL text) -> -1
feed(bad utf-8) -> -2

The first document has four words on one line and the second has five words across two, so nine words and three lines, computed by Rust and printed by C. The next two lines show the border holding. Passing a null text pointer returned -1 where a careless C library would have crashed, and passing the bytes caf\xff, which end in a byte that is not valid UTF-8, returned -2 rather than corrupting the counter. wordstat_free(NULL) is explicitly allowed, mirroring free(NULL), which makes cleanup code in C simpler to write correctly.

Static or dynamic

The same build produced both artifacts. The static archive libwordstat.a is 18.7 MB on disk, large because it carries debugging information and all of std, and the linker keeps only what the program uses, so the final executable is much smaller. The dynamic library libwordstat.dylib is 388 KB. Its exported symbols show exactly the six functions in the header and nothing else:

Text
$ nm -gU target/release/libwordstat.dylib | grep wordstat
0000000000000900 T _wordstat_feed
0000000000000c2c T _wordstat_free
0000000000000c40 T _wordstat_new
0000000000000c78 T _wordstat_report
0000000000000d6c T _wordstat_string_free
0000000000000d9c T _wordstat_summary

That is the stable surface. Anything not marked #[unsafe(no_mangle)] is invisible to C. Check this listing in CI so an accidental export, or a missing one, fails a build. Choose static linking for a single self-contained executable and the simplest deployment; choose the dynamic library when several programs share it, when you want to update the Rust side without relinking, or when the host is a language runtime that loads plugins. With a dynamic library the loader must find it at runtime, via LD_LIBRARY_PATH or DYLD_LIBRARY_PATH while testing, and an install name or rpath in production.

Let the caller own the buffer

The report function above returns memory that Rust allocated, which forces the two-sided lifecycle: C must remember to call wordstat_string_free. A cleaner border avoids shared ownership entirely by letting the caller supply the memory, the way snprintf does. The caller passes a buffer and its capacity, Rust writes into it, and the return value says how much space the full result needs. Nothing allocated on one side is freed on the other.

src/lib.rsrust
#[unsafe(no_mangle)]
pub unsafe extern "C" fn wordstat_report_into(stats: *const WordStats, buf: *mut c_char, cap: usize) -> isize {
    let Some(s) = (unsafe { stats.as_ref() }) else { return WORDSTAT_NULL_ARGUMENT as isize };
    let text = format!("{} words on {} lines", s.words, s.lines);
    if !buf.is_null() && cap > 0 {
        let n = text.len().min(cap - 1);
        // SAFETY: the caller promised `buf` is writable for `cap` bytes and `n + 1 <= cap`.
        unsafe {
            std::ptr::copy_nonoverlapping(text.as_ptr(), buf.cast::<u8>(), n);
            *buf.add(n) = 0;
        }
    }
    text.len() as isize
}

(The doc comment, trimmed here, spells out the contract: at most cap bytes are written including the terminator, the return value is the full length excluding it, and a NULL buffer with zero capacity asks only for the length.) The C caller uses the standard two-call pattern, and the second call shows truncation:

main2.cc
ptrdiff_t need = wordstat_report_into(stats, NULL, 0);
char *buf = malloc((size_t)need + 1);
wordstat_report_into(stats, buf, (size_t)need + 1);
printf("needed %td bytes: \"%s\"\n", need, buf);
free(buf); /* correct here: C allocated it, so C frees it */

char small[8];
ptrdiff_t full = wordstat_report_into(stats, small, sizeof small);
printf("small buffer holds \"%s\", full length is %td (truncated: %s)\n",
       small, full, (size_t)full >= sizeof small ? "yes" : "no");
Text
needed 18 bytes: "7 words on 1 lines"
small buffer holds "7 words", full length is 18 (truncated: yes)

Here free(buf) is correct, because C allocated the buffer. The eight-byte buffer received "7 words", seven characters plus the terminator, and the return value 18 told the caller the output was cut off. The length written is min(len, cap - 1), so the function can never write past the buffer, which is the property a C caller most needs from a library it did not write. This pattern is the better default for any variable-length output, and the library gains a second benefit: there is nothing to leak when the C caller returns early on an error path. Reserve the allocate-and-return style for objects with real lifecycles, like the WordStats handle itself.

Evolving the interface without breaking callers

A shared library lives longer than any one release of the program that uses it, so the interface needs a versioning plan on day one. Three habits cost almost nothing. Export a function wordstat_version() that returns a number or a string, so a program can check at startup that it linked the library it was compiled against. Never remove or change the meaning of an exported function in a compatible release; add wordstat_feed2 and keep the old name. And keep structs opaque whenever possible. The one by-value struct in this library is also the one place where a future change is a breaking change, so if you expect to add statistics, return them through accessor functions, wordstat_word_count(stats) and so on, and keep the layout private. A change to the header should be a conscious decision with a version bump, which the CI check that regenerates the header and compares it to the committed one is there to enforce.

Symbols deserve the same discipline. On Linux, a version script or -Wl,--exclude-libs keeps Rust's internal symbols from leaking out of the shared object, and on every platform the nm -gU listing above is the audit: the exported set should equal the header, exactly. If it lists more, something has become part of your public interface by accident.

How the border breaks

The failures that matter are all things the compiler cannot see, so list them before you ship.

Freeing across allocators. Calling free() on the pointer from wordstat_report happened to run without a crash on this platform, because Rust's default allocator here is the system malloc. That is luck. A project that later switches its Rust code to another global allocator, or a Windows build where the DLL and the executable use different C runtimes, will crash or corrupt the heap. The rule is absolute: whoever allocates, frees.

Use after free and double free. C can call wordstat_free twice on the same pointer. The Rust side cannot stop it, because after Box::from_raw it holds no state. If the risk is real, expose handles that are indices into a table, or document it loudly and test with a sanitizer.

Threads. The example is not thread-safe: two C threads feeding one WordStats race. Rust's Send and Sync are meaningless to C. Either document "one thread per handle", or put a Mutex inside and make the handle safe to share.

Layout drift. A #[repr(C)] struct returned by value is part of your binary interface. Adding a field changes its size, and old compiled callers read garbage. Version the library, or pass such structs through opaque accessor functions instead.

Strings that are not UTF-8. C strings are arbitrary bytes. The example rejects invalid UTF-8 with an error code; a library that must accept any bytes should take a pointer and a length (*const u8, usize) and work on &[u8], which also removes the NUL-terminator requirement and allows embedded NULs.

A review checklist for every exported function

Before an exported function is merged, a reviewer should be able to answer yes to each of these, because each one is a way a C caller can hurt the process through a function that looks harmless in Rust.

  • Does it start with null checks on every pointer argument, and return an error code for a null that should not be one?
  • Is the body inside catch_unwind, so a bug becomes a return value and not an abort of the host?
  • Does the doc comment carry a # Safety section naming the lifetime, validity and thread requirements of each pointer?
  • Is every allocation either freed by an exported function in the same library, or avoided by letting the caller own the memory?
  • Does it avoid returning a Rust type with no stable layout: String, Vec, Option, Result, a trait object, a tuple?
  • Is it covered by a C test that ran under a sanitizer, including the error paths and a call with NULL?
  • Is the symbol name prefixed, so it cannot collide with the host application's own names?

A function that passes all seven is boring, and boring is the aim. The adventurous parts of the program, the parsing, the data structures, the concurrency, stay on the Rust side of the line, where the compiler is checking them.

Testing from both sides

Test the Rust logic with ordinary cargo test, and test the border from C. A tiny C test program built in CI and run under valgrind or AddressSanitizer exercises the lifecycle that Rust tests cannot see: create, feed, report, free, and the error paths. Run the same C program against the static and the dynamic build. And run cbindgen in the pipeline with a check that the committed header matches the generated one, so a changed signature in Rust cannot ship without the header changing alongside it.

When this is the wrong approach

If the C application can host a different process, an out-of-process service reached over a socket or a pipe removes the whole border, at the cost of a protocol. If the C++ side wants to use rich Rust types directly, the cxx crate generates safe bindings in both directions, instead of flattening everything to C. If the consumer is Python, Node or Java, use the dedicated binding crates (PyO3 and napi-rs), which handle conversions and error translation for you. Use the cbindgen route when the consumer is C, or any language whose FFI speaks C, and when you want one narrow, stable, auditable surface. For the opposite direction, see calling C from Rust with extern and bindgen.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.