Home › Compilers & runtimes › Code generation
algorithm medium

FFI bindings generation

Problem A program in one language needs to call a library written in another, and hand-writing the glue for every function and type is tedious and breaks silently when either side changes.

Describe a library's C-ABI surface once (header, attributes or IDL) and generate the declarations, converters and wrappers for each caller language.

generation O(declarations); per call: one indirect call plus argument conversion, O(bytes) for strings and buffers
ffic-abibindgencbindgenuniffipyo3wasm-bindgenswig
Overview

Every language can call C: a function is a symbol, arguments follow a fixed convention, structs a fixed layout. So the C ABI is the common denominator, and a binding generator turns one description of it into each language's glue: declarations, conversions, error mapping, wrappers. The input is a header, attributes on the source, or an IDL.

Key facts

Use when

  • One core is shared by mobile apps, a web front end or Python scripts.
  • A database or game host loads native plugins; firmware calls a vendor C SDK.

Avoid when

  • Both sides are one language.
  • The interface is one function; a hand-written wrapper is shorter.

Deep dive

How it works

Parts:

  • C ABI surface — extern "C" functions, #[repr(C)] structs, plain integers, pointers (calling conventions).
  • Interface description — header, attributes or IDL; the input of the generator (compare IDL code generation).
  • Generator — parser plus a type-mapping table plus templates.
  • Native library — static or shared object, linked or loaded at run time.
  • Foreign-side wrapper — idiomatic classes that convert values, free memory and map errors.
  1. Fix the surface. Export only functions with C-compatible types. extern "C" selects the C calling convention; #[no_mangle] keeps the symbol name.
  2. Fix the layout. #[repr(C)] keeps declaration order with C alignment and padding. Default Rust layout is unspecified: the struct below is 24 bytes as repr(C), 16 in default layout (measured, rustc 1.99).
  3. Decide ownership. Whoever allocates frees, with the same allocator: an opaque handle (Box::into_raw) plus _free; the fields stay private, so layout can change.
  4. Decide strings. C strings end at NUL (no embedded NUL, length by scan). Pointer + length carries any bytes and avoids the scan; prefer it, and treat the result as bytes whose UTF-8 validity is checked on entry.
  5. Stop unwinding. Wrap every exported body in catch_unwind and return an error code. A panic escaping a plain extern "C" function aborts the process (measured); the nomicon says expected unwinding needs a -unwind ABI string and a caller that handles it.
  6. Generate. Header-driven generators parse declarations (ffigen uses LibClang). Source-driven ones read attributes, .udl files or proc-macros and emit both the C surface and the foreign wrapper, so they cannot drift.
  7. Errors and callbacks. Return codes plus out-parameters; callbacks are a function pointer plus opaque user_data.
foreign code --wrapper--> C ABI symbols --> Rust/C++ core
 (Kotlin, Swift,          (handles, ptr+len,   (panics caught,
  Dart, Python, JS)        repr(C) structs)     errors as codes)
        ^ generated from header / attributes / IDL ^

Code

A handle API, a (ptr, len) function, a panic guard and a toy generator that emits a C header and a Rust declaration from a spec table. Edition 2021, std only; compiled and run.

use std::mem::{align_of, offset_of, size_of};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;

#[repr(C)]
pub struct Sample { pub tag: u8, pub value: u32, pub id: u64, pub flag: u8 }

pub struct Bag { items: Vec<i64> } // opaque: C only sees `Bag*`

/// Run `f`; a panic must not unwind into the caller, so map it to an error code.
fn guard(f: impl FnOnce() -> i32) -> i32 {
    catch_unwind(AssertUnwindSafe(f)).unwrap_or(-2)
}

