Your Boss Said “Rewrite It in Rust” and Your Heart Sank
You maintain a core C service at your company. Millions of requests a day, running stable for three years. Then one day at a standup, your tech lead drops the bomb: “We’re migrating this service to Rust.”
What’s your first instinct?
If it’s “sure, let me start from main(),” I’d suggest you take a breath.
It’s like your kitchen. You’ve used it for three years. The faucet drips sometimes, the stove is a bit dated, but it works. Would you tear the whole thing down for a “kitchen upgrade”? What do you eat for the next three months?
Production C code works the same way. It has quirks (the occasional segfault, a memory leak here and there), but it’s making money. You take it down for a full rewrite, and who covers the revenue gap?
Let’s talk about a more practical approach: the FFI Sandwich. This is the steadiest incremental migration pattern in the Rust FFI world, turning the C-to-Rust migration into a controlled process.
What’s an FFI Sandwich? One Sentence
You’ve had a sandwich before. Two slices of bread, some filling in the middle.
The FFI Sandwich is three layers too:
Your application (safe Rust code)
|
FFI shim layer (unsafe but thin)
|
C library (battle-tested legacy code)
- Top bread: Your Rust application code. Safe, no raw pointers flying around
- The filling: The FFI shim, bridging with
#[no_mangle] extern "C", handling only type conversion - Bottom bread: Your existing C library. Stable, working, making money
This pattern is boring on purpose. No fancy macro magic, no complex generics gymnastics. Boring is the point. Production doesn’t need surprises.
Think of it like hiring a translator. You speak English, the old craftsman across the table speaks a local dialect, and the translator sits in the middle passing messages. The craftsman keeps doing what he’s always done. You just need to make sure the translator doesn’t garble anything.
Pick a Direction: Who Calls Whom?
Before you start, there’s one decision to make: Rust calls C, or C calls Rust? This determines the direction of your Rust-C interop.
Rust-on-C
Your C library has algorithms polished over three years. Fast, rock-solid. You just want to add a safety shell around it.
Put a new lock on an old house. Furniture stays, entry gets safer.
C-on-Rust
You’re writing a brand new core module in Rust, but the existing C program still needs to call it.
Swap in a new stove in the old kitchen. Recipes don’t change, just more firepower.
One rule: pick one direction per subsystem, don’t cross-call both ways. You can technically press the gas and the brake at the same time, but your brain will blow up first.
Types at the Boundary: Passport Control
The FFI boundary is like customs. Not everything gets through. The Rust safety model requires extra care at this layer. What can pass:

- Pointers:
*const T,*mut T - Fixed-width integers:
i32,u64,usize #[repr(C)]structs: memory layout identical to C- Buffers: passed as
(pointer, length)pairs, like a shipping label that says exactly how many items and where they go - Error codes: don’t use Rust’s
Result, just return integers (0 for success, negative for error). That’s the standard forextern "C"functions
What gets rejected:
- Rust’s
String,Vec,Option– their memory layouts are alien to C paniccannot cross the FFI boundary. Setting off fireworks at customs blows up both sides- Rust’s drop semantics. C has no idea what destructors you’re running
The core of Rust-C interop is locking down the rules at this boundary. Each side manages its own types, and only the rawest data crosses the gap.
The Safest Pattern: One Out-Param, One Status Code
Enough theory. Here’s code. These 18 lines are the core of the safest Rust FFI pattern, and the most important part of this article:
use std::os::raw::c_int;
#[repr(C)]
pub struct FfiResult {
pub code: c_int, // 0 = success, <0 = error
}
#[no_mangle]
pub extern "C" fn rs_sum_u32(
input: *const u32,
len: usize,
out: *mut u64,
) -> FfiResult {
// Check pointers first, reject nulls immediately
if input.is_null() || out.is_null() {
return FfiResult { code: -1 };
}
// Safety: caller guarantees `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 }
}
See it? The pattern is straightforward:
- Validate input: null pointer? Reject. Bad length? Reject. Like a restaurant host checking your reservation
- Return a status code: 0 is success, negatives are various errors. No exceptions, no panics. Both sides of the
extern "C"boundary understand it - Write results via out-params: don’t allocate in the FFI layer. Whoever allocates, frees
- Keep
unsafeblocks small: only where truly needed, precise like a scalpel
You can use this pattern a hundred times and it won’t let you down.
Memory Management: Whoever Allocated It Frees It
Memory management is where FFI gets tricky. There’s one iron rule:
Rust allocates, Rust frees. C allocates, C frees.
Shared apartment fridge rules. You bought the milk, you drink it. Don’t touch anyone else’s stuff.
If Rust needs to give C a chunk of data, you must also provide a free_* function:
#[no_mangle]
pub extern "C" fn rs_create_buffer(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf); // Don't let Rust auto-free this
ptr
}
#[no_mangle]
pub extern "C" fn rs_free_buffer(ptr: *mut u8, size: usize) {
if !ptr.is_null() {
unsafe {
// Reconstruct the Vec so Rust frees it properly
let _ = Vec::from_raw_parts(ptr, 0, size);
}
}
}
As for threads, don’t pass shared buffers across the FFI boundary between threads. The two runtimes understand threading differently, like two people defining “tomorrow” differently (you mean next business day, they mean the calendar day). It’ll go wrong eventually.
Be careful with callbacks too. If you must use them, follow C conventions: function pointer + void* context. Keep them short. Don’t try to shove closures across the boundary.
Build System: Don’t Overthink It
Build configuration isn’t hard, but there are pitfalls.
Rust calling C:
- Set
crate-type = ["cdylib"]or["staticlib"]inCargo.toml - Use
cbindgento auto-generate C headers from Rust code (matchingrepr(C)) - Link the C library in
build.rs:println!("cargo:rustc-link-lib=your_c_lib") - Final artifacts:
libyourlib.so(or.dylib/.dll/.a) plus a.hheader
C calling Rust, reverse it:
- Compile Rust into a shared or static library
- Use the generated header in your C/C++ build (CMake, Bazel, Make all work)
- Add platform-specific link flags
Two tools to remember:
cbindgen: generates C headers from Rust exportsbindgen: generates Rust bindings from C headers, use it inbuild.rs
Testing: Make C and Rust Compare Answers
This step matters. You can’t just go with “I think it’s right.” You need both sides running the same inputs and comparing outputs.
Here’s how:
- Prepare a test corpus: edge cases, large inputs, weird locales. The nastier the better
- Write a C test harness: call the legacy C API, print output hashes
- Write a Rust test: call the new Rust API, print output hashes
- Compare both sides: matching means correct, divergence means a bug
# Rough idea
./c_test_harness < test_corpus.bin > c_output.txt
./rust_test_harness < test_corpus.bin > rust_output.txt
diff c_output.txt rust_output.txt
Then add cargo-fuzz to your nightly CI. Let it hammer both interfaces with random inputs every night. Any divergence triggers an alert; fix it the next morning.
Watch out with floating-point comparisons. Agree on a tolerance first, then use assert!((a - b).abs() < 1e-9). Floating-point is like seasoning food – “to taste” means something different to everyone. You need a standard.
Performance: The Bottleneck Is Call Count, Not Rust
People worry FFI will slow things down. In practice, Rust and C execute at similar speeds. What’s actually slow is the number of boundary crossings.
When moving apartments, the bottleneck isn’t how fast you run between floors. It’s how many trips you make. Carrying one book per trip for a hundred trips is obviously worse than one box per trip.
So:
- Batch processing: pass
(pointer, length)and process thousands of items per call, not one at a time - Keep the hot loop in one language: if C’s algorithm is already fast, call it once and let it process everything
- Don’t allocate in the FFI shim: allocate in the engine layer (Rust or C) and pass it through
Performance target: hot path p95 latency after adding the FFI layer should be within +/-3%, or faster. If it’s worse, you’re probably calling too often, not a problem with FFI itself.
When profiling with perf or VTune, focus on call counts, not just CPU time.
Safety Wins: Free of Charge
Even with C code still running underneath, adding that Rust layer gets you these:
- Input validation: check
(pointer, length)pairs at the Rust layer, reject absurd length values. Buffer overflows from C blindly trusting input? Gone - Recursion and loop bounds: cap recursion depth and iteration counts at the Rust API layer, preventing malicious inputs from killing the service
- String safety: treat untrusted strings as
&[u8], explicitly validate UTF-8 before passing to C - Null pointer interception: all null pointers and invalid enum values get blocked at the entry point
Rust isn’t a firewall. If C code internally misuses its own buffers, that’s C’s problem. But at least security issues caused by external input are blocked at the door. That’s the most direct value of Rust’s safety model in a C-to-Rust migration.
Incremental Migration in Four Steps

