Mehdi Akiki
Published on

A Rust FFI Boundary Is More Than repr(C)

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article ยท Through the layers

#[repr(C)] solves one part of Rust FFI: it gives a struct a C-compatible field layout for the target ABI. It does not validate pointers, define who frees memory, stop panics, preserve lifetimes, make Rust enums universally safe, or keep two independently built libraries compatible forever.

When I review an FFI boundary, I treat it as a small protocol. Every exported function needs a representation contract, an ownership contract, a failure contract, and tests compiled from both languages.

This is more work than adding extern "C". It is also what keeps one unsafe edge from weakening the complete Rust application.

What extern "C" and repr(C) actually do

These annotations answer different questions:

#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

pub unsafe extern "C" fn consume(point: *const Point) {
    // ...
}
  • extern "C" selects the C calling convention for the function.
  • #[repr(C)] selects C-compatible layout rules for Point.
  • unsafe tells Rust callers that they must uphold conditions the function cannot verify completely.

None of these prove that point is non-null, aligned, initialized, still alive, or safe to read. Those are validity and lifetime requirements.

repr(C) is also not recursively magical. Every field must itself have a representation the other language understands. A #[repr(C)] struct containing String, Vec<T>, &str, a trait object, or a default-layout Rust enum does not become a stable C type.

The Rust Reference documents the exact type-layout guarantees. I use that document as the contract, not one size_of result from my laptop.

Keep the boundary types boring

I prefer a small vocabulary:

  • fixed-width integers such as u32 matched with uint32_t;
  • size_t matched with usize on the same target ABI;
  • raw pointers plus explicit lengths;
  • opaque handles;
  • simple #[repr(C)] structs containing only FFI-safe fields;
  • integer status codes;
  • nullable function pointers where their layout is guaranteed.

I avoid exporting Rust-specific ownership and error types directly:

Rust typeWhy I do not expose it directlyBoundary representation
StringRust allocator, capacity and UTF-8 ownershippointer + length, or copy function
Vec<T>Rust-owned allocation and layout contractpointer + length, opaque handle
&strfat pointer and Rust lifetimeconst uint8_t* + length
Result<T, E>no C ABI representationstatus code + out parameter
boolC boolean conventions varyuint8_t with documented 0/1 values
charRust char is a Unicode scalar, not C charuint32_t or encoded bytes
data-carrying enumdefault layout is not a C contracttag + union, or opaque API
trait objectRust vtable ABI is not stable C ABIexplicit function table

The boundary converts boring C values into strong Rust types after validation.

A complete small API

This example exposes an opaque counter. C never reads its fields, and only Rust allocates or frees it.

use std::mem::size_of;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;

pub struct Counter {
    total: i64,
}

pub const COUNTER_OK: i32 = 0;
pub const COUNTER_NULL: i32 = 1;
pub const COUNTER_LENGTH: i32 = 2;
pub const COUNTER_PANIC: i32 = 3;

#[unsafe(no_mangle)]
/// # Safety
/// `out` must point to writable, aligned storage for one `*mut Counter`.
pub unsafe extern "C" fn counter_new(out: *mut *mut Counter) -> i32 {
    if out.is_null() {
        return COUNTER_NULL;
    }

    match catch_unwind(|| Box::new(Counter { total: 0 })) {
        Ok(counter) => {
            // SAFETY: `out` was checked for null. The caller's contract must
            // still guarantee writable, aligned storage for one pointer.
            unsafe { ptr::write(out, Box::into_raw(counter)) };
            COUNTER_OK
        }
        Err(_) => COUNTER_PANIC,
    }
}

/// # Safety
/// `counter` must be a live handle returned by `counter_new` and not freed.
/// If `len > 0`, `values` must point to `len` initialized `i32` values.
/// `out_total` must point to writable, aligned storage for one `i64`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_add(
    counter: *mut Counter,
    values: *const i32,
    len: usize,
    out_total: *mut i64,
) -> i32 {
    if counter.is_null() || out_total.is_null() {
        return COUNTER_NULL;
    }
    if len > 0 && values.is_null() {
        return COUNTER_NULL;
    }
    if len > isize::MAX as usize / size_of::<i32>() {
        return COUNTER_LENGTH;
    }

    let result = catch_unwind(AssertUnwindSafe(|| {
        // SAFETY: the remaining pointer, initialization, aliasing and lifetime
        // conditions are required by this function's public safety contract.
        let counter = unsafe { &mut *counter };
        let values: &[i32] = if len == 0 {
            &[]
        } else {
            unsafe { std::slice::from_raw_parts(values, len) }
        };

        for value in values {
            counter.total = counter.total.saturating_add(i64::from(*value));
        }
        unsafe { ptr::write(out_total, counter.total) };
    }));

    if result.is_ok() { COUNTER_OK } else { COUNTER_PANIC }
}

