How to Manage Memory Across FFI Boundaries

Use Box and raw pointers to explicitly transfer memory ownership between Rust and foreign languages.

The FFI Boundary is a Cliff

You wrote a Rust function that returns a string to a C program. The C program prints the string. Everything looks perfect. Then you run Valgrind, and it screams about a leak. Or worse, the C program frees the pointer, and your Rust program crashes with a double-free. The boundary between Rust and C is where the borrow checker goes to die. You have to take the wheel.

Rust manages memory through ownership. Every value has one owner, and the compiler inserts cleanup code when the owner goes out of scope. C manages memory manually. You call malloc, you get a pointer, you call free when done. These two systems do not talk to each other. When you cross the FFI boundary, Rust's safety guarantees vanish. You must explicitly transfer ownership using raw pointers. The moment you pass a pointer to C, Rust must forget about the memory. If Rust tries to clean it up later, you get undefined behavior.

Ownership Transfer, Not Copying

Think of Rust's heap allocation like a package in a secure vault. The Box<T> is the key to that vault. C lives outside the vault. You cannot hand the key to C because C does not know how to open the vault or follow Rust's rules. To give C the package, you have to break the vault seal, take the package out, and hand it over. Once you do that, Rust can no longer track the package. From Rust's perspective, the package is lost. In reality, you just transferred ownership.

This transfer is called "leaking" in Rust terminology, which sounds alarming but is exactly the mechanism you need for FFI. You leak the Rust wrapper so the raw data persists on the heap for C to use. You must provide a way for C to give the memory back, or C must free it directly if the allocator is compatible. The community convention is to always provide a free function in your FFI API. C programmers expect a create and destroy pair. If you make them call free() directly, you risk allocator mismatches or layout issues.

Minimal Example: Strings and CString

Passing strings is the most common FFI task. Rust String uses UTF-8 and does not include a null terminator. C expects null-terminated byte strings. If you cast a String to a raw pointer and pass it to C, C will read past the end of the buffer until it finds a zero byte, likely crashing or leaking secrets. You must use CString.

use std::ffi::CString;
use std::os::raw::c_char;

/// Creates a C-compatible string owned by the caller.
/// The caller must free this pointer using `free_string`.
#[no_mangle]
pub extern "C" fn create_string() -> *mut c_char {
    // Create a CString. This allocates on the heap and adds a null terminator.
    // unwrap() is safe here because the literal contains no null bytes.
    let c_str = CString::new("Hello from Rust").unwrap();
    
    // Transfer ownership to C by leaking the Box inside CString.
    // This returns a raw pointer. Rust gives up responsibility for the memory.
    let ptr = c_str.into_raw();
    ptr
}

/// Frees a string created by `create_string`.
/// This reclaims ownership and runs the drop logic.
#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
    // Check for null to handle defensive C callers.
    if !ptr.is_null() {
        // SAFETY:
        // 1. ptr must be non-null and valid.
        // 2. ptr must have been created by create_string.
        // 3. ptr must not be used after this call.
        unsafe {
            // Reconstruct the CString to reclaim ownership.
            // This consumes the pointer and will free the memory when CString drops.
            let _ = CString::from_raw(ptr);
        }
    }
}

The into_raw method consumes the CString and returns a *mut c_char. Crucially, it prevents the Drop implementation from running. The memory stays alive. The free_string function uses CString::from_raw to reconstruct the wrapper. This takes the raw pointer back into Rust's ownership system. When the CString goes out of scope, it frees the memory safely.

Write the free function. If you don't, C will leak.

Walkthrough: The Lifecycle of a Pointer

When create_string runs, CString::new allocates memory on the heap. The allocation holds the bytes H, e, l, l, o, , f, r, o, m, , R, u, s, t, and a null terminator \0. The CString struct holds a pointer to this memory.

Calling into_raw moves the pointer out of the CString struct. The CString is destroyed, but into_raw suppresses the cleanup code. The heap allocation remains. The function returns the raw pointer to C. Rust has no reference to the memory anymore.

C receives the pointer. C can read the string. C can pass it to printf. C owns the memory. If C calls free_string, the pointer goes back to Rust. CString::from_raw wraps the pointer again. Now Rust owns the memory. The CString drops at the end of free_string, calling the allocator to free the heap block.

If C never calls free_string, the memory is leaked forever. If C calls free() directly on the pointer, the memory is freed by C's allocator. This works only if Rust and C use the same global allocator, which is often true but not guaranteed. Using free_string is safer because it ensures Rust's allocator handles the deallocation and respects Rust's type layout.

Realistic Example: Structs and repr(C)

Strings are simple. Real code passes structs. Rust structs can have arbitrary memory layouts. The compiler may reorder fields or add padding for performance. C has no idea about this. If you pass a Rust struct pointer to C, C will read the wrong fields. You must use #[repr(C)] to force Rust to use the C-compatible layout.