#[no_mangle]
pub extern "C" fn bag_new() -> *mut Bag {
    Box::into_raw(Box::new(Bag { items: Vec::new() })) // caller now owns it
}
#[no_mangle]
pub extern "C" fn bag_push(h: *mut Bag, v: i64) -> i32 {
    guard(|| match unsafe { h.as_mut() } {
        None => -1,
        Some(b) => { b.items.push(v); 0 }
    })
}
#[no_mangle]
pub extern "C" fn bag_get(h: *const Bag, i: usize, out: *mut i64) -> i32 {
    guard(|| match unsafe { h.as_ref() } {
        None => -1,
        Some(b) => { unsafe { *out = b.items[i] }; 0 } // out of range: panic -> -2
    })
}
#[no_mangle]
pub extern "C" fn bag_sum(h: *const Bag) -> i64 {
    unsafe { h.as_ref() }.map_or(0, |b| b.items.iter().sum())
}
#[no_mangle]
pub extern "C" fn bag_free(h: *mut Bag) {
    if !h.is_null() { drop(unsafe { Box::from_raw(h) }); } // free exactly once
}
#[no_mangle]
pub extern "C" fn sum_bytes(p: *const u8, len: usize) -> u64 {
    if p.is_null() { return 0; }
    unsafe { std::slice::from_raw_parts(p, len) }.iter().map(|&b| b as u64).sum()
}

