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.
- Fix the surface. Export only functions with C-compatible types.
extern "C"selects the C calling convention;#[no_mangle]keeps the symbol name. - Fix the layout.
#[repr(C)]keeps declaration order with C alignment and padding. Default Rust layout is unspecified: the struct below is 24 bytes asrepr(C), 16 in default layout (measured, rustc 1.99). - 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. - 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.
- Stop unwinding. Wrap every exported body in
catch_unwindand return an error code. A panic escaping a plainextern "C"function aborts the process (measured); the nomicon says expected unwinding needs a-unwindABI string and a caller that handles it. - Generate. Header-driven generators parse declarations (ffigen uses LibClang). Source-driven ones read attributes,
.udlfiles or proc-macros and emit both the C surface and the foreign wrapper, so they cannot drift. - 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*; Rustcharis a 32-bit scalar, Cchara 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_datafor completion, or a poll function on a handle. flutter_rust_bridge exposesasync fnas Dart futures (its README). - Versioning: export
lib_abi_version(); never reorder exported struct fields; for growable structs put asizefield first. UniFFI is pre-1.0 and warns that advanced uses may break on upgrade.
Pitfalls
- Freeing with the wrong allocator (a
Boxreleased by Cfree) corrupts the heap. - Returning a pointer into a temporary (a
CStringdropped 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 (
longwidth, struct returns): regenerate per target.
Variants and alternatives
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_initentry 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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| bindgen | Rust | rust-lang/rust-bindgen | cargo add --build bindgen | — | — | — | — | C (some C++) headers to Rust |
| cbindgen | Rust | mozilla/cbindgen | cargo install cbindgen | — | — | — | — | Rust to C/C++11 headers |
| UniFFI | Rust | mozilla/uniffi-rs | cargo add uniffi | — | — | — | — | Kotlin, Swift, Python, Ruby |
| PyO3 / maturin | Rust | PyO3/pyo3 | pip install maturin | — | — | — | — | Python extension modules |
| wasm-bindgen | Rust | wasm-bindgen/wasm-bindgen | cargo add wasm-bindgen | — | — | — | — | Wasm to JavaScript |
| flutter_rust_bridge | Rust, Dart | fzyzcjy/flutter_rust_bridge | cargo install flutter_rust_bridge_codegen | — | — | — | — | Flutter/Dart to Rust |
| ffigen | Dart | dart-lang/native | dart pub add dev:ffigen | — | — | — | — | C headers to Dart; C only, not C++ |
| SWIG | C++ | swig/swig | brew install swig | — | — | — | — | many scripting and JVM targets |
Sources
- Rust Project, The Rustonomicon: Foreign Function Interface (panics,
-unwindABIs, 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