Introduction
ezffi is a procedural-macro crate that turns ordinary Rust types and functions into C-FFI bindings, without any boilerplate and with a focus on performance, keeping 0% overhead though the FFI layer when theoretically possible.
A small taste:
#![allow(unused)] fn main() { #[ezffi::export] pub struct Point { pub x: f64, pub y: f64 } #[ezffi::export] impl Point { pub fn new(x: f64, y: f64) -> Self { Self { x, y } } pub fn distance(&self, other: &Point) -> f64 { ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt() } } }
produces, on the C side:
typedef struct MyCratePoint { double x; double y; } MyCratePoint;
MyCratePoint my_crate_point_new(double x, double y);
double my_crate_point_distance(const MyCratePoint *this_, const MyCratePoint *other);
That's the visible surface. Underneath, ezffi is solving a handful of problems:
- Layout decisions — which Rust types can cross as
#[repr(C)]and which need an opaque pointer wrapper. - Async —
ezffiallows you to export async functions and exposes logic to define the async dispatcher of your choice. - Ownership —
Box-ed types go behind their generated FFI struct, with a matching_freefunction so C can drop them. - Rust std types — Rust
Option<T>,Result<T, E>,Vec<T>,Arc<T>,Rc<T>,HashMap<K, V>,String, etc. are types that you can use in your exported functions without having to worry about implementation details. - Cross-crate — a library can export its types and functions so Rust, C, Swift and any other language can consume them. You can also write your own library using
ezffion top of that library and use those types in your exported functions without any extra configuration.
This library has a heavy focus un areas like HPC and Scientific software, where comunicating Rust with other languages so performance is really important for me and archiving 0% performance overhead is one of my goals (thought it may be immposible in some sceneraios). Check the benchmarks chapter for more informacion on how the bench are organized and executed and check BENCH_RESULTS.md on the root of the repo to see in witch sncenarios you can get that 0% overhead and in which ones you have to pay for the FFI layer, so you can make the right choice for your project.
Disclaimer: this library is in the early stages of development. It contains all the features I personally need for my own projects and professional work, and that can change (I'll try not to). Feel free to use it and contribute if you'd like to get features in or fix issues you're hitting. The base I've been working on is solid and lets me keep building on top of it. Hope you like it and that it helps the community write better and safer code, taking advantage of Rust's features.
Getting Started
This chapter wires ezffi into a fresh crate end to end — your first exported function and the C program that calls it. By the end you'll have a working Counter library callable from C.
Dependencies
ezffi ships the proc macro and some Rust types ready to be export-compatible; cbindgen reads the macro-generated extern "C" items and produces the C header. If you wanna use any other header generator feel free, but cbindgen is the one I'm using in my tests until I write my own solution if I see it's needed.
[package]
name = "counter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["lib", "staticlib"] # or `cdylib`, it depends on your use case
[dependencies]
ezffi = "0.1"
[build-dependencies]
cbindgen = "0.29"
The library
src/lib.rs:
#![allow(unused)] fn main() { #[ezffi::export] pub struct Counter { value: u64 } #[ezffi::export] impl Counter { pub fn new() -> Self { Self { value: 0 } } pub fn increment(&mut self) { self.value += 1; } pub fn value(&self) -> u64 { self.value } } }
Wiring cbindgen
The header generation is a really important step. ezffi generates its headers in target/<profile>/include/ezffi so it's really important to generate yours in the same include dir, so we end up with the following structure:
include
├── ezffi
│ ├── ezffi.h
│ ├── slice.h
│ ├── ...
│ └── string.h
└── counter
└── counter.h
The recommended build.rs using cbindgen is the following:
build.rs:
use std::env; use std::path::Path; fn main() { // cbindgen cannot expand macros in stable builds // unless you set this env var unsafe { std::env::set_var("RUSTC_BOOTSTRAP", "counter") }; let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let out_dir = env::var("OUT_DIR").unwrap(); let pkg_name = env::var("CARGO_PKG_NAME").unwrap(); println!("cargo:rerun-if-changed=src/"); println!("cargo:rerun-if-changed=cbindgen.toml"); // `OUT_DIR` is `target/<profile>/build/<crate>-<hash>/out` let target_dir = Path::new(&out_dir) .ancestors() .nth(3) .expect("Failed to find target dir"); let include_dir = target_dir.join("include").join(&pkg_name); std::fs::create_dir_all(&include_dir).unwrap(); let config = cbindgen::Config::from_file(Path::new(&crate_dir) .join("cbindgen.toml")) .expect("Failed to read cbindgen.toml"); cbindgen::Builder::new() .with_crate(&crate_dir) .with_config(config) .generate() .expect("Unable to generate bindings") .write_to_file(include_dir.join(format!("{pkg_name}.h"))); }
cbindgen.toml:
language = "C"
# Add here the relative .h path of any dependency you are
# using from `ezffi` or from other lib crates that expose
# an `ezffi` interface (e.g.: ezffi/string.h).
# In this case we don't use any of the `ezffi` exposed types
# so it is empty.
include = []
[parse.expand]
crates = ["counter"]
The generated header
After cargo build, target/debug/include/counter/counter.h looks roughly like:
#include <stdint.h>
typedef struct CounterCounter { uint64_t value; } CounterCounter;
CounterCounter counter_counter_new(void);
void counter_counter_increment(CounterCounter *this_);
uint64_t counter_counter_value(const CounterCounter *this_);
I talk more about why this is the header output, how it works internally and the rules I apply when creating C-compatible wrappers (and teach you what to expect from your code) in chapter #[ezffi::export].
The C side
main.c:
#include "counter/counter.h"
#include <stdio.h>
int main(void) {
CounterCounter c = counter_counter_new();
counter_counter_increment(&c);
counter_counter_increment(&c);
counter_counter_increment(&c);
printf("%llu\n", (unsigned long long)counter_counter_value(&c));
return 0;
}
Compile and run:
cargo build --release
gcc main.c \
-I target/release/include \
-L target/release \
-l counter \
-o counter_demo
./counter_demo
# 3
Depending on your binary you may also need other libraries to be linked like -lpthread -ldl -lm, otherwise you'll get a compile-time error warning you of missing symbols (e.g. pow if you don't link m but some dependency uses it).
And that's it — you got your first C-compatible library and a bin consumer without having to worry about any implementation details.
The macro
In this chapter we'll explore where you can use the #[ezffi::export] macro and the code it generates.
Structs
C-compatible structs
If you have a struct where all the fields are C-compatible, #[ezffi::export] will rename it and mark it with #[repr(C)], and provide a type alias so you can keep using the original name in your code. E.g.:
#[ezffi::export]
pub struct Counter {
num: u64
}
will generate
#[derive(Clone, Copy)]
pub struct LibNameCounter {
num: u64
}
pub type Counter = LibNameCounter;
// These are the `ezffi` traits that define how to go from
// a Rust-type to a C-type and the other way around. For
// C-compatible structs they're no-op and only exist to
// simplify the wrapper generation. The compiler should
// optimize them out.
impl ::ezffi::RustRefIntoC<()> for LibNameCounter {...} // This one copies the value though
impl ::ezffi::RustOwnedIntoC<()> for LibNameCounter {...}
impl ::ezffi::CRefIntoRust<LibNameCounter> for LibNameCounter {...}
impl ::ezffi::COwnedIntoRust<LibNameCounter> for LibNameCounter {...}
With this approach ezffi can ensure max performance on the C side, avoiding the generic solution of a c_void pointer to a heap-allocated struct.
Non C-compatible structs
This will be the most common case for users that don't need that extra performance and want to use all kind of Rust types in their exported structs, like Rc. E.g.:
#[ezffi::export]
pub struct Screen {
header: Rc<Widget>
}
will generate
pub struct Screen {
header: Rc<Widget>
}
#[derive(Clone, Copy)]
#[repr(C)]
pub struct LibNameScreen {
inner: *mut core::ffi::c_void
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn libname_screen_free(o: *const LibNameScreen) {
let _ = unsafe { Box::from_raw((*o).inner as *mut Screen) };
}
// In this case the traits Box the Rust-type and
// know how to cast from c_void to the actual Rust-type.
impl ::ezffi::RustRefIntoC<()> for Screen {...}
impl ::ezffi::RustOwnedIntoC<()> for Screen {...}
impl ::ezffi::CRefIntoRust<T> for LibNameScreen {...}
impl ::ezffi::COwnedIntoRust<T> for LibNameScreen {...}
Generic structs
These are a little trickier and I personally avoid exposing them. E.g.:
pub struct Wrapper<T> {
value: T,
}
will generate
pub struct Wrapper<T> {
value: T,
}
#[derive(Clone, Copy)]
#[repr(C)]
pub struct LibNameWrapper {
inner: *mut core::ffi::c_void,
drop_fn: unsafe extern "C" fn(*mut core::ffi::c_void), // Per-monomorphization drop
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn libname_wrapper_free(o: *const LibNameWrapper) {
unsafe { ((*o).drop_fn)((*o).inner); }
}
// Same `ezffi` conversion traits as before.
Structs with Lifetimes
Not yet implemented but should be easy to do.
Enums
C-compatible enums
C-compatible enums are those that have all their variants as unit variants, and they receive the same treatment as the C-compatible structs. Your enum will be renamed and marked as #[repr(C)], and a type alias will be declared so you can use the name you gave to it.
Non C-compatible
Non C-compatible enums are those that, well, aren't C-compatible, and they receive the same treatment as non C-compatible structs. A struct will be created containing a pointer to the heap-allocated enum.
Generic enums and enums with lifetimes
Not yet implemented. I felt lazy and honestly, I didn't have a use case for it. Feel free to do the implementation yourself if you'd like to collaborate and/or need it.
Functions
This section is a short and easy one, but I'll split it in two.
Functions as God intended them to be
All functions that receive and return C-compatible types or exported Rust-types (ergo they have a generated C-type) can be marked with #[ezffi::export]. E.g.:
#[ezffi::export]
pub fn distance(a: Point, b: Point) -> f64 {...}
generates
pub fn distance(a: Point, b: Point) -> f64 {...}
pub unsafe extern "C" fn libname_distance(a: LibNamePoint, b: LibNamePoint) -> f64 {
use ezffi::RustRefIntoC;
use ezffi::RustOwnedIntoC;
use ezffi::CRefIntoRust;
use ezffi::COwnedIntoRust;
// Here is where the `ezffi` traits are used
// to convert C-types into Rust-types.
let a = &*a;
let a = a.into_rust();
let b = &*b;
let b = b.into_rust();
// Now we can call the Rust function without any problem.
let result = distance(a, b);
result.owned_into_c()
}
Async functions
These are pretty easy. They need the async feature to be enabled, and the macro will generate almost the same code as before — only the Rust function call will change, and look like let result = ::ezffi::dispatch_async(distance(a, b));. This dispatch_async function is defined in ezffi only if the feature is enabled, and hands the future to the configured dispatcher. By default ezffi will run pollster::block_on(fut) for every async function, but you can plug in any dispatcher you want, it's up to you.
The recommended way to change the dispatcher is the following:
static TOKIO_RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
// We export this function to set the desired async dispatcher
// from the C side before calling any async function.
#[ezffi::export]
pub fn init_tokio() {
TOKIO_RT
.set(
tokio::runtime::Builder::new_multi_thread()
.enable_time()
.build()
.unwrap(),
)
.ok();
ezffi::set_async_dispatcher(|fut| {
TOKIO_RT.get().unwrap().block_on(fut);
});
}
The C side would look something like
#include "libname/libname.h"
#include <assert.h>
int main() {
libname_init_tokio();
// Now we can execute our async functions using tokio.
return 0;
}
Impl Blocks
This is also a trivial implementation: any exported Rust-type can export its methods, we saw an example of this in the Getting Started section. The only difference with regular functions is that the Self type gets resolved to the actual type, and the Rust method calls are done using the full path: Point::add(this, other);.
Async methods are also supported and will be dispatched using the same logic as regular functions.
Conversion traits
ezffi defines four traits that describe how to translate a value between its Rust-type and its C-type representation. #[ezffi::export] implements them automatically for every type it touches, so most users never see them but they are the heart of this project. They are public so you can implement them by hand when you need a specific layout for your types that the macro doesn't produce.
#![allow(unused)] fn main() { pub trait RustRefIntoC<T> { type C: CRefIntoRust<Self>; unsafe fn ref_into_c(&self) -> Self::C; } pub trait RustOwnedIntoC<T>: Sized { type C: COwnedIntoRust<Self>; unsafe fn owned_into_c(self) -> Self::C; } pub trait CRefIntoRust<T: ?Sized> { unsafe fn into_rust(&self) -> &T; unsafe fn into_rust_mut(&mut self) -> &mut T; } pub trait COwnedIntoRust<T> { unsafe fn into_rust_owned(self) -> T; } }
What each one does
The four traits come in two pairs, splitting the conversion by direction and ownership:
| Trait | Direction | Ownership |
|---|---|---|
RustRefIntoC | Rust → C | borrowed (&T → C-type) |
RustOwnedIntoC | Rust → C | owned (T → C-type, moves into C) |
CRefIntoRust | C → Rust | borrowed (C-type → &T) |
COwnedIntoRust | C → Rust | owned (C-type → T, moves into Rust) |
The conversion flow
So, how am I using these traits?? Basically, when you want to export a Rust fn, the macro creates a wrapper that receives and returns the C-types of the Rust-types used in your Rust fn, and, depending on the type of argument/return, the wrapper picks the trait that makes the desired conversion, it will be easier to understand with an example.
Let's suppose we want to wrap pub fn hire(user: &mut User, company: &Company, salary: u64, idk: Idk) -> &Contract, the macro would generate something similar to the following wrapper:
pub extern "C" fn c_hire(user: *mut CUser, company: *const CCompany, salary: u64, idk: CIdk) -> *const CContract {
use ezffi::RustRefIntoC;
use ezffi::RustOwnedIntoC;
use ezffi::CRefIntoRust;
use ezffi::COwnedIntoRust;
// Bcs User is a mut ref, the macro receives a pointer and
// asks for the rust mut ref using the right trait.
let user = &mut *user;
let user = user.into_rust_mut();
// Here almost the same, but this time we want an immutable ref
let company = &*company;
let company = company.into_rust();
// Idk passes by value so we consume it and obtain an owned
// Rust-type that will be dropped at the end of this function.
let idk = idk.into_rust_owned();
// For simplicity, primitive types also apply the trait
let salary = salary.into_rust_owned();
// Now that we have the Rust-types we can call the Rust function
let result = hire(user, company, salary, idk);
// And, since the function returns a reference to something, we
// convert that reference into a reference to the right C-type
// by borrowing the data. Dont free borrowed data!!!
result.ref_into_c();
}
If you pay attention, the way this library achieves the 0% overhead is by providing 0-cost conversion when code is compiled so this entire wrapper gets replaced with the Rust call.
Why two pairs (ref vs owned)
This is pretty simple to answer, some types only make sense as a reference (eg: slices), so it doesn't make sense to implement the owned logic for them, it may even be impossible
Implementing them by hand
The macro decides layout automatically: C-compatible Rust-types stay as #[repr(C)] values; everything else is wrapped in an opaque *mut c_void handle (see the macro chapter). That covers most cases, but sometimes you want a different shape, a custom inline struct, a tagged-union, a pointer with extra metadata, a borrowing form that aliases an existing buffer, etc. In that case you take over the four impls yourself.
The pattern is always the same: declare your C-type, then write the four impls so they round-trip, write a free function if you allocate data, and you no longer need the #[ezffi::export] on your type to be used in an exported function.
#![allow(unused)] fn main() { // Suppose we want a custom C-type for `String` that exposes // pointer + length without going through an opaque handle. #[repr(C)] #[derive(Clone, Copy)] pub struct MyStr { ptr: *const u8, len: usize, } impl ::ezffi::RustRefIntoC<()> for String { type C = MyStr; unsafe fn ref_into_c(&self) -> MyStr { MyStr { ptr: self.as_ptr(), len: self.len() } } } impl ::ezffi::CRefIntoRust<String> for MyStr { unsafe fn into_rust(&self) -> &String { // Up to you how to reconstruct the borrow safely unimplemented!() } unsafe fn into_rust_mut(&mut self) -> &mut String { unimplemented!() } } // And the owned pair if you also need to move strings across. }
One rule of thumb:
- Mind the ownership semantics. If
owned_into_cheap-allocates,into_rust_ownedmust consume that allocation. Ifref_into_caliases borrowed memory,into_rustmust not extend its lifetime past the original borrow.
If you find yourself reaching for a custom impl frequently, that's usually a sign the macro is missing a feature, open an issue, ty.
Strings, str and Slices
This chapter will cover the Rust String type that crosses to the C-side as EzffiString, and the two fat-pointer types ezffi ships by default: EzffiStr (&str) and EzffiSlice (&[T]). Both fat pointers point to borrowed data, so they don't have a free function, and they're always passed by value. EzffiString and EzffiStr are defined in the ezffi/string.h header, and EzffiSlice in ezffi/slice.h.
String
Rust Strings are wrapped the same way non C-compatible structs are wrapped (a c_void pointer). This may change in the future in favor of a more performant solution. What makes String special is that ezffi makes it easy to work with it using C-like strings, by exporting the following functions in the ezffi/string.h header:
void ezffi_string_free(const struct EzffiString *o);
struct EzffiString ezffi_string_new(void);
struct EzffiString ezffi_string_with_capacity(uintptr_t capacity);
struct EzffiString ezffi_string_from(struct EzffiStr s);
uintptr_t ezffi_string_len(const struct EzffiString *this_);
bool ezffi_string_is_empty(const struct EzffiString *this_);
uintptr_t ezffi_string_capacity(const struct EzffiString *this_);
void ezffi_string_clear(struct EzffiString *this_);
void ezffi_string_reserve(struct EzffiString *this_, uintptr_t additional);
void ezffi_string_truncate(struct EzffiString *this_, uintptr_t new_len);
void ezffi_string_shrink_to_fit(struct EzffiString *this_);
void ezffi_string_push_str(struct EzffiString *this_, struct EzffiStr s);
void ezffi_string_insert_str(struct EzffiString *this_, uintptr_t idx, struct EzffiStr s);
bool ezffi_string_eq_str(const struct EzffiString *s, struct EzffiStr other);
EzffiStr
This is the key that makes Rust String easy to work with using C strings. EzffiStr is the C-type for &str, it's defined in ezffi/string.h, and its definition looks like:
typedef struct EzffiStr {
uintptr_t len;
uint8_t *ptr;
} EzffiStr;
and can be built in the C-side using the two following macros:
// Note that the first macro uses sizeof() to get the length and the second one
// uses strlen(), stopping in the first \0 byte it founds
#define EZFFI_STR(lit) ((EzffiStr){ .ptr = (uint8_t*)(lit), .len = sizeof(lit) - 1 })
#define EZFFI_CSTR(cstr) ((EzffiStr){ .ptr = (uint8_t*)(cstr), .len = strlen(cstr) })
It's important to remember: if EzffiStr is built on the C-side and you allocated heap memory for the bytes it points to, you must ensure that memory is deallocated using the C free() function once EzffiStr is no longer in use. The struct points to borrowed data — it doesn't own it.
EzffiSlice
Almost the same concept as EzffiStr but with a slightly different definition, and defined in ezffi/slice.h:
typedef struct EzffiSlice {
void *ptr;
uintptr_t len;
} EzffiSlice;
EzffiSlice is the C-type for any &[T]. It points to borrowed data so it doesn't have a free function, and it's the user's responsibility to ensure the data lives at least as long as the EzffiSlice. The user can manipulate the data using pointer arithmetic and casting the void* to the contained type. It's the user's job to ensure the cast is valid.
EzffiSlice can also be built on the C-side using the two following macros:
// Again, the difference is in how the len is being obtained
#define EZFFI_SLICE(p, n) ((EzffiSlice){ .ptr = (void*)(p), .len = (n) })
#define EZFFI_SLICE_FROM_ARRAY(arr) ((EzffiSlice){ .ptr = (void*)(arr), .len = sizeof(arr) / sizeof((arr)[0]) })
Option and Result
This chapter will cover the EzffiOption and EzffiResult wrappers. They're the C-types for the already-known Rust-types Option<R> and Result<T, E>, and are included in the ezffi/ezffi.h header.
ezffi ships with macros that allow the user to extract T from an Option and a Result. Don't be stupid: check if the Result is Ok and the Option is Some before unwrapping. Their definitions are:
#define EZFFI_OPTION_UNWRAP(Type, opt_ptr) ({ Type __ez_v; ezffi_option_unwrap((opt_ptr), &__ez_v); __ez_v; })
#define EZFFI_RESULT_UNWRAP(Type, res_ptr) ({ Type __ez_v; ezffi_result_unwrap((res_ptr), &__ez_v); __ez_v; })
and their usage is:
T value = EZFFI_OPTION_UNWRAP(T, opt_ptr);
T value = EZFFI_RESULT_UNWRAP(T, res_ptr);
I also provide the following functions:
// Helpers that allow the user to easily check if the wrappers
// contain any value.
bool ezffi_option_is_some(const struct EzffiOption *o);
bool ezffi_result_is_ok(const struct EzffiResult *o);
// In the case of `Result` I have this function to extract the code.
int32_t ezffi_result_error_code(const struct EzffiResult *o);
// You can also get a pointer to the error message. NULL if there
// is no error. This lives as long as EzffiResult lives.
const char *ezffi_result_error_message(const struct EzffiResult *o);
// These functions can be used to extract the generic type, but I
// recommend the macros, they're easier to read and use these
// functions internally.
//
// This functions also free the structs allocated data, so you don't need
// to call the free function after using them or the macros
void ezffi_option_unwrap(const struct EzffiOption *o, void *out);
void ezffi_result_unwrap(const struct EzffiResult *o, void *out);
// Call this function to free the allocated memory in case you didn't
// unwrap and want to ignore the returned value
void ezffi_option_free(const struct EzffiOption *o);
void ezffi_result_free(const struct EzffiResult *o);
To be able to use the EzffiResult struct, the Error type your Rust Result contains must implement From<Error> for EzffiError, From<EzffiError> for Error, and Clone. The first lets ezffi set the C-side code and message; the second lets it reconstruct an Err(Error) when C passes a Result back into Rust; Clone covers the by-ref shapes and the pre-computation of code / message at construction time. You can use as many Error types as you want but always ensure each of them has a different error code. An example would be:
#[derive(Clone)]
pub enum Error {
IOError,
SqlxError,
ReqwestError,
}
impl From<Error> for EzffiError {
fn from(e: Error) -> EzffiError {
EzffiError::new(e.code(), e.to_string())
}
}
impl From<EzffiError> for Error {
fn from(e: EzffiError) -> Error {
match e.code() {
1 => Error::IOError,
2 => Error::SqlxError,
_ => Error::ReqwestError,
}
}
}
Rust std library
If you enable the std feature, ezffi will generate some extra headers, ezffi/std/*.h. Each header defines a C-type for its respective Rust-type, enabling the user to export functions with #[ezffi::export] that receive or return those types. All the C-types for Rust std types are defined the same way non C-compatible struct C-types are, even if they are C-compatible — this has to do with me being unable to mark Rust types as #[repr(C)]. This may change in the future if I find a way to implement zero-cost export for any Rust-type. Also, only a few Rust types are being exported right now. The list is the following:
ArcBTreeMapBTreeSetHashMapHashSetRcVecVecDeque
Cross-Crate
Probably the feature I like the most about ezffi.
This chapter will cover the scenario where crate B depends on crate A, both use ezffi, and B wants a function that takes a type A exports, without re-declaring A's types or duplicating the C definitions.
TypeMap, the logic that makes this possible
The ezffi macro uses a shared registry that lives at target/ezffi-typemap.toml (one per workspace, shared across all profiles and across cbindgen parse-expand sub-builds). Each crate writes its own section when its macros expand, and reads every other crate's section when resolving foreign types. The ezffi-typemap.toml looks like:
[dependency_crate] # Crate A
codegen_version = 1
BigInteger = { ffi = "DependencyCrateBigInteger", c_compatible = false }
[your_crate] # Crate B
codegen_version = 1
User = { ffi = "YourCrateUser", c_compatible = false }
Color = { ffi = "YourCrateColor", c_compatible = true }
When crate B writes &dependency_crate::BigInteger in an exported signature, the macro looks up BigInteger in the typemap, finds it lives in dependency_crate, and emits *const dependency_crate::DependencyCrateBigInteger on the C side. B should update their cbindgen.toml includes field to add dependency_crate/dependency_crate.h, and the type is reused.
The file gets a per-section lock so concurrent macro expansions across the workspace don't stomp on each other.
Codegen failures at compile time
Every typemap section records the codegen version that produced it. Currently we are on codegen version 1, and I hope I don't have to change it. When you upgrade ezffi and one of your dependencies is still on an older codegen, the macro refuses to read its section and won't compile. The error tells you which crate is out of sync and which ezffi version fixes it, copy-pasteable into your Cargo.toml. If ezffi changes the codegen version, a new major release will happen.
Making your library compatible with the ezffi cross-crate feature
There's nothing extra to opt into, using #[ezffi::export] already registers your types in the typemap. To make consumers' lives easier, and your crate predictable, I'd like to define a set of rules:
- Optionally hide the
ezfficompatibility behind anezffifeature, this will help your library performance for Rust consumers, see Benchmarks for more info. - If you hide
ezffibehind a feature remember to addfeatures = ["ezffi"]to yourcbindgen.toml - Re-export the original Rust-types at your crate root (
pub use structs::*;). The downstream crate then writesuse dep::User; … &User …instead of digging into module paths.ezffiwill also try to usedep::Userto resolve the type. - Make sure your generated header lives at
target/<profile>/include/<your_crate>/<your_crate>.h(the recommendedbuild.rsfrom Getting Started already does this). That's the path the consumer'scbindgen.tomlwill reference in itsincludeslist. Feel free to split your exported symbols into different headers but always under your crate's folder, the same wayezffiheaders are generated. - Pin a codegen-compatible
ezffirange in yourCargo.toml. Consumers will pick a range that overlaps with yours; if you start emitting a new codegen version, mention it in your CHANGELOG. It's also recommended to mention the codegen yourezffiversion is emitting in the section where you talk aboutezffisupport in your README, book, etc.
Configuration
You can create an ezffi.toml sitting next to your crate's Cargo.toml (workspace configuration not supported) to control the names the macro emits — prefixes, suffixes and case styles for types, methods, free functions and standalone functions. The defaults give you <CrateName><Type> for types and <crate_name>_<fn> for free functions, which is collision-safe by default.
Here's an example file with all the fields declared, set to their default values for a crate named counter:
# `case_style` valid values: "SnakeCase", "CamelCase", "PascalCase",
# "ScreamingSnakeCase", "Raw"
[types]
prefix: "Counter", # CounterTypeName
suffix: null, # String
case_style: CamelCase,
[methods] # Outputs: typename_methodname
prefix: null, # String
suffix: null, # String
case_style: SnakeCase,
[fns] # Outputs: counter_fnname
prefix: "counter_", # String
suffix: null, # String
case_style: SnakeCase,
[free_fns] # Outputs: typename_free
prefix: null, # String
suffix: "_free", # String
case_style: SnakeCase,
Benchmarks
ezffi's design goal is that calling a Rust function through its generated FFI binding should cost the same as calling it directly from Rust when theoretically possible. The benches/ directory is where all the benches I currently have live. There's still a lot left to bench and plenty of performance improvements to make, but so far the macro is doing really well, 0% overhead on every bench I have right now. In this chapter we'll explore how the tests are structured and the results I obtained.
See BENCH_RESULTS.md at the root of the repo for the latest numbers, it's periodically updated.
How each bench is structured
Each bench is a small crate under benches/<name>/ with a fixed layout:
benches/<name>/
├── Cargo.toml — `lib` + `staticlib`, plus a `native` Rust bin target
├── build.rs — runs cbindgen (only when `--features ezffi` is on)
├── cbindgen.toml — header generation config
├── src/
│ ├── lib.rs — the function/type under test, gated with
│ │ `#[cfg_attr(feature = "ezffi", ezffi::export)]`
│ └── bin.rs — the Rust "native" runner
├── c/runner.c — the C runner
└── DESCRIPTION.md — one-line summary embedded into the results doc
The whole point is the same Rust source runs three ways:
| label | how it's built | what it measures |
|---|---|---|
rust | cargo build --profile bench-release | pure-Rust baseline, no macro expansion |
rust+ezffi | cargo build --profile bench-release --features ezffi | pure-Rust baseline, with macro expansion |
c | C linked against rust+ezffi staticlib via clang -O3 -flto=thin | same crate called from a C binary across the FFI |
Each runner reads three env vars (ITERATIONS, NUMA, NUMB) at startup so values aren't constant-folded, runs a warmup pass, then times the measured loop with Instant::now() / clock_gettime. A side-channel acc accumulator is printed after the loop — Rust and C must agree byte-for-byte on it, that's the semantic sanity check across the boundary.
For workloads where rustc would otherwise solve the loop in closed form (any purely-linear acc += i + b), a single black_box(i) per iteration is enough to keep each iteration opaque to the optimiser without distorting what's measured.
Here is a quick snippet of the profile config I'm using:
[profile.bench-release]
inherits = "release"
lto = "thin"
Running them
From the repo root:
benches/run.sh
The first time this builds a small Docker image (benches/Dockerfile — clang + rust-lld from rustup so the LLVM versions line up for cross-language LTO). After that it just runs. The host's repo is bind-mounted into the container, so source edits are picked up without rebuilds.
ITERATIONS, NUMA, NUMB can be overridden from the environment if you want different inputs. Default is 1_000_000_000 iterations with arbitrary constants.
When the script finishes it overwrites BENCH_RESULTS.md at the repo root with the latest numbers plus the host's toolchain block. That file is meant to be committed alongside releases.
Adding a new bench
Copy any of the existing dirs (add-fn is the simplest), rename, edit src/lib.rs with the function or type you want to measure, mirror it in src/bin.rs and c/runner.c, and write a one-line DESCRIPTION.md. The workspace glob (benches/*) and run.sh's directory loop pick it up automatically on the next run.
Bench results summary
So far, I have 0% overhead on every single bench when calling from C. The only case where I've seen heavy overhead is in native Rust with the macro active, and oddly enough, that overhead disappears once the C binary links against the same crate (cross-language ThinLTO recovers the missed optimisation). I'll be investigating that in more depth and adding more tests to catch the cases where it shows up.
Terminology
Exported item
An exported item is one marked with the #[ezffi::export] macro.
Rust and C types
This book calls Rust-type the types defined in Rust by the user and marked with the #[ezffi::export] macro, and C-type the types created by the macro that are used as parameters in the exported functions.
C-compatible
In this book, a C-compatible item is one that has an equivalent in C by default. Currently the C-compatible items this crate cares about are:
- Primitives such as
i32,u64, etc. extern "C"function pointers.
A Rust-type or C-type will also be called C-compatible if all of its fields are C-compatible, that includes other C-compatible Rust-types and C-types. It's also important to note that the C-type and the Rust-type are internally the same type if they are C-compatible: ezffi just uses a type alias to let you, the ezffi user, keep referring to it by the original name you gave to your struct.
Future Work
In this chapter I'll mention work I'd like to implement one day. Feel free to implement any of it if you want — after notifying me so we can discuss the implementation details I have in mind, keep track of your work and help you if you allow me to :)
Header Generation
Right now ezffi uses cbindgen, but I personally don't like it for my use case in this crate (it's a really good tool but not the best fit for the project I'm building here). The reasons are the following:
- To generate multiple headers depending on the enabled feature I have to make a mess: I need hidden features per each header I want to generate, and it is far from ideal.
- It increases the amount of boilerplate users have to write in their
build.rsand forces me to explain why they should generate their header in thetarget/<profile>/includefolder I talk about in the Getting Started. - It requires a flag in the
build.rsto be able to expand macros on stable toolchains.
Lang wrappers generation
It would be nice to attach some metadata to each exported item to later generate quick wrappers in other languages. I have an idea of how this would work and how to allow people to write their own generator, given a framework written by the ezffi maintainers that reads the already-mentioned metadata and shares it with the language-specific implementation.