/// (name, [(param, rust type)], return type)
type Spec = (&'static str, &'static [(&'static str, &'static str)], &'static str);

fn c_type(t: &str) -> String {
    match t {
        "i32" => "int32_t".into(), "i64" => "int64_t".into(), "u64" => "uint64_t".into(),
        "usize" => "size_t".into(), "()" => "void".into(),
        "*mut Bag" => "Bag*".into(), "*const Bag" => "const Bag*".into(),
        "*const u8" => "const uint8_t*".into(), "*mut i64" => "int64_t*".into(),
        _ => panic!("no C type for {t}"),
    }
}
fn header(specs: &[Spec]) -> String {
    let mut h = String::from("typedef struct Bag Bag;\n");
    for (name, params, ret) in specs {
        let ps: Vec<String> = params.iter().map(|(n, t)| format!("{} {n}", c_type(t))).collect();
        h += &format!("{} {name}({});\n", c_type(ret), ps.join(", "));
    }
    h
}
fn rust_decl(s: &Spec) -> String {
    let ps: Vec<String> = s.1.iter().map(|(n, t)| format!("{n}: {t}")).collect();
    let ret = if s.2 == "()" { String::new() } else { format!(" -> {}", s.2) };
    format!("fn {}({}){ret};", s.0, ps.join(", "))
}

fn main() {
    // layout: u8 @0, 3 padding, u32 @4, u64 @8, u8 @16, 7 tail padding = 24
    assert_eq!((offset_of!(Sample, tag), offset_of!(Sample, value)), (0, 4));
    assert_eq!((offset_of!(Sample, id), offset_of!(Sample, flag)), (8, 16));
    assert_eq!((size_of::<Sample>(), align_of::<Sample>()), (24, 8));

    let specs: [Spec; 3] = [
        ("bag_new", &[], "*mut Bag"),
        ("bag_push", &[("h", "*mut Bag"), ("v", "i64")], "i32"),
        ("sum_bytes", &[("p", "*const u8"), ("len", "usize")], "u64"),
    ];
    assert_eq!(header(&specs),
        "typedef struct Bag Bag;\nBag* bag_new();\nint32_t bag_push(Bag* h, int64_t v);\n\
         uint64_t sum_bytes(const uint8_t* p, size_t len);\n");
    assert_eq!(rust_decl(&specs[1]), "fn bag_push(h: *mut Bag, v: i64) -> i32;");

    let h = bag_new();
    assert_eq!((bag_push(h, 40), bag_push(h, 2)), (0, 0));
    assert_eq!(bag_sum(h), 42);
    assert_eq!(bag_push(ptr::null_mut(), 1), -1); // null handle
    let mut out = 0i64;
    assert_eq!(bag_get(h, 1, &mut out), 0);
    assert_eq!(out, 2);
    std::panic::set_hook(Box::new(|_| {})); // keep the demo quiet
    assert_eq!(bag_get(h, 99, &mut out), -2); // panic stopped at the boundary
    bag_free(h);
    bag_free(ptr::null_mut());
    let data = [1u8, 2, 3, 250];
    assert_eq!(sum_bytes(data.as_ptr(), data.len()), 256);
    println!("ok");
}

Complexity and constants

Generation is linear in the number of declarations. At run time a call is an indirect call plus conversion: strings and collections are copied or validated, O(bytes); a handle or integer costs nothing extra. Cost drivers: crossings (locks, runtime pins), bytes copied, allocations.

Efficiency

  • Cost: a scalar call is cheap; per-language overhead and per-element conversion dominate. No figure given: measure your pair.
  • Levers:
    • Batch: one call over a buffer instead of N calls — removes N-1 crossings — always when the data is bulk.
    • Pass (ptr, len), not owned copies — no copy — read-only inputs.
    • Opaque handles keep state native — no marshalling of it — stateful objects, caches.
    • Coarse API — one call returning a struct instead of many getters — chatty interfaces.
  • Naive → standard → state of the art: one call per element, string conversions each time · generated wrapper per function · handles plus whole-buffer calls, shared memory when both sides map the same bytes.

Implementation notes

  • Types: i32→int32_t, usize→size_t, *const u8→const uint8_t*; Rust char is a 32-bit scalar, C char a byte.
  • Never expose Vec, String, Option<Box<T>>, trait objects or generics; convert to pointer + length.
  • Async: C has no futures. Use a callback with user_data for completion, or a poll function on a handle. flutter_rust_bridge exposes async fn as Dart futures (its README).
  • Versioning: export lib_abi_version(); never reorder exported struct fields; for growable structs put a size field first. UniFFI is pre-1.0 and warns that advanced uses may break on upgrade.

Pitfalls

  • Freeing with the wrong allocator (a Box released by C free) corrupts the heap.
  • Returning a pointer into a temporary (a CString dropped on return).
  • Double free, use after free: a handle is a raw pointer nobody checks. Null-check it, as the code does.
  • Panic or C++ exception crossing the boundary.
  • Platform ABI differences (long width, struct returns): regenerate per target.

Variants and alternatives

bindgenC headers to Rust declarations; opposite direction: cbindgen — Rust items to a C/C++ header.
UniFFIone Rust core, bindings for Kotlin, Swift, Python and Ruby, with third-party C# and Go (its README).
PyO3 with maturinRust extension modules for Python, built as wheels.
wasm-bindgenJavaScript and Wasm exchange strings, classes and closures over a Wasm module.
ffigen / flutter_rust_bridgeDart from C headers, or from a Rust API (incl. async).
SWIGparses C/C++ interfaces, generates glue for many targets (Python, Java, C#, Go, Lua, R).
cxxchecked Rust/C++ interop when the other side is C++.

In production

  • Mobile: Firefox for Android and iOS use UniFFI-bound Rust (its README); Flutter apps reach a Rust core via flutter_rust_bridge.
  • Scientific Python: Rust extension modules via PyO3, shipped as wheels.
  • Web: Rust compiled to Wasm with wasm-bindgen glue for the browser.
  • Databases: SQLite loads a shared library and calls its sqlite3_extension_init entry point (SQLite docs).
  • Games: C plugin APIs for scripting hosts, SWIG-generated or by hand.
  • Embedded: Rust firmware calling vendor C SDKs through bindgen output (from memory, no single source).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
bindgenRustrust-lang/rust-bindgencargo add --build bindgen————C (some C++) headers to Rust
cbindgenRustmozilla/cbindgencargo install cbindgen————Rust to C/C++11 headers
UniFFIRustmozilla/uniffi-rscargo add uniffi————Kotlin, Swift, Python, Ruby
PyO3 / maturinRustPyO3/pyo3pip install maturin————Python extension modules
wasm-bindgenRustwasm-bindgen/wasm-bindgencargo add wasm-bindgen————Wasm to JavaScript
flutter_rust_bridgeRust, Dartfzyzcjy/flutter_rust_bridgecargo install flutter_rust_bridge_codegen————Flutter/Dart to Rust
ffigenDartdart-lang/nativedart pub add dev:ffigen————C headers to Dart; C only, not C++
SWIGC++swig/swigbrew install swig————many scripting and JVM targets

Sources

  • Rust Project, The Rustonomicon: Foreign Function Interface (panics, -unwind ABIs, nullable pointers). docs
  • The Rust Reference: External blocks (ABI strings). docs
  • UniFFI user guide. docs · code
  • The bindgen User Guide. docs
  • The wasm-bindgen Guide. docs
  • Run-time loadable extensions, SQLite documentation. docs
  • SWIG overview. site

Builds on

calling conventions abi · plannedSystem V, Windows x64 and AAPCS64 ABIs: argument registers, stack frames, struct passing, FFI
idl code generation · plannedGenerate typed client/server stubs and codecs from an interface definition
Reference countingA program shares objects between many owners and must free each one the moment the last owner lets go, with no collector pause and without the programmer tracking who is last.

Used by

Systems (5):

Alternatives and related