FFI (Foreign Function Interface)

Level 13 — Unsafe Rust & FFI The mechanism that allows Rust code to call functions defined in foreign compiled libraries (such as C, C++, or system APIs) and export Rust functions to be called by external environments (Node.js, Python, C).


1. Prerequisites


2. Term Category

Rust Interoperability Subsystem (foreign function interface execution bridge): Foreign Function Interface (FFI) is the bridge that allows Rust binaries to communicate directly with compiled machine code generated by other programming languages. FFI operates at the Application Binary Interface (ABI) level, allowing Rust to invoke external C library functions (e.g. c_math, openssl, operating system C APIs) or export Rust libraries to higher-level runtimes like Node.js (via N-API / neon), Python (via PyO3), or WebAssembly.


3. Explanation

(1) Design Motivation — "Why did we design this?"

Software engineering rarely happens in a vacuum. Millions of existing, battle-tested software libraries — operating system kernels, cryptography engines (OpenSSL, libsodium), database engines (SQLite), and media codecs (ffmpeg) — are written in C or C++.

If Rust could only communicate with code written strictly in Rust:

  1. Developers would have to rewrite millions of lines of legacy C/C++ infrastructure from scratch before building production applications in Rust.
  2. Rust could not serve as a drop-in replacement for existing C libraries in multi-language enterprise systems or embedded device stacks.
  3. High-level runtime environments (Node.js, Python, Java) could not call high-performance Rust modules for performance-critical tasks.

Rust introduced FFI to provide seamless, zero-cost C interoperability. By declaring external function signatures inside an extern "C" block, Rust compiles direct machine-level function calls to foreign libraries without any dynamic runtime translation overhead.

(2) Reality Metaphor

