Skip to content
rusthacks.dev
Systems

Ownership Across FFI in Rust Who Frees the Memory Explained

Memory must be freed by the allocator that created it. At an FFI boundary that means every allocation travels with a matching free function on the same side.

4 min read
Summarize in AI
Flat illustration for the post “Ownership Across FFI in Rust Who Frees the Memory Explained”, composed around embedded.

Memory bugs at an FFI boundary are rarely about the languages. They are about nobody agreeing who is responsible. The rule that settles most questions is short: whoever allocates, frees, using the same allocator. Examples compile on Rust 1.90, edition 2024.

Why allocators must match

An allocator keeps hidden bookkeeping next to each block, and a free implementation expects its own format. Rust's default allocator on many platforms is the system malloc, but you can replace it with jemalloc or mimalloc, and on Windows the C runtime and the Rust runtime can each own a heap. Passing a pointer to the wrong free corrupts the heap, sometimes silently and much later (custom allocators).

Rust gives C an owned value

rust
use std::ffi::{c_char, CString};

pub struct Config {
    pub retries: u32,
}

#[unsafe(no_mangle)]
pub extern "C" fn config_new() -> *mut Config {
    Box::into_raw(Box::new(Config { retries: 3 }))
}

#[unsafe(no_mangle)]
pub extern "C" fn config_free(p: *mut Config) {
    if !p.is_null() {
        // SAFETY: p came from config_new and has not been freed
        drop(unsafe { Box::from_raw(p) });
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn config_describe(p: *const Config) -> *mut c_char {
    if p.is_null() {
        return std::ptr::null_mut();
    }
    // SAFETY: p is non-null and points to a live Config per the API contract
    let c = unsafe { &*p };
    match CString::new(format!("retries={}", c.retries)) {
        Ok(s) => s.into_raw(),
        Err(_) => std::ptr::null_mut(),
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn string_free(s: *mut c_char) {
    if !s.is_null() {
        // SAFETY: s came from CString::into_raw in this library
        drop(unsafe { CString::from_raw(s) });
    }
}

Each constructor has a destructor, and the string function names its partner in documentation. A C caller that calls free(str) instead of string_free(str) is the bug to prevent with comments and tests.

C gives Rust an owned value

rust
use std::ffi::c_void;

unsafe extern "C" {
    fn make_buffer(len: usize) -> *mut u8;   // returns malloc'd memory
    fn free_buffer(p: *mut u8);               // the library's own free
}

pub struct Buffer {
    ptr: *mut u8,
    len: usize,
}

impl Buffer {
    pub fn new(len: usize) -> Option<Self> {
        // SAFETY: plain call, returns null on failure
        let ptr = unsafe { make_buffer(len) };
        if ptr.is_null() { None } else { Some(Self { ptr, len }) }
    }

    pub fn as_slice(&self) -> &[u8] {
        // SAFETY: ptr is valid for len bytes until Drop
        unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
    }
}

impl Drop for Buffer {
    fn drop(&mut self) {
        // SAFETY: ptr came from make_buffer and is freed once
        unsafe { free_buffer(self.ptr) };
    }
}

Never convert such a pointer into a Vec or Box with from_raw_parts. Rust would later free it with its allocator. Keep the foreign pointer in a type whose Drop calls the foreign free. Do not call std::mem::forget on guard types to "hand off" without recording who frees.

Borrowed pointers

A function like const char *config_name(const config *) returns a pointer into the object. Document it as borrowed, valid until the object is freed or modified. In Rust, wrap it as &'a CStr tied to the owner's lifetime:

rust
pub fn name(&self) -> &std::ffi::CStr {
    // SAFETY: the library guarantees a NUL terminated string valid as long as self
    unsafe { std::ffi::CStr::from_ptr(config_name(self.raw)) }
}

The returned reference borrows &self, so the compiler prevents it outliving the object.

Common mistakes

| Mistake | Result | |---|---| | free() on a Box::into_raw pointer | heap corruption | | Box::from_raw on a malloc pointer | allocator mismatch | | Double free (calling destroy twice) | undefined behavior | | Using a pointer after destroy | use after free | | Returning a pointer to a local variable | dangling | | Forgetting the free | leak, found by sanitizers |

Use AddressSanitizer or Valgrind on the combined program, and Miri for the Rust half (sanitizers).

Design checklist

  1. Name every pointer parameter owned or borrowed in the docs.
  2. Keep constructor and destructor adjacent in the header.
  3. Offer opaque handles, not structs with owned fields.
  4. In Rust wrappers, put the free in Drop.

Next steps: callbacks, panics across FFI and cbindgen exports.

Taggedffi
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.