Foreign Function Interface (FFI)
FFI and Ownership
Because C has no compile-time ownership tracker, crossing an FFI boundary requires an explicit ownership and allocator contract for every pointer: both sides must agree on whether a pointer is borrowed for the duration of a call or transferred ("moved") to the receiver, and the side that allocates heap memory must always be the side that deallocates it.
Rust-to-C Handle Lifecycle (`Box::into_raw` / `Box::from_raw`):
1. Create: `Box::into_raw(Box::new(Engine::new()))` ---> Returns `*mut Engine` to C
(Rust relinquishes ownership; `Engine` stays alive on Rust heap)
|
v
2. Use: C passes `*mut Engine` back into `engine_process(ptr, ...)`
(Rust temporarily borrows `&mut *ptr` for the duration of the call)
|
v
3. Free: C calls `engine_destroy(ptr)` ---> `unsafe { drop(Box::from_raw(ptr)) }`
(Rust reclaims ownership, runs `Engine::drop`, and frees Rust heap memory!)
Golden Rule of FFI Memory Management: Never Mix Allocators!
| Allocation Origin | Deallocation Function | Why |
|---|---|---|
Rust Box::into_raw(b) |
unsafe { drop(Box::from_raw(ptr)) } |
Rust's global allocator (System, jemalloc, mimalloc, or Windows HeapAlloc) tracks size and alignment layout (core::alloc::Layout). Passing ptr to C's free() is Undefined Behavior! |
Rust CString::into_raw(s) |
unsafe { drop(CString::from_raw(ptr)) } |
CString stores allocation layout metadata incompatible with C free(). |
Rust Arc::into_raw(a) |
unsafe { drop(Arc::from_raw(ptr)) } |
Decrements the atomic reference count and frees only when count reaches 0. |
C malloc / calloc |
C free(ptr) (or library-specific foo_free(ptr)) |
Must be returned to the exact C runtime heap that allocated it. |
Implementing an Opaque Handle Pattern (create, use, destroy)
The cleanest way to expose a complex Rust struct (containing Vec, String, or HashMap fields) to C is the opaque handle pattern: C only ever sees a typed pointer (typedef struct KvEngine KvEngine;), while Rust manages construction, borrowing, and destruction through Box::into_raw and Box::from_raw:
use std::collections::HashMap;
pub struct KvEngine {
entries: HashMap<u32, u64>,
}
/// Allocates a new `KvEngine` on the Rust heap and transfers ownership to C.
#[unsafe(no_mangle)]
pub extern "C" fn kv_engine_create() -> *mut KvEngine {
let engine = Box::new(KvEngine {
entries: HashMap::new(),
});
Box::into_raw(engine)
}
/// Inserts a key-value pair into the borrowed `KvEngine`.
///
/// # Safety
///
/// `engine_ptr` must be a valid, non-null, unaliased pointer returned by
/// `kv_engine_create` that has not yet been passed to `kv_engine_destroy`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn kv_engine_insert(
engine_ptr: Option<&mut KvEngine>,
key: u32,
value: u64,
) -> i32 {
let Some(engine) = engine_ptr else {
return -1;
};
engine.entries.insert(key, value);
0
}
/// Looks up `key` in the borrowed `KvEngine`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn kv_engine_get(
engine_ptr: Option<&KvEngine>,
key: u32,
out_val: Option<&mut u64>,
) -> i32 {
let (Some(engine), Some(out)) = (engine_ptr, out_val) else {
return -1;
};
match engine.entries.get(&key) {
Some(&v) => {
*out = v;
0
}
None => 1, // Not found
}
}
/// Reclaims ownership of `engine_ptr` from C and drops the `KvEngine`.
///
/// # Safety
///
/// `engine_ptr` must either be null or a pointer returned by `kv_engine_create`
/// that has not yet been destroyed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn kv_engine_destroy(engine_ptr: *mut KvEngine) {
if !engine_ptr.is_null() {
// SAFETY: Reconstructs the `Box<KvEngine>` and drops it immediately.
unsafe {
drop(Box::from_raw(engine_ptr));
}
}
}
fn main() {
let handle = kv_engine_create();
assert!(!handle.is_null());
unsafe {
assert_eq!(kv_engine_insert(handle.as_mut(), 42, 9999), 0);
let mut fetched = 0u64;
assert_eq!(kv_engine_get(handle.as_ref(), 42, Some(&mut fetched)), 0);
println!("Fetched from opaque handle: key 42 -> {fetched}");
kv_engine_destroy(handle);
}
}
Fetched from opaque handle: key 42 -> 9999