FFI (Foreign Function Interface)
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
unsafeBlock — FFI function calls bypass Rust compiler checks and must be executed withinunsafeblocks.- Raw Pointers (
*const T,*mut T) — Passing data pointers across language ABI boundaries. - Undefined Behavior (UB) — Memory layout or ABI mismatches across FFI boundaries cause Undefined Behavior.
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:
- Developers would have to rewrite millions of lines of legacy C/C++ infrastructure from scratch before building production applications in Rust.
- Rust could not serve as a drop-in replacement for existing C libraries in multi-language enterprise systems or embedded device stacks.
- 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 anunsafe { ... }block) because the host country's automated safety cameras (borrow checker) cannot monitor foreign trucks once they leave domestic roads.
- Both countries agree on a standardized shipping container shape and passport format (C Calling Convention
(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 Type | Rust FFI Equivalent (std::os::raw) | Primitive |
|---|---|---|
int | c_int | i32 (on most platforms) |
unsigned int | c_uint | u32 |
char* (string) | *const c_char | Null-terminated byte pointer |
void* | *mut std::ffi::c_void | Opaque raw pointer |
size_t | usize | Pointer-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:
- Rejects null pointer arguments, returning
FFI_ERR_NULL_PTR(-1). - Converts
input_ptrinto a Rust&strviaCStr::from_ptr. ReturnsFFI_ERR_INVALID_UTF8(-2) if UTF-8 parsing fails. - Parses the
TEMPvalue. If temperature is greater than50.0, output string is"ALERT: OVERHEAT", otherwise"STATUS: NORMAL". - Copies the result plus trailing null byte (
\0) intoout_buf. ReturnsFFI_ERR_BUFFER_TOO_SMALL(-3) ifout_capis insufficient. - 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
- C-String Parsing (
CStr::from_ptr):CStr::from_ptrreads a raw C pointer up to its terminating null byte (0x00). Calling.to_str()verifies valid UTF-8 sequence without allocation or memory copy. - Null Pointer Safeguards: Dereferencing a null pointer causes segmentation faults. Explicitly checking
input_ptr.is_null()andout_buf.is_null()returns error status codes cleanly across the ABI. - Manual Buffer Writes & Null Termination:
ptr::copy_nonoverlappingcopies string raw bytes into the foreign buffer, and*out_buf.add(len) = 0explicitly 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:
- Define a Rust struct
SessionContext { session_id: u64, active_keys: Vec<u32> }. - Implement
pub extern "C" fn session_create(session_id: u64) -> *mut SessionContext: AllocatesSessionContexton the heap and returns a raw opaque handle usingBox::into_raw. - Implement
pub extern "C" fn session_add_key(handle: *mut SessionContext, key: u32) -> i32: Validates non-null handle, mutates state by pushingkey, and returns0(or-1if null). - Implement
pub extern "C" fn session_key_count(handle: *const SessionContext) -> i32: Queries key count via shared reference, returning-1if handle is null. - Implement
pub extern "C" fn session_destroy(handle: *mut SessionContext) -> i32: Reclaims heap allocation usingBox::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
Box::into_rawTransfer:Box::into_rawconverts a heap-allocatedBox<T>into a raw pointer*mut Twithout invoking destructors. Ownership of the raw memory address is handed off to the foreign C caller.Box::from_rawDestruction: When foreign code callssession_destroy,Box::from_raw(handle)takes back ownership of the raw pointer. When_boxedgoes out of scope, Rust automatically drops bothSessionContextand its nestedVec<u32>heap memory.- Raw Reference Casting (
&*handle,&mut *handle): Dereferencing valid raw pointers insideunsafeblocks 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:
- A function pointer passed from foreign code might be
None(representing a CNULLpointer). - 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:
- 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)>. - Implement
pub extern "C" fn dispatch_event_safe(cb: EventCallback, code: u32, val: i32, user_data: *mut std::ffi::c_void) -> i32:- If
cbisNone, returnFFI_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, returnFFI_ERR_PANIC_CAUGHT(-2).
- If
Include unit tests covering:
- Normal event dispatch updating context state through
user_data(*mut c_void). - Rejection of
Nonefunction 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
-
Option<unsafe extern "C" fn(...)>Null Safety: Rust function pointers in FFI are represented insideOption. ANonevariant corresponds to a CNULLpointer, enabling idiomatic pattern matching for function pointer validation. -
Panic Boundary Isolation (
catch_unwind): Unwinding acrossextern "C"function boundaries is Undefined Behavior in Rust.catch_unwindcatches unwinding panics at the boundary and returns an integer status code (-2) cleanly. -
*mut c_voidOpaque Context: C callbacks usevoid* user_datato pass application context state. Casting*mut c_voidback to*mut CustomStateallows inspecting and mutating caller environment state.
6. Related Terms
extern "C"— The ABI specification keyword for declaring C calling conventions.unsafeBlock — The required block construct for invoking FFI functions.- Raw Pointers (
*const T,*mut T) — Pointers used to transfer data memory addresses across FFI boundaries. Function Pointers(fn()) — Related concept:Function Pointers(fn()).- Build Scripts (
build.rs) — Related concept: Build Scripts (build.rs). MaybeUninit<T>— Related concept:MaybeUninit<T>.bindgen— Related concept:bindgen.cbindgen— Related concept:cbindgen.- Undefined Behavior (UB) — Related concept: Undefined Behavior (UB).
unsafe fn— Related concept:unsafe fn.CString/CStr— Related concept:CString/CStr.
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 insideunsafe { ... }blocks. - Exported Rust functions use
#[no_mangle] pub extern "C" fn. - Use
std::ffi::CStringandstd::ffi::CStrfor passing null-terminated strings across FFI boundaries. - Always prevent Rust panics from unwinding across FFI boundaries using
std::panic::catch_unwind.