RustForeign Function Interface (FFI)

Foreign Function Interface

The Foreign Function Interface (FFI) is the mechanism that lets Rust call code written in other languages and lets other languages call Rust. In practice, FFI almost always means interoperating with C — the lingua franca of systems programming — because virtually every language can produce and consume C-compatible binaries.

FFI is inherently unsafe. C gives no memory-safety, lifetime, or thread-safety guarantees. Every call across the FFI boundary must be wrapped in an unsafe block, and the programmer is responsible for upholding every invariant that Rust would normally enforce automatically.

Calling a C Function from Rust

Use an extern "C" block to declare the signature of a function that lives in a C library. The "C" string specifies the calling convention — the low-level ABI that governs how arguments and return values are passed. "C" is the standard C ABI, supported on all platforms.

RUST
// Tell the linker that 'abs' and 'sqrt' exist in the C standard library
extern "C" {
    fn abs(input: i32) -> i32;
    fn sqrt(x: f64) -> f64;
}

fn main() {
    // All calls to foreign functions require unsafe
    unsafe {
        println!("abs(-5)  = {}", abs(-5));
        println!("sqrt(16) = {}", sqrt(16.0));
    }
}
abs(-5)  = 5
sqrt(16) = 4
Note
The Rust standard library already wraps the most common C functions. You rarely need to declare abs yourself — this example is illustrative. For real C interop you will typically use the libc crate.
The libc Crate

The libc crate provides comprehensive, cross-platform definitions for all C types and standard C library functions. Always use it instead of manually mapping C types, because sizes differ between platforms and architectures.

Add it to Cargo.toml:

TOML
[dependencies]
libc = "0.2"

RUST
use libc::{c_int, c_double};

extern "C" {
    fn abs(x: c_int) -> c_int;
    fn pow(base: c_double, exp: c_double) -> c_double;
}

fn main() {
    unsafe {
        println!("abs(-42) = {}", abs(-42));
        println!("2^10     = {}", pow(2.0, 10.0));
    }
}
abs(-42) = 42
2^10     = 1024

C type

libc type

Typical Rust equivalent

int

c_int

i32

unsigned int

c_uint

u32

long

c_long

i64 on 64-bit

char *

c_char (as *mut c_char)

*mut i8

void *

c_void (as *mut c_void)

*mut ()

double

c_double

f64

float

c_float

f32

size_t

size_t

usize

Linking External Libraries

Use #[link(name = "...")] to tell the linker which native library provides the declarations in an extern block. For most system libraries the linker finds them automatically; for others you need to be explicit.

RUST
// Link against libm (the C math library)
#[link(name = "m")]
extern "C" {
    fn sin(x: f64) -> f64;
    fn cos(x: f64) -> f64;
}

fn main() {
    let pi = std::f64::consts::PI;
    unsafe {
        println!("sin(pi/2) = {:.4}", sin(pi / 2.0));
        println!("cos(pi)   = {:.4}", cos(pi));
    }
}
sin(pi/2) = 1.0000
cos(pi)   = -1.0000
Tip
For complex linking requirements (multiple libraries, conditional linking, build-time configuration) write a build.rs script and useprintln!("cargo:rustc-link-lib=...") to instruct Cargo at build time.
Exposing Rust Functions to C

To make a Rust function callable from C:

  1. Declare it pub extern "C" — use the C calling convention.
  2. Add #[no_mangle] — prevent the Rust compiler from mangling the symbol name. Without this, the compiled symbol name becomes something C cannot recognise.

RUST
// Callable from C as: int add(int a, int b);
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Callable from C as: double multiply(double x, double y);
#[no_mangle]
pub extern "C" fn multiply(x: f64, y: f64) -> f64 {
    x * y
}

// Returns a C-compatible bool
#[no_mangle]
pub extern "C" fn is_even(n: i32) -> bool {
    n % 2 == 0
}

In Cargo.toml, set the crate type to produce a C-compatible shared library:

TOML
[lib]
crate-type = ["cdylib"]   # .so on Linux, .dylib on macOS, .dll on Windows
C-Compatible Struct Layouts with repr(C)

By default, the Rust compiler may reorder struct fields to optimise alignment and size. C makes no such reordering — field order matches declaration order exactly. When passing structs across the FFI boundary, annotate them with #[repr(C)] to guarantee C-compatible memory layout.

RUST
// Without #[repr(C)]: Rust may reorder fields — unsafe to pass to C
struct BadPoint { y: f32, x: f32 }

// With #[repr(C)]: field order is guaranteed to match the declaration
#[repr(C)]
struct Point { x: f32, y: f32 }

