Jump to content

The Rust FFI Sandwich: Keep C Alive While You Migrate Risk-Free

From JOHNWICK

Ship Rust safely without a “big bang” rewrite — layer it between C you trust and APIs your team already uses.

A tidy three-layer sandwich showing Rust API on top, an FFI boundary in the middle, and a C engine as the base. Two ugly truths stalled our rewrite.

  • The C library made money every day.
  • The Rust rewrite didn’t — yet.

We kept the C core running, slid Rust in as a safety layer, and migrated feature-by-feature. Crashes fell; throughput rose. No Friday night rollbacks. A Rewrite We Didn’t Ship (and Why the Sandwich Won) Story. We had a money-making C library in production, a half-done Rust clone in staging, and deadlines. Every time we flipped traffic to Rust, edge-case parsing broke in markets we didn’t test. Engineers fought the code; finance watched the burn. Insight. Don’t replace revenue code. Wrap it. Rust becomes a safety kernel and new home for critical APIs, while C keeps doing the proven heavy lifting underneath. How-to.

  • Keep the C shared/static library as the source of truth for now.
  • Publish a thin Rust safe API your app calls. Rust translates to C via extern "C".
  • Move logic into Rust only when it’s proven with differential tests.
  • Flip callers from old C headers to the new Rust crate one endpoint at a time.

Counterpoint. “But we’ll end up with two codebases.” Temporarily, yes. You also end up with uptime. What “FFI Sandwich” Means (And Why It’s Boring on Purpose) Story. The turning point was when we stopped trying to be clever. We drew three boxes on a whiteboard and promised not to cross the lines. Insight. A sandwich is just C API → Rust boundary → Safe Rust → (optional) C. Rust owns correctness and memory hygiene at the edges; C keeps its mature algorithms. How-to.

  • Top bread (callers): App calls a safe Rust API. No raw pointers escape.
  • Filling: An FFI module with #[no_mangle] extern "C" or extern "C" calls out, using only repr(C) types.
  • Bottom bread: C library you already ship. Stable ABI, boring headers.

ASCII map. App (safe) → Rust public API → [FFI shim] → C lib (proven)

                             ↘ optional: call back into Rust as you port

Counterpoint. “Why not just bind C straight to the app?” Because you want one place to enforce invariants and lifetimes. That’s Rust. Choose a Direction: Rust-on-C vs C-on-Rust Story. We debated for a week. Our hot path lived in C; our new parsing and validation belonged in Rust. Direction decided itself. Insight. If C has the proven algorithms, do Rust-on-C (Rust calls C). If Rust is the new engine and C is glue, do C-on-Rust (C calls Rust). Pick one and stick to it per subsystem. How-to.

  • Rust-on-C when: you trust C’s implementation; you need safety and tests at the edges.
  • C-on-Rust when: you’re introducing a new core algorithm in Rust; C is the legacy host.
  • Keep one FFI surface per direction in a module like ffi/, not scattered function by function.

Counterpoint. Mixing both directions in one module doubles your mental load. Segment by package. The Only Types Across the Boundary Story. The biggest bugs weren’t logic; they were type mismatches and ownership leaks. We standardized the ABI and bugs stopped “mysteriously” appearing. Insight. FFI is a contract. Use C ABI types only: pointers, usize/size_t, fixed-width integers, opaque handles. No Rust panics across FFI. No Rust drop semantics implied. How-to.

  • Mark all structs crossing FFI as #[repr(C)].
  • Pass buffers as (ptr, len); define who frees.
  • Return error codes, not Rust Result; map to errno-like constants.
  • Strings: prefer (ptr, len) UTF-8; avoid NUL-terminated unless you must interop with existing C APIs.

Counterpoint. Yes, CString/CStr are handy. They still need explicit ownership rules. Write them down. A Minimal, Safe Pattern (One Out Param, One Code) Story. Our first FFI function leaked. We changed the pattern once and never looked back. Insight. Return status as an i32; write results via out-pointers; never allocate on behalf of the caller unless you also expose a free. Code (18 lines). use std::os::raw::c_int;

  1. [repr(C)]

pub struct FfiResult { pub code: c_int } // 0 = OK, <0 = error

  1. [no_mangle]

pub extern "C" fn rs_sum_u32(input: *const u32, len: usize, out: *mut u64) -> FfiResult {

   if input.is_null() || out.is_null() { return FfiResult { code: -1 }; }
   // Safety: caller promises `input` points to `len` u32s, `out` is valid.
   let slice = unsafe { std::slice::from_raw_parts(input, len) };
   let sum: u64 = slice.iter().map(|&x| x as u64).sum();
   unsafe { *out = sum; }
   FfiResult { code: 0 }

} How-to.

  • Use from_raw_parts only for read-only slices; document it in the header.
  • If you must allocate, return an opaque handle plus a free_handle(handle).

Counterpoint. Returning big structs by value is portable but brittle; ABIs differ. Use out-params. Memory & Threading: Decide Once, Everywhere Story. We had one crash that only reproduced under load. Culprit: C freed a Rust-allocated buffer from a different thread. Insight. Pick a single allocator owner and a clear threading rule. Enforce it via API shape. How-to.

  • If Rust allocates, Rust frees through an exported free_* function. If C allocates, C frees.
  • Don’t bounce buffers between threads across FFI. Document: “All calls happen on threads created by X.”
  • Avoid callbacks across FFI unless you can guarantee reentrancy. If you must, model them as messages (function pointer + void* context) and keep them short.