/// # Safety
/// `counter` must be null or a live handle returned by `counter_new` that has
/// not already been passed to `counter_free`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn counter_free(counter: *mut Counter) {
    if !counter.is_null() {
        drop(unsafe { Box::from_raw(counter) });
    }
}

Rust 2024 requires unsafe attributes such as no_mangle to be written as #[unsafe(no_mangle)]. The exported symbol itself is part of the safety review: duplicate or unexpected symbol names can break linking assumptions.

The C header makes the other half of the contract explicit:

#ifndef COUNTER_H
#define COUNTER_H

#include <stddef.h>
#include <stdint.h>

typedef struct Counter Counter;

enum {
    COUNTER_OK = 0,
    COUNTER_NULL = 1,
    COUNTER_LENGTH = 2,
    COUNTER_PANIC = 3
};

int32_t counter_new(Counter **out);
int32_t counter_add(
    Counter *counter,
    const int32_t *values,
    size_t len,
    int64_t *out_total
);
void counter_free(Counter *counter);

#endif

The opaque typedef prevents C from depending on Rust field layout. I can change the internal Counter without changing the ABI.

The C harness matters

I compile and run a consumer written in C:

#include "counter.h"
#include <assert.h>

int main(void) {
    Counter *counter = NULL;
    assert(counter_new(&counter) == COUNTER_OK);
    assert(counter != NULL);

    int32_t values[] = {10, -3, 8};
    int64_t total = 0;
    assert(counter_add(counter, values, 3, &total) == COUNTER_OK);
    assert(total == 15);

    assert(counter_add(counter, NULL, 0, &total) == COUNTER_OK);
    assert(counter_add(counter, NULL, 1, &total) == COUNTER_NULL);

    counter_free(counter);
    return 0;
}

A typical CI flow builds the Rust crate as a cdylib, compiles this file with the platform C compiler, links it against the produced library, and runs it. This catches symbol, calling-convention, header, and linker mistakes that a Rust-only unit test cannot see.

I run the harness on every supported architecture and operating system. C ABI details such as long size differ between LP64 and LLP64 targets, which is why I prefer fixed-width integers in the public header.

Pointer checks have limits

Checking is_null() is useful, but it does not prove that a non-null pointer is valid. Rust cannot generally verify that foreign memory:

  • is aligned for T;
  • covers the requested length;
  • contains initialized valid values;
  • lives for the duration of the call;
  • is not concurrently mutated;
  • does not alias a Rust mutable reference.

This is why an exported raw-pointer function can remain unsafe from Rust's point of view even when it returns errors for common bad inputs.

I keep the unsafe block small and write a # Safety contract that states what the caller must guarantee. Validation then narrows the remaining assumptions before safe domain code receives the data.

The 2024 edition's unsafe_op_in_unsafe_fn discipline helps: an unsafe function still uses explicit unsafe blocks for each operation, making the proof boundary visible.

Zero-length slices are a small trap

slice::from_raw_parts still requires a non-null, aligned pointer even when length is zero. C APIs commonly allow (NULL, 0).

In the example I handle zero length separately and return &[]. I do not pass the null pointer to from_raw_parts.

I also check that len * size_of::<T>() fits within isize::MAX, one of the documented slice requirements. This does not validate the allocation; it only rejects impossible slice sizes early.

Ownership must cross in one direction at a time

I use naming and API shape to show ownership:

borrowed input:       const pointer + length, valid only for the call
mutable borrowed:     mutable pointer + length, exclusive for the call
Rust-owned handle:    created by Rust, destroyed by one Rust free function
caller-owned output:  caller provides buffer and capacity
transferred buffer:   explicit pointer/length/capacity plus matching destructor

