Introduction
Rust and C have fundamentally different memory management models. Rust uses ownership and RAII; C uses manual malloc/free. When passing heap-allocated data across the FFI boundary, you must ensure the correct allocator frees the memory. The golden rule: memory should be freed by the same allocator that created it.
Key Concepts
Box::into_raw: Consumes aBox<T>and returns a raw pointer, preventing Rust from running the destructor.Box::from_raw: Reconstructs aBox<T>from a raw pointer, allowing Rust to drop it.- Opaque handle pattern: Expose complex Rust types to C as opaque pointers with create/use/destroy functions.
- Allocator mismatch: Freeing memory with the wrong allocator (e.g., Rust-allocated memory with C's
free()) is undefined behavior.
Real World Context
Every Rust library that returns heap data to C — strings, structs, buffers — must solve the ownership problem. Libraries like rusqlite, ring, and game engines use the opaque handle pattern extensively.
Deep Dive
The Problem: Allocator Mismatch
Rust and C may use different allocators. Freeing Rust memory with C's free() is undefined behavior:
rust// BAD: Who frees this? C might use free(), but Rust used its own allocator! #[unsafe(no_mangle)] pub extern "C" fn get_data() -> *mut u8 { let data = vec![1u8, 2, 3]; Box::into_raw(data.into_boxed_slice()) as *mut u8 // Caller has no way to correctly free this! }
The Solution: Paired Create/Free Functions
Always provide a Rust-side free function:
rust#[unsafe(no_mangle)] pub extern "C" fn create_buffer(size: usize) -> *mut u8 { let buffer = vec![0u8; size].into_boxed_slice(); Box::into_raw(buffer) as *mut u8 } #[unsafe(no_mangle)] pub extern "C" fn free_buffer(ptr: *mut u8, size: usize) { if !ptr.is_null() { // SAFETY: ptr was created by create_buffer with the given size unsafe { let _ = Box::from_raw(std::slice::from_raw_parts_mut(ptr, size)); } } }
The C side calls create_buffer to allocate and free_buffer to deallocate. Both use Rust's allocator.
The Opaque Handle Pattern
For complex Rust types, expose them as opaque pointers:
rustpub struct Database { connection: String, pool_size: usize, } #[unsafe(no_mangle)] pub extern "C" fn db_open(conn: *const libc::c_char) -> *mut Database { let conn_str = unsafe { // SAFETY: caller provides valid C string std::ffi::CStr::from_ptr(conn).to_str().unwrap() }; Box::into_raw(Box::new(Database { connection: conn_str.to_string(), pool_size: 10, })) } #[unsafe(no_mangle)] pub extern "C" fn db_close(db: *mut Database) { if !db.is_null() { // SAFETY: db was created by db_open via Box::into_raw unsafe { drop(Box::from_raw(db)); } } }
The C header declares the type as opaque:
ctypedef struct Database Database; Database* db_open(const char* conn); void db_close(Database* db);
C code can only use the pointer through the provided API — it cannot access the struct fields.
Common Pitfalls
- Calling
free()on Rust-allocated memory — This is UB. Always provide a Rust-side deallocation function. - Forgetting
Box::from_rawreconstructs ownership — AfterBox::from_raw, theBoxwill drop the value. Do not use the raw pointer afterward. - Double-freeing opaque handles — Document clearly that the C side must call the destroy function exactly once. Set the pointer to NULL after freeing.
Best Practices
- Always pair create/destroy functions — For every function that returns an owned pointer, provide a matching free function.
- Use the opaque handle pattern — Never expose Rust struct internals to C. Always use opaque pointers.
- Null-check every pointer — Defensive null checks in your free functions prevent crashes from double-frees.
Summary
- Memory must be freed by the same allocator that created it.
- Use
Box::into_rawto transfer ownership to C andBox::from_rawto reclaim it. - The opaque handle pattern exposes Rust types to C as opaque pointers.
- Always provide paired create/destroy functions.
- Null-check every pointer in free functions to prevent crashes.
Code Examples
use std::os::raw::c_char;
use std::ffi::CStr;
use std::collections::HashMap;
pub struct Config {
values: HashMap<String, String>,
}
pub type ConfigHandle = *mut Config;
#[unsafe(no_mangle)]
pub extern "C" fn config_new() -> ConfigHandle {
Box::into_raw(Box::new(Config {
values: HashMap::new(),
}))
}
#[unsafe(no_mangle)]
pub extern "C" fn config_set(
handle: ConfigHandle,
key: *const c_char,
value: *const c_char,
) -> bool {
if handle.is_null() || key.is_null() || value.is_null() {
return false;
}
// SAFETY: handle is valid (from config_new), key/value are valid C strings
let config = unsafe { &mut *handle };
let key = unsafe { CStr::from_ptr(key).to_str().ok() };
let value = unsafe { CStr::from_ptr(value).to_str().ok() };
match (key, value) {
(Some(k), Some(v)) => {
config.values.insert(k.to_string(), v.to_string());
true
}
_ => false,
}
}
#[unsafe(no_mangle)]
pub extern "C" fn config_free(handle: ConfigHandle) {
if !handle.is_null() {
// SAFETY: handle was created by config_new
unsafe { drop(Box::from_raw(handle)); }
}
}