Systems Programming · Unsafe Code

Ownership Across the FFI Boundary and Who Frees What

Two runtimes with two allocators meet at a foreign function call, and every pointer that crosses must have an unambiguous owner, layout, and deallocation routine.

A foreign function call is a place where two languages agree on a calling convention and on nothing else. Each side has its own allocator, its own idea of what a string is, and its own rules about when memory may be released. The pointers that cross the boundary are typed by one side and reinterpreted by the other, and the question that decides whether the program works is not “what are the bytes” but “who owns them and who will free them”.

Getting this wrong does not usually produce a crash at the call site. It produces a free through the wrong allocator, which corrupts the heap at a later point and in an unrelated function, or a double free when both sides believe they own the same block.

The allocator is not a global resource

malloc and free in a C library share a bookkeeping structure: a free must be passed to the same allocator that produced the pointer, with the same size class metadata. A pointer allocated by one allocator and released by another is undefined behaviour even if both are malloc-compatible in isolation, because each maintains its own arena and its own free lists.

A language runtime that manages memory with its own allocator makes this concrete. Rust’s Box, Vec, and String allocate through the global allocator, which on a typical Unix build is the system allocator but is a substitution point and may be a different implementation. Passing a Box::into_raw pointer to C and having C call free on it is a violation unless the global allocator is known to be the same malloc and the layout matches. Passing a C malloc pointer into Box::from_raw is the same violation in the other direction.

The sound patterns are correspondingly narrow. Either one side allocates and the other only borrows, with the allocating side also freeing, or the allocation and the deallocation are both expressed as calls into the owning side’s API. A C library that allocates a structure and returns it must also expose a destructor, and the foreign caller must use that destructor rather than its own free.

Layout is part of the contract

Two structures with the same fields in the same order are not necessarily laid out the same way. C’s rules for structure layout are implementation-defined in the details: padding between members is inserted to satisfy each member’s alignment, tail padding may be added to satisfy the structure’s alignment, and bitfields are laid out in an order the standard leaves to the implementation.

Rust’s repr(C) asks for the C layout algorithm, and it is the only representation that may be passed across the boundary for a structure with more than one field. The default repr(Rust) makes no layout guarantee at all: the compiler may reorder fields to minimise padding, and it does so in practice. A structure declared without repr(C) and passed to a C function by value will be passed with the wrong offsets.

#[repr(C)]
pub struct Header {
    pub kind: u32,
    pub flags: u32,
    pub length: u64,
    pub payload: *mut u8,
}

With repr(C), the two u32 fields occupy the first eight bytes, length is at offset 8, and payload at offset 16 on a 64-bit target, for a total size of 24 bytes with no tail padding. That is the layout a C compiler produces for the equivalent declaration, and it is the layout the ABI’s classification rules will use when deciding whether to pass the structure in registers.

The tail-padding case is worth singling out because it crosses languages differently. A C structure with a 4-byte tail pad passed by value may be classified as two eightbytes, and the callee reads the padding bytes as part of the value. If the caller’s language does not guarantee those bytes are initialised, the callee may observe indeterminate data. Rust’s repr(C) structures have the same padding, and copying one by value copies it. Passing such a structure across the boundary requires the caller to have initialised the padding or to have established that the callee does not read it, which is rarely knowable.

Strings, and the three representations

A C string is a pointer to bytes terminated by a zero byte. A Rust String is a pointer, a length, and a capacity, and may contain interior zero bytes. A Rust &str is a pointer and a length, is not necessarily terminated, and carries a UTF-8 validity invariant.

Converting between them requires a copy in at least one direction. CString allocates a buffer with a trailing zero and can be produced only from a value with no interior zero, returning an error otherwise. CStr borrows a terminated buffer and validates on conversion to &str. Neither conversion is free, and the copy is the price of the different invariants.

The lifetime question is where the copies cause bugs. A pointer returned from a C function that points into a static buffer is valid until the next call to that function; wrapping it in a Rust type that does not carry that constraint produces a reference that may dangle after the next call. A pointer to a buffer the C library expects the caller to free must be converted into an owned Rust type at the boundary, with the freeing done by the library’s destructor, not by Rust’s allocator.

Interior zeros are the other trap. A &[u8] containing a zero byte passed to a function that takes a C string is truncated at the first zero, and the truncation is silent. Functions that accept a pointer and a length avoid the problem; functions that accept only a pointer do not, and the caller must have validated the absence of zeros.

Callbacks and the lifetime of a function pointer

A C library that accepts a callback takes a function pointer and, usually, an opaque context pointer. The context pointer’s type is void * in C and *mut c_void at the boundary, and the library will pass it back unchanged.

The context pointer is the standard route for carrying Rust state into a C callback, and it has two failure modes. The first is lifetime: the C library may retain the pointer after the call that registered it, and the Rust object it points at may have been dropped. The registration API’s documentation must state whether the pointer is borrowed for the duration of the call or retained, and the wrapper must encode that in the type. A retained pointer means the object must be leaked or kept alive by an explicit handle whose destructor deregisters the callback first.

The second is unwinding. A Rust panic that crosses into a C frame is undefined behaviour, because the C code has no landing pads and no knowledge of the unwind tables. The boundary must catch panics and convert them into an error code, which means the callback body is wrapped in a catch and the C-visible signature returns a status. The alternative, aborting on panic, is a legitimate policy and is what a panic strategy of abort enforces globally.

The reverse direction is equally constrained: a C function that calls back into Rust and then longjmps out of it skips the Rust frames’ destructors. A library that uses setjmp and longjmp for error handling cannot safely call into code that owns resources, and the wrapper must either not call such a library or must ensure no Rust destructor is pending across the jump.

Ownership transfer as an API design choice

There are four transfer shapes, and each needs a different declaration.

Caller allocates, callee fills. The caller owns the buffer and the callee writes into it under a stated length. The length must be passed in and out, and the callee must report the required size when the buffer is too small. Without the out-parameter, the callee either truncates silently or overruns.

Callee allocates, callee frees. The callee returns a pointer and exposes a destructor. The caller must not free it. This is the shape of most C library object APIs, and the destructor call must be paired with every constructor on every path, including error paths.

Callee allocates, caller frees. This requires the callee’s allocator to be the caller’s, or the callee to expose a free function. The pattern is common in libraries that document “the caller must free the returned pointer with free”, and it is only sound when both sides genuinely use the same allocator.

Caller allocates, caller frees, callee borrows. The simplest and the one with the fewest failure modes. The callee must not retain the pointer, and the documentation must say so.

The declaration should name the shape explicitly, because the pointer type is identical in all four. A C header that returns char * without saying who frees it is a defect that no amount of care at the call site can repair, and a wrapper that guesses wrong will either leak every call or free memory it does not own. The aliasing rules that govern the borrow case are treated in Aliasing Rules for References and the Role of UnsafeCell, and the initialisation requirements for buffers the callee fills are treated in Uninitialised Memory, Padding, and the Cost of Assuming Zero.

References

  1. The Rustonomicon: FFI — Rust project
  2. std::ffi module documentation — Rust project
  3. POSIX.1-2017, malloc, free, and realloc — IEEE / The Open Group
  4. System V Application Binary Interface, x86-64 Processor Supplement — AMD64 ABI specification