Theory covered. How do you actually ship this?
Step 1: Pick the Most Dangerous API, Wrap It in Rust
Find the C function most likely to crash or get exploited. Write a safe Rust entry point with input validation, then canary it at 10% traffic.
Fix the leakiest faucet first. Leave the rest alone for now.
Step 2: Run Both Paths for a Week, Diff the Results
Run the C-only path and the FFI Sandwich path side by side, comparing outputs. Any difference gets fixed until they match perfectly.
Step 3: Move One Chunk of Logic into Rust
Inside the sandwich, rewrite one piece of C logic in Rust. Keep the C call as a fallback. If the new code has issues, switch back instantly.
Step 4: Repeat, One at a Time
Find the next dangerous edge API, repeat the steps above. Don’t rush. Each iteration should give finance real numbers showing crash counts dropping and latency holding steady.
Migrate by API surface area, not by lines of code. Start with the riskiest edge, not the largest function. That’s the essence of FFI Sandwich incremental migration.
How Do You Know It’s Working?
Watch four numbers:
- Crash rate: panics/segfaults per million requests. Target: zero from the FFI boundary
- Hot path p95: before vs after the sandwich. Target: within +/-3%
- Bug classes eliminated: input validation, lifetime issues. Track “another class of bug wiped out”
- Migration cadence: one wrapped API per sprint, one logic move per two sprints
These numbers are how you talk to management. Don’t say “Rust is safer.” Say “crashes dropped 73% last month.” The latter gets budgets approved.
Three Things You Can Do This Week
If you’ve read this far, don’t just nod. Open a terminal.
1. Wrap one function
Pick the simplest function in your C codebase, wrap it with the out-param pattern from this article. If it allocates memory, don’t forget the free_*.
2. Build a comparison test
Write a test harness, prepare 100 edge-case inputs, run C once and Rust once, diff the outputs. Put it in CI.
3. Count your call frequency
Measure how many times per second your FFI function gets called, and the average batch size. If call volume is high but each call processes only one item, batch first, then migrate logic.
Remember: don’t replace revenue code. Wrap it.
What’s the most painful function in your C codebase? The parser that segfaults randomly, or the encryption module nobody’s dared to touch in a decade? Drop a comment. Chances are someone else has hit the same wall.
Next up, we’ll talk about upgrading the FFI Sandwich to an “observable version” – where every cross-boundary call gets logs, metrics, and alerts, making the migration as controlled as changing a tire.
Found this useful?
- Like: Help more people see it
- Share: Send it to a coworker still debating “should we rewrite”
- Follow: Follow Dream Beast Programming for more hands-on Rust content
- Comment: Got migration war stories? Let’s hear them
Your support keeps me writing.