Counterpoint. Cross-language alloc/free can work with libc::free / malloc, but it’s a trap in cross-platform builds. Avoid. Build System Without Tears Story. The first week was build pain. After we standardized artifacts, merges stopped being scary. Insight. Treat Rust as a normal dependency: cdylib/staticlib out, headers generated, link flags explicit. How-to (Rust-on-C).

  • In Cargo.toml: crate-type = ["cdylib"] or ["staticlib"].
  • Generate a C header from Rust with cbindgen (consistent with your repr(C)).
  • Link to the C library via build.rs (println!("cargo:rustc-link-lib=…")).
  • Ship artifacts: libyourlib.(so|dylib|dll|a) + yourlib.h.

How-to (C-on-Rust).

  • Build Rust cdylib/staticlib.
  • Consume the generated header from your C/C++ build (CMake, Bazel, Make). Add link flags per platform.

Counterpoint. bindgen is great for consuming complex C headers into Rust. Keep it in build.rs and check generated bindings into CI only if you must. Boundaries You Can Test Automatically Story. What convinced skeptical teammates? Green differential tests where both paths produced the same bytes. Insight. FFI is testable: run C and Rust implementations on the same inputs and diff outputs. Ship only when the delta is zero (or explained). How-to.

  • Create a golden corpus: edge cases, large inputs, weird locales.
  • Write a small C harness that calls the legacy API and prints hashes.
  • Write a Rust test that calls the new API; compare hashes.
  • Add a nightly fuzz (cargo-fuzz) that feeds both sides and rejects divergent outputs.

Counterpoint. Perfect equality isn’t always possible (floating point). Agree on tolerances; assert them. Performance: Where Overhead Actually Appears Story. We were nervous about “FFI overhead.” Profiling made it boring. Insight. The penalty is calls, not Rust. Crossing the boundary per element hurts; crossing per batch is fine. How-to.

  • Batch: accept (ptr, len) and process thousands of items per call.
  • Keep the hot loop in one language. If C already does it fast, call it once.
  • Don’t allocate in the FFI shim; allocate in the engine (Rust or C).
  • Measure with perf/VTune and -Zself-profile/perf (Rust), perf/oprofile (C). Look for call counts, not just CPU.

Counterpoint. When the whole hot path is moving to Rust, move it. The sandwich is for migration, not permanent split-brain. Security & Safety Wins You Get for Free Story. Two production bugs vanished the day we landed the first Rust wrapper. Insight. Even with C under the hood, a Rust boundary lets you validate inputs, cap sizes, and centralize lifetimes. How-to.

  • Validate (ptr, len) pairs, reject absurd lengths.
  • Clamp recursion depth and iteration counts at the Rust API level.
  • Convert untrusted strings to &[u8] and validate UTF-8 explicitly before touching C.
  • Kill C’s undefined behavior by blocking null pointers and impossible enum values at the top.

Counterpoint. Rust is not a firewall; if C misuses its own buffers internally, you still need to address it. But Rust can stop bad inputs from reaching it. A 4-Step Migration Plan That Actually Ships Story. We stopped arguing once we put a date on step 1. Shipping is clarifying. Insight. Migrate by surface area, not lines of code. Start with the riskiest edge, not the largest function. How-to.

  • Pick one API that bites you (crashes or exploits) and wrap it with a Rust boundary. Ship behind a flag to 10% traffic.
  • Diff outputs between C-only and Sandwich paths for a week. Fix deltas.
  • Move one chunk of logic into Rust inside the sandwich and keep the C call as a fallback.
  • Repeat on the next dangerous edge. Keep finance happy with real before/after crash counts and p95s.

Counterpoint. If you’re truly greenfield, skip the sandwich and build Rust-first. This article is for teams with revenue C in production. What Success Looks Like (Numbers to Watch) Story. We set boring metrics and hit them. Insight. Track the outcomes that matter to business, not just technical purity. How-to.

  • Crash rate: panics/segfaults per million requests → target: zero from the boundary.
  • Hot path p95: before vs after sandwich → target: ±3% or faster.
  • Bug class removal: input validation, lifetime bugs → track “class eliminated”.
  • Migration cadence: one wrapped API per sprint; one logic move per two sprints.

Counterpoint. If p95 climbs meaningfully, you’re crossing the boundary too often. Batch or move the hot loop. 3 Action Steps for This Week

  • Wrap one function. Expose a Rust API for a single C function using the out-param pattern above; add a free_* only if you allocate.
  • Ship a golden test. Build a harness that runs C vs Rust paths over 100 edge-case inputs and diffs outputs; put it in CI.
  • Instrument calls. Count FFI calls/second and average batch size; if calls are tiny and frequent, batch before you port logic.

Your Turn What’s the tightest Rust-on-C or C-on-Rust boundary you’ve shipped in production — and what were your exact before/after numbers (crash rate, p95, or defect class eliminated)? Bring headers, call counts, or flamegraphs.