Imagine an International Trade Border Customs Crossing:

  • Native Rust Code is like domestic trucks driving inside a country with standard driving rules, safety inspections, and road signs (ownership, lifetimes, borrow checking).
  • Foreign C Library Code is like foreign freight trucks arriving at the border checkpoint: they operate under different domestic rules (no borrow checker, raw pointers, manual malloc/free).
  • FFI (Foreign Function Interface) is the international customs cargo manifest protocol:
    • Both countries agree on a standardized shipping container shape and passport format (C Calling Convention extern "C" and C memory layout #[repr(C)]).
    • Crossing the border into foreign territory (calling an extern "C" function) requires a security escort (wrapping the call inside an unsafe { ... } block) because the host country's automated safety cameras (borrow checker) cannot monitor foreign trucks once they leave domestic roads.

(3) Code Examples

Short Snippet (Calling Standard C Library abs via FFI)

use std::os::raw::c_int;

// 1. Declare foreign C function signatures inside an `extern "C"` block
extern "C" {
    // Import `abs` function from standard C library (libc)
    fn abs(input: c_int) -> c_int;
}

fn main() {
    let val: i32 = -42;

    // 2. Calling an external FFI function REQUIRES an `unsafe` block!
    unsafe {
        // SAFETY: `abs` from C standard library takes a 32-bit int and returns a 32-bit int.
        // It does not dereference pointers or mutate global state, making this call safe.
        let result = abs(val);
        println!("Absolute value of {} via C libc FFI: {}", val, result); // 42
    }
}

Fuller Example (Exporting a Rust Function to C and Passing C-Compatible Strings)

use std::ffi::CStr;
use std::os::raw::c_char;

/// Exporting a Rust function for foreign C callers to invoke.
/// - `no_mangle`: Prevents Rust from renaming the function symbol during compilation.
/// - `pub extern "C"`: Specifies standard C ABI calling convention.
#[no_mangle]
pub extern "C" fn greet_from_rust(name_ptr: *const c_char) {
    if name_ptr.is_null() {
        println!("Rust received a null string pointer!");
        return;
    }

    unsafe {
        // SAFETY: We checked for null above. `CStr::from_ptr` parses null-terminated C string.
        if let Ok(c_str) = CStr::from_ptr(name_ptr).to_str() {
            println!("Hello '{}', called from foreign C code into Rust!", c_str);
        } else {
            println!("Failed to parse UTF-8 string from C!");
        }
    }
}

fn main() {
    // Simulating foreign caller passing a null-terminated C string (`b"Alice\0"`)
    let c_name = std::ffi::CString::new("Alice").unwrap();
    
    // Call exported Rust function as if an external C program invoked it
    greet_from_rust(c_name.as_ptr());
}

(4) FFI Type Mapping (Rust vs C)

When passing data across FFI boundaries, primitive types must map to equivalent C types from std::os::raw or core::ffi:

C TypeRust FFI Equivalent (std::os::raw)Primitive
intc_inti32 (on most platforms)
unsigned intc_uintu32
char* (string)*const c_charNull-terminated byte pointer
void**mut std::ffi::c_voidOpaque raw pointer
size_tusizePointer-sized unsigned integer

4. Common Mistakes & Pitfalls

Mistake 1: Passing Rust String or &str Directly to C Functions

The mistake: Passing a Rust &str or String pointer directly to a C function expecting a C string const char*.

Why it's wrong: Rust strings are NOT null-terminated (\0) and store string length alongside data. C strings are null-terminated raw pointer sequences without explicit length fields. Passing a &str to C causes C to read past the end of the buffer until it encounters a random 0 byte, causing memory corruption or segmentation faults.

Incorrect:

extern "C" { fn puts(s: *const c_char); }

let rust_str = "Hello World";
unsafe {
    // ❌ UNDEFINED BEHAVIOR! `rust_str` is not null-terminated!
    puts(rust_str.as_ptr() as *const c_char); 
}

Fix:

use std::ffi::CString;

let c_string = CString::new("Hello World").unwrap(); // Appends `\0` null byte
unsafe {
    // Correct: `c_string.as_ptr()` delivers valid null-terminated C string
    puts(c_string.as_ptr()); 
}

Mistake 2: Unwinding / Panicking across an FFI Boundary

The mistake: Allowing a Rust function exported to C (pub extern "C") to panic.

Why it's wrong: Unwinding a stack frame across an extern "C" boundary into foreign C stack frames is Undefined Behavior in Rust. It crashes the host process unpredictably or corrupts the foreign runtime stack.

Incorrect:

#[no_mangle]
pub extern "C" fn calculate(val: i32) -> i32 {
    if val == 0 {
        // ❌ UNDEFINED BEHAVIOR! Panicking across C FFI boundary!
        panic!("Zero is not allowed!"); 
    }
    100 / val
}

Fix:

use std::panic::catch_unwind;

#[no_mangle]
pub extern "C" fn calculate(val: i32) -> i32 {
    // Correct: Catch panics at FFI boundary and return error code integer
    let result = catch_unwind(|| {
        if val == 0 { panic!("Zero"); }
        100 / val
    });

    match result {
        Ok(res) => res,
        Err(_) => -1, // Return C-compatible error indicator
    }
}

Mistake 3: Passing Rust Structs with Default Layout to C

The mistake: Passing a standard Rust struct struct Point { x: i32, y: i32 } across an FFI boundary to C.

Why it's wrong: By default, Rust does NOT guarantee struct field ordering or memory alignment (rustc reorders struct fields for memory optimization). C compilers expect strict, non-reordered field alignment.

Incorrect:

// ❌ Rust compiler may reorder fields in memory! Incompatible with C!
struct Point {
    x: i32,
    y: i64,
}

Fix:

// Correct: `#[repr(C)]` guarantees field memory layout matches C ABI rules exactly
#[repr(C)]
pub struct Point {
    x: i32,
    y: i64,
}

5. Practice Exercises

Exercise 1: Embedded Telemetry C-String Parser & Safe Buffer Exporter

Scenario: In an embedded edge gateway daemon, legacy C systems communicate telemetry strings across FFI formatted as "SYS_STATUS:OK|TEMP:42.5|ERR:0".

Implement an exported Rust function pub extern "C" fn parse_and_format_telemetry(input_ptr: *const c_char, out_buf: *mut c_char, out_cap: usize) -> i32 that:

  1. Rejects null pointer arguments, returning FFI_ERR_NULL_PTR (-1).
  2. Converts input_ptr into a Rust &str via CStr::from_ptr. Returns FFI_ERR_INVALID_UTF8 (-2) if UTF-8 parsing fails.
  3. Parses the TEMP value. If temperature is greater than 50.0, output string is "ALERT: OVERHEAT", otherwise "STATUS: NORMAL".
  4. Copies the result plus trailing null byte (\0) into out_buf. Returns FFI_ERR_BUFFER_TOO_SMALL (-3) if out_cap is insufficient.
  5. Returns FFI_SUCCESS (0) on successful write.

Include a unit test module verifying normal output, overheating alerts, null pointer resilience, and buffer capacity bounds checking.

Answer

Implementation

use std::ffi::CStr;
use std::os::raw::c_char;
use std::ptr;

pub const FFI_SUCCESS: i32 = 0;
pub const FFI_ERR_NULL_PTR: i32 = -1;
pub const FFI_ERR_INVALID_UTF8: i32 = -2;
pub const FFI_ERR_BUFFER_TOO_SMALL: i32 = -3;

/// Parses raw C telemetry log string and writes formatted status to output buffer.
///
/// # Safety
/// - `input_ptr` must point to a valid null-terminated C string if non-null.
/// - `out_buf` must point to a writable memory region of at least `out_cap` bytes if non-null.
#[no_mangle]
pub extern "C" fn parse_and_format_telemetry(
    input_ptr: *const c_char,
    out_buf: *mut c_char,
    out_cap: usize,
) -> i32 {
    if input_ptr.is_null() || out_buf.is_null() {
        return FFI_ERR_NULL_PTR;
    }

    let input_str = unsafe {
        match CStr::from_ptr(input_ptr).to_str() {
            Ok(s) => s,
            Err(_) => return FFI_ERR_INVALID_UTF8,
        }
    };

    let is_overheat = input_str
        .split('|')
        .find(|part| part.starts_with("TEMP:"))
        .and_then(|temp_part| temp_part.strip_prefix("TEMP:"))
        .and_then(|val_str| val_str.parse::<f64>().ok())
        .map_or(false, |temp_val| temp_val > 50.0);

    let result_text = if is_overheat {
        "ALERT: OVERHEAT"
    } else {
        "STATUS: NORMAL"
    };

    let required_bytes = result_text.len() + 1;
    if out_cap < required_bytes {
        return FFI_ERR_BUFFER_TOO_SMALL;
    }

    unsafe {
        ptr::copy_nonoverlapping(
            result_text.as_ptr(),
            out_buf as *mut u8,
            result_text.len(),
        );
        *out_buf.add(result_text.len()) = 0;
    }

    FFI_SUCCESS
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::CString;

    #[test]
    fn test_telemetry_normal_temp() {
        let input = CString::new("SYS_STATUS:OK|TEMP:42.5|ERR:0").unwrap();
        let mut buffer = [0 as c_char; 64];

        let status = parse_and_format_telemetry(input.as_ptr(), buffer.as_mut_ptr(), buffer.len());
        assert_eq!(status, FFI_SUCCESS);

        let output_cstr = unsafe { CStr::from_ptr(buffer.as_ptr()) };
        assert_eq!(output_cstr.to_str().unwrap(), "STATUS: NORMAL");
    }

    #[test]
    fn test_telemetry_overheat_alert() {
        let input = CString::new("SYS_STATUS:OK|TEMP:78.3|ERR:0").unwrap();
        let mut buffer = [0 as c_char; 64];

        let status = parse_and_format_telemetry(input.as_ptr(), buffer.as_mut_ptr(), buffer.len());
        assert_eq!(status, FFI_SUCCESS);

        let output_cstr = unsafe { CStr::from_ptr(buffer.as_ptr()) };
        assert_eq!(output_cstr.to_str().unwrap(), "ALERT: OVERHEAT");
    }

    #[test]
    fn test_null_pointer_safety() {
        let mut buffer = [0 as c_char; 64];
        let status = parse_and_format_telemetry(ptr::null(), buffer.as_mut_ptr(), buffer.len());
        assert_eq!(status, FFI_ERR_NULL_PTR);
    }

    #[test]
    fn test_buffer_capacity_overflow() {
        let input = CString::new("SYS_STATUS:OK|TEMP:20.0|ERR:0").unwrap();
        let mut small_buffer = [0 as c_char; 5];

        let status = parse_and_format_telemetry(input.as_ptr(), small_buffer.as_mut_ptr(), small_buffer.len());
        assert_eq!(status, FFI_ERR_BUFFER_TOO_SMALL);
    }
}

Technical Explanation

  1. C-String Parsing (CStr::from_ptr): CStr::from_ptr reads a raw C pointer up to its terminating null byte (0x00). Calling .to_str() verifies valid UTF-8 sequence without allocation or memory copy.
  2. Null Pointer Safeguards: Dereferencing a null pointer causes segmentation faults. Explicitly checking input_ptr.is_null() and out_buf.is_null() returns error status codes cleanly across the ABI.
  3. Manual Buffer Writes & Null Termination: ptr::copy_nonoverlapping copies string raw bytes into the foreign buffer, and *out_buf.add(len) = 0 explicitly appends the mandatory C string null terminator byte (\0).

Exercise 2: Opaque Pointer Handle Pattern for Native Cryptographic Sessions

Scenario: When exporting a native Rust library for external callers (such as C, C++, or Python extensions), foreign environments cannot directly hold or construct complex Rust struct values on their stack. The standard architecture pattern is the Opaque Pointer Handle Idiom: Rust allocates state on the heap, converts the Box into a raw handle pointer, hands ownership of the address to C, and later reclaims the handle to perform actions or deallocate memory.

Implement an opaque cryptographic session handle API:

  1. Define a Rust struct SessionContext { session_id: u64, active_keys: Vec<u32> }.
  2. Implement pub extern "C" fn session_create(session_id: u64) -> *mut SessionContext: Allocates SessionContext on the heap and returns a raw opaque handle using Box::into_raw.
  3. Implement pub extern "C" fn session_add_key(handle: *mut SessionContext, key: u32) -> i32: Validates non-null handle, mutates state by pushing key, and returns 0 (or -1 if null).
  4. Implement pub extern "C" fn session_key_count(handle: *const SessionContext) -> i32: Queries key count via shared reference, returning -1 if handle is null.
  5. Implement pub extern "C" fn session_destroy(handle: *mut SessionContext) -> i32: Reclaims heap allocation using Box::from_raw(handle) so Rust's drop checker automatically cleans up heap memory.

Write a unit test module verifying handle creation, key addition, count queries, null pointer resilience, and memory destruction.

Answer

Implementation

use std::ptr;

/// Internal Rust state hidden behind an opaque pointer handle for foreign callers.
pub struct SessionContext {
    pub session_id: u64,
    pub active_keys: Vec<u32>,
}

/// Allocates a new session context on the heap and returns an opaque raw handle.
#[no_mangle]
pub extern "C" fn session_create(session_id: u64) -> *mut SessionContext {
    let ctx = SessionContext {
        session_id,
        active_keys: Vec::new(),
    };
    Box::into_raw(Box::new(ctx))
}

/// Adds a key to an existing session via opaque handle reference.
#[no_mangle]
pub extern "C" fn session_add_key(handle: *mut SessionContext, key: u32) -> i32 {
    if handle.is_null() {
        return -1;
    }
    let ctx = unsafe { &mut *handle };
    ctx.active_keys.push(key);
    0
}

/// Queries key count from an opaque session handle.
#[no_mangle]
pub extern "C" fn session_key_count(handle: *const SessionContext) -> i32 {
    if handle.is_null() {
        return -1;
    }
    let ctx = unsafe { &*handle };
    ctx.active_keys.len() as i32
}

/// Destroys the session context and frees heap memory.
#[no_mangle]
pub extern "C" fn session_destroy(handle: *mut SessionContext) -> i32 {
    if handle.is_null() {
        return -1;
    }
    unsafe {
        let _boxed = Box::from_raw(handle);
    }
    0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_opaque_session_lifecycle() {
        let handle = session_create(1001);
        assert!(!handle.is_null());

        assert_eq!(session_key_count(handle), 0);

        assert_eq!(session_add_key(handle, 0xDEADBEEF), 0);
        assert_eq!(session_add_key(handle, 0xCAFEBABE), 0);

        assert_eq!(session_key_count(handle), 2);

        assert_eq!(session_destroy(handle), 0);
    }

    #[test]
    fn test_null_handle_resilience() {
        assert_eq!(session_add_key(ptr::null_mut(), 123), -1);
        assert_eq!(session_key_count(ptr::null()), -1);
        assert_eq!(session_destroy(ptr::null_mut()), -1);
    }
}

Technical Explanation

  1. Box::into_raw Transfer: Box::into_raw converts a heap-allocated Box<T> into a raw pointer *mut T without invoking destructors. Ownership of the raw memory address is handed off to the foreign C caller.
  2. Box::from_raw Destruction: When foreign code calls session_destroy, Box::from_raw(handle) takes back ownership of the raw pointer. When _boxed goes out of scope, Rust automatically drops both SessionContext and its nested Vec<u32> heap memory.
  3. Raw Reference Casting (&*handle, &mut *handle): Dereferencing valid raw pointers inside unsafe blocks creates temporary Rust references for safe field access without cloning memory.

Exercise 3: Safe FFI Callback Interface with Panic Boundary Protection

Scenario: When Rust libraries invoke foreign callbacks or expose callback dispatching to C code:

  1. A function pointer passed from foreign code might be None (representing a C NULL pointer).
  2. If a callback implementation triggers a Rust panic, allowing that panic to unwind across an extern "C" ABI boundary causes Undefined Behavior and process termination.

Design a C-compatible event dispatcher:

  1. Define the callback type signature pub type EventCallback = Option<unsafe extern "C" fn(event_code: u32, payload_val: i32, user_data: *mut std::ffi::c_void)>.
  2. Implement pub extern "C" fn dispatch_event_safe(cb: EventCallback, code: u32, val: i32, user_data: *mut std::ffi::c_void) -> i32:
    • If cb is None, return FFI_ERR_NO_CALLBACK (-1).
    • Wrap the callback invocation inside std::panic::catch_unwind(AssertUnwindSafe(...)) to intercept panics.
    • If execution succeeds, return FFI_SUCCESS (0). If a panic occurred, return FFI_ERR_PANIC_CAUGHT (-2).

Include unit tests covering:

  • Normal event dispatch updating context state through user_data (*mut c_void).
  • Rejection of None function pointers.
  • Interception of panicking callbacks, verifying that panics are caught and returned as status codes without crashing.
Answer

Implementation

use std::ffi::c_void;
use std::panic::{catch_unwind, AssertUnwindSafe};

pub const FFI_SUCCESS: i32 = 0;
pub const FFI_ERR_NO_CALLBACK: i32 = -1;
pub const FFI_ERR_PANIC_CAUGHT: i32 = -2;

/// C ABI function pointer signature for foreign event callbacks.
pub type EventCallback = Option<
    unsafe extern "C" fn(event_code: u32, payload_val: i32, user_data: *mut c_void),
>;

/// Safely dispatches an event to a C callback with panic isolation boundaries.
#[no_mangle]
pub extern "C" fn dispatch_event_safe(
    cb: EventCallback,
    code: u32,
    val: i32,
    user_data: *mut c_void,
) -> i32 {
    let callback_fn = match cb {
        Some(f) => f,
        None => return FFI_ERR_NO_CALLBACK,
    };

    let result = catch_unwind(AssertUnwindSafe(|| unsafe {
        callback_fn(code, val, user_data);
    }));

    match result {
        Ok(_) => FFI_SUCCESS,
        Err(_) => FFI_ERR_PANIC_CAUGHT,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct CustomState {
        received_code: u32,
        received_val: i32,
    }

    unsafe extern "C" fn valid_c_callback(code: u32, val: i32, user_data: *mut c_void) {
        if !user_data.is_null() {
            let state = &mut *(user_data as *mut CustomState);
            state.received_code = code;
            state.received_val = val;
        }
    }

    unsafe extern "C" fn panicking_c_callback(_code: u32, _val: i32, _user_data: *mut c_void) {
        panic!("Unexpected hardware fault in callback!");
    }

    #[test]
    fn test_successful_callback_dispatch() {
        let mut state = CustomState {
            received_code: 0,
            received_val: 0,
        };
        let state_ptr = &mut state as *mut CustomState as *mut c_void;

        let status = dispatch_event_safe(Some(valid_c_callback), 101, 42, state_ptr);

        assert_eq!(status, FFI_SUCCESS);
        assert_eq!(state.received_code, 101);
        assert_eq!(state.received_val, 42);
    }

    #[test]
    fn test_none_callback_rejection() {
        let status = dispatch_event_safe(None, 100, 0, std::ptr::null_mut());
        assert_eq!(status, FFI_ERR_NO_CALLBACK);
    }

    #[test]
    fn test_panic_isolation_at_ffi_boundary() {
        let status = dispatch_event_safe(
            Some(panicking_c_callback),
            500,
            -1,
            std::ptr::null_mut(),
        );
        assert_eq!(status, FFI_ERR_PANIC_CAUGHT);
    }
}

Technical Explanation

  1. Option<unsafe extern "C" fn(...)> Null Safety: Rust function pointers in FFI are represented inside Option. A None variant corresponds to a C NULL pointer, enabling idiomatic pattern matching for function pointer validation.

  2. Panic Boundary Isolation (catch_unwind): Unwinding across extern "C" function boundaries is Undefined Behavior in Rust. catch_unwind catches unwinding panics at the boundary and returns an integer status code (-2) cleanly.

  3. *mut c_void Opaque Context: C callbacks use void* user_data to pass application context state. Casting *mut c_void back to *mut CustomState allows inspecting and mutating caller environment state.



7. Key Takeaways

  • FFI (Foreign Function Interface) allows Rust code to call foreign C/C++ libraries and export Rust functions to external languages.
  • External C functions are declared inside extern "C" { ... } blocks and called inside unsafe { ... } blocks.
  • Exported Rust functions use #[no_mangle] pub extern "C" fn.
  • Use std::ffi::CString and std::ffi::CStr for passing null-terminated strings across FFI boundaries.
  • Always prevent Rust panics from unwinding across FFI boundaries using std::panic::catch_unwind.
Built with LogoFlowershow