#[repr(C)]
struct Color { r: u8, g: u8, b: u8, a: u8 }

fn main() {
    let p = Point { x: 1.0, y: 2.0 };
    let c = Color { r: 255, g: 128, b: 0, a: 255 };
    println!("Point ({}, {})", p.x, p.y);
    println!("Color ({}, {}, {}, {})", c.r, c.g, c.b, c.a);
}
Point (1, 2)
Color (255, 128, 0, 255)
Passing Strings Across FFI

Rust strings (String, &str) are not null-terminated and may contain null bytes. C strings are null-terminated and cannot contain null bytes. The bridge types are CString (owned, null-terminated) and CStr (borrowed, null-terminated):

  • Rust to C: create a CString, then call .as_ptr() for a *const c_char
  • C to Rust: wrap the raw pointer with CStr::from_ptr(ptr), then call .to_str()

RUST
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

// Rust -> C: wrap a Rust &str in a CString before passing to C
fn demonstrate_to_c(s: &str) {
    // CString::new errors if the string contains a null byte
    let c_string = CString::new(s).expect("interior null byte");

    // as_ptr() is valid as long as c_string is alive
    let ptr: *const c_char = c_string.as_ptr();
    println!("raw pointer: {:?}", ptr);
    // pass ptr to a C function here — c_string must stay in scope until done
}

// C -> Rust: read a *const c_char as a &str
unsafe fn receive_from_c(ptr: *const c_char) -> &'static str {
    // SAFETY: caller guarantees ptr is a valid, null-terminated UTF-8 C string.
    CStr::from_ptr(ptr)
        .to_str()
        .expect("C string contains invalid UTF-8")
}

fn main() {
    demonstrate_to_c("Hello from Rust!");
}
Warning
Never hold a pointer from .as_ptr() after theCString that produced it is dropped — the pointer becomes dangling immediately. Always bind the CString to a named variable to ensure it lives long enough.
Null Pointers

C APIs frequently use null pointers as sentinel values — "no result found" or "optional argument not provided". In Rust, use std::ptr::null() and std::ptr::null_mut() to create null raw pointers, and always check before dereferencing.

RUST
use std::ptr;

fn process_optional(ptr: *const i32) {
    if ptr.is_null() {
        println!("received null pointer — nothing to process");
    } else {
        // SAFETY: we just checked for null; assume caller guarantees valid alignment.
        let value = unsafe { *ptr };
        println!("value: {}", value);
    }
}

fn main() {
    let x = 42;
    let valid: *const i32 = &x;
    let null:  *const i32 = ptr::null();

    process_optional(valid);
    process_optional(null);
}
value: 42
received null pointer — nothing to process
Automating Bindings with bindgen and cbindgen

Manually writing extern "C" declarations for large C APIs is tedious and error-prone. Two tools automate this:

  • bindgen — reads a C header file (.h) and generates the corresponding Rust extern "C" declarations, #[repr(C)] structs, and type aliases. Run it from a build.rs script or as a one-off CLI tool (cargo install bindgen-cli).

  • cbindgen — the reverse: reads a Rust library and generates a C header file that declares all pub extern "C" functions and #[repr(C)] types. Useful when distributing a Rust library for C consumers.

TOML
# Cargo.toml — add bindgen as a build dependency
[build-dependencies]
bindgen = "0.69"

RUST
// build.rs — auto-generate bindings from a C header at build time
fn main() {
    println!("cargo:rerun-if-changed=src/mylib.h");

    let bindings = bindgen::Builder::default()
        .header("src/mylib.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("failed to generate bindings");

    let out_path = std::path::PathBuf::from(
        std::env::var("OUT_DIR").unwrap()
    );
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("failed to write bindings");
}

RUST
// src/lib.rs — include the generated bindings
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
FFI Safety Checklist
  1. Annotate every unsafe block that calls foreign code with a // SAFETY: comment.

  2. Use #[repr(C)] on every struct passed across the boundary.

  3. Check raw pointers for null before dereferencing.

  4. Null-terminate strings with CString before passing them to C.

  5. Never return Rust references or Box pointers to C — pass raw pointers and document ownership clearly.

  6. Match the C library's ownership model: if C expects to free memory you allocated, expose a dedicated free_xxx function.

  7. Use libc types (c_int, c_char, etc.) rather than assuming Rust primitives match.

Success
FFI is one of Rust's most powerful features for systems programming. By combining #[repr(C)], CString/CStr,bindgen, and careful unsafe discipline, Rust can interoperate with virtually any C library while still providing a safe wrapper layer that protects the rest of your codebase.