FFI Boundaries
Not every C function needs to be fully transpiled. When downstream C consumers
depend on the library’s ABI, or when performance-critical inner loops use
inline assembly, keeping a C FFI boundary is the pragmatic choice. Decy
generates safe Rust wrappers around unsafe FFI calls.
When to Keep C Code via FFI
- Stable ABI contracts: Shared libraries consumed by C/C++ applications.
- Inline assembly: Platform-specific intrinsics not yet ported.
- Third-party dependencies: Vendored C code you do not own.
- Incremental migration: Converting module by module over time.
Safe Wrappers Around Unsafe FFI
C header (vecmath.h)
int vec_add(const double* a, const double* b, double* out, size_t len);
Rust FFI binding
#![allow(unused)]
fn main() {
extern "C" {
fn vec_add(
a: *const f64,
b: *const f64,
out: *mut f64,
len: libc::size_t,
) -> libc::c_int;
}
}
Safe Rust wrapper
#![allow(unused)]
fn main() {
pub fn vector_add(a: &[f64], b: &[f64]) -> Result<Vec<f64>, VecMathError> {
if a.len() != b.len() {
return Err(VecMathError::DimensionMismatch);
}
let mut out = vec![0.0; a.len()];
let rc = unsafe {
vec_add(a.as_ptr(), b.as_ptr(), out.as_mut_ptr(), a.len())
};
if rc != 0 {
return Err(VecMathError::from_code(rc));
}
Ok(out)
}
}
The safe wrapper enforces three invariants that the C caller was responsible for:
- Input slices have matching lengths (dimension check).
- The output buffer is correctly sized (allocated by the wrapper).
- The return code is checked and converted to a typed error.
Decy’s FFI Generation
When batuta transpile encounters functions marked for FFI preservation, decy
generates both directions:
Rust calling C (for functions not yet migrated):
#![allow(unused)]
fn main() {
// Auto-generated by decy -- safe wrapper around C implementation
mod ffi {
use super::*;
extern "C" { fn matrix_inverse(m: *const f64, n: usize) -> *mut f64; }
pub fn inverse(m: &[f64], n: usize) -> Result<Vec<f64>> {
let ptr = unsafe { matrix_inverse(m.as_ptr(), n) };
if ptr.is_null() {
return Err(anyhow::anyhow!("matrix_inverse returned NULL"));
}
let result = unsafe { Vec::from_raw_parts(ptr, n * n, n * n) };
Ok(result)
}
}
}
C calling Rust (for functions already migrated):
#![allow(unused)]
fn main() {
// Exported for C consumers via cdylib
#[no_mangle]
pub extern "C" fn vec_dot(
a: *const f64,
b: *const f64,
len: libc::size_t,
) -> f64 {
let a = unsafe { std::slice::from_raw_parts(a, len) };
let b = unsafe { std::slice::from_raw_parts(b, len) };
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
}
Gradual Migration Strategy
A typical migration proceeds in three phases:
-
Wrap: Generate safe Rust wrappers around the entire C library. All existing C consumers link against the Rust cdylib with no source changes.
-
Replace: Rewrite functions one at a time in pure Rust. The FFI wrapper is removed for each function as it is replaced. Tests run after each replacement.
-
Remove: Once all functions are pure Rust, drop the C source and the FFI layer. The library is now a native Rust crate.
Phase 1: C library <-- FFI --> Rust wrappers <-- Rust API
Phase 2: C library <-- FFI --> Rust (partial) <-- Rust API
Phase 3: Rust (complete) <-- Rust API
At every phase, the public API (both Rust and C) remains stable. Downstream consumers experience no breakage during the transition.
Key Takeaways
- Keep C code via FFI when ABI stability, inline assembly, or third-party ownership prevents full transpilation.
- Safe wrappers enforce dimension checks, null-pointer validation, and error
code translation around every
unsafeFFI call. - Decy generates wrappers in both directions: Rust-calling-C and C-calling-Rust.
- Gradual migration (wrap, replace, remove) lets teams convert incrementally without breaking downstream consumers.
Navigate: Table of Contents