use std::os::raw::c_char;

/// A configuration struct with C-compatible memory layout.
#[repr(C)]
pub struct Config {
    pub max_connections: i32,
    pub timeout_ms: i32,
    pub name: *mut c_char,
}

/// Creates a Config struct and transfers ownership to C.
/// Caller must free this with `free_config`.
#[no_mangle]
pub extern "C" fn create_config(max_conn: i32, timeout: i32) -> *mut Config {
    // Allocate a Config on the heap.
    let config = Config {
        max_connections: max_conn,
        timeout_ms: timeout,
        // Initialize name to null for now.
        name: std::ptr::null_mut(),
    };
    
    // Wrap in Box for heap allocation.
    let boxed = Box::new(config);
    
    // Leak the box to transfer ownership.
    // Returns a raw pointer to the Config.
    Box::into_raw(boxed)
}

/// Frees a Config created by `create_config`.
/// Also frees the internal name string if present.
#[no_mangle]
pub extern "C" fn free_config(ptr: *mut Config) {
    if !ptr.is_null() {
        // SAFETY:
        // 1. ptr must be non-null and valid.
        // 2. ptr must have been created by create_config.
        // 3. ptr must not be used after this call.
        unsafe {
            // Reclaim ownership of the Config.
            let mut config = Box::from_raw(ptr);
            
            // If the name string was set, free it too.
            if !config.name.is_null() {
                let _ = CString::from_raw(config.name);
            }
            
            // Config drops here, freeing the struct memory.
        }
    }
}

The #[repr(C)] attribute tells the compiler to lay out the fields exactly as C would. The order is preserved. Padding matches C's rules. Box::into_raw works on any type, not just strings. It leaks the Box and returns a raw pointer. Box::from_raw reconstructs the Box and takes ownership back.

The Config struct contains a *mut c_char. This is a raw pointer to a C string. When freeing the config, you must also free the name string if it exists. This shows nested ownership. The Config owns the name pointer. When you reclaim the Config, you are responsible for cleaning up its fields.

Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have a safe wrapper.

Pitfalls and Errors

FFI memory management is where undefined behavior lives. The compiler cannot check your raw pointers. You must be disciplined.

Null Terminators: Rust String has no null terminator. C functions like strlen and printf scan until they find zero. If you pass a String pointer to C, C reads garbage memory until it hits a random zero byte. This causes crashes or data leaks. Always use CString for text. CString guarantees a null terminator.

Double Free: If C frees a pointer and Rust also drops the wrapper, the allocator receives two free calls for the same address. This corrupts the heap. The program may crash immediately or silently corrupt data. The crash might happen hours later in an unrelated function. Always ensure only one side frees the memory. If you use into_raw, Rust forgets the memory. If you use Box::from_raw, Rust takes it back. Never hold both.

Use After Free: If C frees a pointer and then reads it, or if Rust drops a value and C still uses the pointer, you access freed memory. The allocator may reuse that memory for something else. C reads the new data, thinking it's the old data. This is a classic vulnerability. Document the lifetime clearly. The pointer is valid from creation until the free function is called.

Dereferencing Raw Pointers: If you try to dereference a raw pointer in safe Rust, the compiler rejects you with E0133 (dereference of raw pointer requires unsafe). You must wrap the dereference in an unsafe block. This is a compile-time guard. It forces you to acknowledge the risk.

repr(C) on Structs: Forgetting #[repr(C)] on a struct passed to C causes field misalignment. C reads the wrong bytes for each field. The values are garbage. This is hard to debug because the code compiles and runs, but the data is wrong. Always use #[repr(C)] for FFI structs.

Allocator Mismatch: If C calls free() on a pointer allocated by Rust, it works only if both use the same global allocator. On Linux, this is usually true. On Windows, or with custom allocators, it may fail. Providing a free function in Rust avoids this risk entirely.

Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Isolate the unsafe blocks. Keep them small.

Decision Matrix

Choose the right tool based on the data type and ownership flow.

Use Box::into_raw when you need to transfer ownership of a generic Rust value to C and plan to reclaim it later with Box::from_raw. This works for any type, including structs and enums.

Use CString::into_raw when you are passing text to C code that expects a null-terminated char*. This handles the null terminator and ensures C-compatible encoding.

Use Vec::into_raw_parts when C needs to mutate a buffer in place and you want to reconstruct the Vec without copying. This gives C a pointer, length, and capacity. You can rebuild the Vec with Vec::from_raw_parts.

Use std::mem::forget only when you are intentionally leaking memory and never intend to reclaim it, such as in a long-lived cache or a FFI handle that C will manage entirely. Prefer into_raw methods when available, as they are more explicit about the intent.

Use #[repr(C)] on every struct and enum that crosses the FFI boundary. Without this attribute, memory layout is undefined for C.

Trust the borrow checker inside Rust. Once you cross the boundary, you are the borrow checker.

Where to go next