Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Input slices have matching lengths (dimension check).
  2. The output buffer is correctly sized (allocated by the wrapper).
  3. 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:

  1. Wrap: Generate safe Rust wrappers around the entire C library. All existing C consumers link against the Rust cdylib with no source changes.

  2. 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.

  3. 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 unsafe FFI 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