When C needs a handle, not a struct
You are wrapping a C library that expects a handle. The C code calls create_context(), receives a pointer, passes that pointer to do_work(ptr), and finally calls destroy(ptr). C never looks inside the pointer. C just stores it, passes it around, and hands it back when the job is done.
Or you are exposing Rust to C. You have a complex Rust struct with fields that change as you refactor. You want C to hold a reference to that struct, but you don't want C to know the layout. If C sees the fields, C might read stale data, break when you add a field, or violate Rust's invariants.
Opaque pointers solve both problems. They let you pass a blob of memory across the FFI boundary while keeping the concrete type hidden. C sees a void*. Rust sees a *mut c_void. The shape is secret. Only the Rust side knows how to interpret the bytes.
The opaque pointer concept
An opaque pointer is a pointer to a type the receiver does not know. In C, this is void*. In Rust, the equivalent is *mut c_void or *const c_void. The c_void type comes from std::ffi and represents an untyped memory location.
The "opaque" part is the key. You can pass the pointer, store it, compare it, and hand it back. You cannot dereference it. You cannot access fields. You cannot know the size. The pointer is just an address. To do anything useful, you must cast it back to the concrete type, and that cast can only happen where you have full control.
Think of a hotel key card. The card lets you enter a room, but the card itself does not contain the furniture. The hotel management knows the room layout. You just hold the card and use the door. If you try to read the room layout from the card, you get nothing. The card is opaque to you. The hotel has the secret.
Minimal example
Here is the basic pattern. C passes a void*. Rust receives it as *mut c_void. Rust casts it to the real type inside an unsafe block. Rust does the work.
use std::ffi::c_void;
/// Internal data structure hidden from C.
struct SecretData {
value: i32,
}
/// C calls this with a pointer to SecretData.
#[no_mangle]
pub extern "C" fn increment(ptr: *mut c_void) {
// SAFETY: Caller must pass a valid, non-null pointer to a live SecretData instance.
unsafe {
// Cast the opaque pointer back to the concrete type.
// This is safe only if the pointer actually points to SecretData.
let data = &mut *(ptr as *mut SecretData);
data.value += 1;
}
}
The unsafe block is mandatory. If you try to dereference ptr outside the block, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). The compiler forces you to acknowledge that you are taking responsibility for the memory.
Convention aside: keep the unsafe block as small as possible. Cast the pointer at the top, do the work, and let the block end. The community calls this the "minimum unsafe surface" rule. It makes audits easier and reduces the chance of accidental UB.
How the cast works
When ptr arrives, it is a *mut c_void. Rust treats this as a raw address with no type information. The compiler does not know the size, alignment, or layout.
Inside unsafe, you write ptr as *mut SecretData. This tells the compiler, "Trust me. This address points to a SecretData." The compiler accepts the cast. Now you have a typed pointer. You can dereference it with *. You can access value.
If the cast is wrong, the compiler cannot save you. If you cast a *mut c_void that points to an i32 as *mut SecretData, you get a pointer to garbage. Dereferencing it reads random memory. This is undefined behavior. The cast is a contract. You promise the type matches. If you lie, the memory is corrupted.
Realistic lifecycle
Real FFI code manages creation and destruction. C needs to allocate the handle, use it, and free it. Rust uses Box for heap allocation. Box drops the value when it goes out of scope. You cannot return a Box to C, because C does not know how to drop it.
The solution is Box::into_raw. This function takes a Box, leaks the allocation, and returns a raw pointer. The value stays alive on the heap. C holds the pointer. When C is done, it calls a destroy function. Rust uses Box::from_raw to recover the Box. The Box drops, and the memory is freed.
use std::ffi::c_void;
/// Engine state managed by Rust.
struct Engine {
fuel: f32,
active: bool,
}
/// C calls this to create a new engine.
/// Returns an opaque pointer.
#[no_mangle]
pub extern "C" fn engine_create() -> *mut c_void {
// Box allocates on the heap.
// into_raw converts Box to *mut without dropping.
// The allocation is now owned by C until engine_destroy is called.
let engine = Box::new(Engine { fuel: 100.0, active: true });
Box::into_raw(engine) as *mut c_void
}
/// C calls this to refuel the engine.
#[no_mangle]
pub extern "C" fn engine_refuel(ptr: *mut c_void, amount: f32) {
// SAFETY: ptr must be a valid pointer returned by engine_create.
// ptr must not be null.
// ptr must not have been passed to engine_destroy yet.
unsafe {
let engine = &mut *(ptr as *mut Engine);
engine.fuel += amount;
}
}
/// C calls this to destroy the engine and free memory.
#[no_mangle]
pub extern "C" fn engine_destroy(ptr: *mut c_void) {
// SAFETY: ptr must be a valid pointer returned by engine_create.
// ptr must not be null.
// ptr must not have been passed to engine_destroy yet.
unsafe {
// Recover the Box.
// When _engine goes out of scope, the Box drops and frees memory.
let _engine = Box::from_raw(ptr as *mut Engine);
}
}
Convention aside: Box::into_raw is the standard way to transfer ownership to C. Do not use ptr::addr_of or &*box and cast that. Those patterns can cause double frees or use-after-free. into_raw and from_raw are the paired functions designed for this exact lifecycle.
Pitfalls and errors
Opaque pointers are simple, but the mistakes are dangerous.
Wrong cast. If you cast *mut c_void to the wrong type, you get undefined behavior. The compiler might not catch this. If you cast a pointer to Engine as *mut i32, you read the first field as an integer. If the layout differs, you read garbage. Always verify the cast matches the allocation.
Double free. If C calls engine_destroy twice, Box::from_raw creates a Box from the same pointer twice. The first drop frees the memory. The second drop frees it again. This corrupts the heap. The C side must ensure destroy is called exactly once. Rust cannot enforce this across FFI.
Null pointer. C might pass a null pointer. Dereferencing null is undefined behavior. Check for null before casting.
#[no_mangle]
pub extern "C" fn engine_refuel_safe(ptr: *mut c_void, amount: f32) {
// Check for null to avoid UB.
if ptr.is_null() {
return;
}
// SAFETY: ptr is non-null and points to Engine.
unsafe {
let engine = &mut *(ptr as *mut Engine);
engine.fuel += amount;
}
}
Alignment trap. c_void has alignment 1. If you cast *mut c_void to a type with higher alignment, like *mut u128, the pointer value is the same, but the type system loses the alignment guarantee. If the original allocation was not aligned to 16 bytes, dereferencing the u128 pointer is undefined behavior. Most allocators return well-aligned memory, so this is rare in practice. Still, be aware. If you cast back to the original type, you regain the alignment requirement. The risk is if C treats the pointer as char* and does arithmetic, or if you cast to a misaligned type.
ABI stability. Opaque pointers protect your ABI. If you expose the struct fields to C, and you add a field to the Rust struct, the size changes. C might read past the end of the struct. With opaque pointers, C never sees the size. You can add fields, remove fields, or change the layout. C code does not break. This is the main reason to use opaque pointers.
Treat the cast as a contract. If the types do not match, the memory is already corrupted.
Decision matrix
Use opaque pointers when you want to hide Rust's internal struct layout from C code to maintain ABI stability. Use opaque pointers when C manages the lifecycle of a handle and Rust provides the implementation behind the scenes. Use direct *mut T pointers when both Rust and C share the same struct definition and you need field access on the C side. Use Box::into_raw to transfer ownership of a heap allocation to C without dropping the value. Use Box::from_raw to reclaim ownership and free the memory when C is done with the handle. Use *const c_void when the operation does not mutate the Rust state. Use *mut c_void when the operation mutates the Rust state.