Memory allocated by Rust should normally be freed by Rust. Allocator choice and layout can differ across modules or runtimes. A Box::into_raw pointer is returned exactly once to Box::from_raw; double free or use after free remains undefined behaviour.

For variable output, I often use a two-call API: first ask for required length, then fill a caller-owned buffer. It avoids transferring allocator ownership across the boundary.

Strings need encoding and length rules

const char * is not enough specification. I declare:

  • UTF-8, another encoding, or uninterpreted bytes;
  • NUL-terminated or explicit length;
  • whether embedded NUL is allowed;
  • borrowed or owned memory;
  • maximum accepted length;
  • behaviour for invalid encoding.

CStr requires a terminating NUL and no earlier assumptions about arbitrary memory. str::from_utf8 validates explicit UTF-8 bytes. I convert only after the pointer and length contract is established.

I never expose a Rust char as a C char: Rust's type represents a Unicode scalar value and has a different validity model.

Panics and foreign exceptions need a policy

A panic must not accidentally unwind through a C ABI boundary that does not permit unwinding. With extern "C", a Rust panic reaching the boundary aborts safely under the documented ABI behaviour. If process abort is unacceptable, I use catch_unwind at the exported entry and translate the result into a status code.

catch_unwind is not a general exception mechanism. It catches unwinding Rust panics, not panic=abort, and foreign-exception interactions have strict limits. The Rustonomicon's FFI and unwinding guide documents when C-unwind is required.

I also make destructors called from FFI unable to panic through the boundary. Error messages use a separate retrieval API or caller buffer with documented lifetime, rather than a pointer to a temporary Rust string.

Callbacks add thread and lifetime contracts

A C callback may be invoked:

  • synchronously before the registration call returns;
  • later on a foreign thread;
  • more than once;
  • during shutdown;
  • concurrently with unregister.

The signature does not communicate these rules. I document them and pass an opaque user-data pointer with an explicit lifetime.

If the callback can arrive from another thread, captured Rust state needs Send/synchronization guarantees enforced by the safe wrapper. Unregistering must define when no more callbacks can happen before user data is freed.

I use Option<extern "C" fn(...)> for nullable callbacks only where Rust documents the nullable-function-pointer representation.

ABI versioning belongs in the first design

Once other programs link to a library, field and symbol changes become compatibility decisions.

I prefer opaque handles and functions, but sometimes a configuration struct is useful. Then I add size and version:

typedef struct {
    uint32_t abi_version;
    size_t struct_size;
    uint32_t flags;
    void *reserved;
} counter_config_v1;

The Rust side reads only fields covered by struct_size, validates the supported version, requires reserved fields to be zero, and copies values instead of retaining a borrowed stack pointer.

I do not reorder or change the meaning of existing fields. New entry points can carry version suffixes when symbol compatibility matters.

My ABI checklist

Before release, I verify:

  • every exported function has an explicit ABI and stable symbol name;
  • every exposed value has documented layout on every supported target;
  • no default-layout Rust type leaks into the header;
  • pointer nullability, alignment, length, aliasing, and lifetime are written down;
  • ownership transfer and the matching destructor are unambiguous;
  • integer widths, boolean values, string encoding, and enum values are fixed;
  • panics and foreign exceptions cannot cross the wrong boundary;
  • callbacks define threads, concurrency, reentrancy, and unregister completion;
  • status codes preserve useful error categories without leaking secrets;
  • header and library versions match;
  • a real C harness builds, links, and executes in CI;
  • sanitizers run on the C side and platform matrix;
  • Rust unit/property tests cover conversion code;
  • unsafe blocks explain the invariants they rely on.

Miri can help test Rust unsafe code, but it cannot model an arbitrary real C library and native ABI. I combine it with C/C++ sanitizers, integration harnesses, and target-specific CI.

Microsoft's pragmatic Rust guidance recommends keeping the FFI crate as a translation layer so interop concerns do not spread through the core domain; see its FFI guidelines. The Rustonomicon also documents alternative representations and which nullable pointer cases have guarantees.

The boundary I want

My safe application code should never receive an unchecked foreign pointer. The FFI module receives boring C values, validates what can be validated, documents what the caller must guarantee, converts once, and calls a normal Rust API.

repr(C) makes selected bytes predictable. The rest of FFI safety comes from protocol design: ownership, validity, failure, unwinding, threading, versioning, and tests from both sides.