Skip to content
AgileLogics
8 min read

FFI & Interop with C

Prerequisites: Unsafe Rust

C's calling convention is the lingua franca of systems software. Rust can call C libraries (and anything exposing a C ABI) and can expose functions that C, Python, Node.js or C# can call. This boundary is the most common place for unsafe in real projects.

Calling C from Rust

Declare foreign functions in an extern "C" block. In the 2024 edition the block itself must be written unsafe extern, because you are asserting the signatures are correct — the compiler cannot check them against the C header. Items inside can be marked safe when calling them can't cause UB for any argument.

use std::ffi::{CString, c_char, c_int};

unsafe extern "C" {
    // abs is defined for every int, so it can be declared safe.
    safe fn abs(x: c_int) -> c_int;
    // strlen reads until a NUL byte: the caller must pass a valid C string.
    fn strlen(s: *const c_char) -> usize;
}

fn main() {
    println!("{}", abs(-7));

    let s = CString::new("hello").expect("no interior NUL bytes");
    // SAFETY: `s` is a valid NUL-terminated string that outlives the call.
    let n = unsafe { strlen(s.as_ptr()) };
    println!("{n}");
}

Expected output:

7
5

Both functions come from the C standard library, which Rust programs already link. For your own C library you tell Cargo what to link, typically from a build.rs script:

// build.rs
fn main() {
    println!("cargo::rustc-link-search=native=vendor/lib");
    println!("cargo::rustc-link-lib=static=mylib");
}

The cc crate can compile bundled C sources from build.rs as well.

Strings across the boundary

Rust strings are length-prefixed UTF-8 without a terminator; C strings are NUL-terminated bytes. Two std types bridge them:

  • CString — an owned, NUL-terminated string you create in Rust and pass to C (as_ptr()). CString::new fails if the input contains \0.
  • CStr — a borrowed view of a C string you received from C. CStr::from_ptr is unsafe; to_str() checks UTF-8, to_string_lossy() replaces invalid bytes.

Use the c_int, c_char, c_long, ... aliases from std::ffi rather than guessing sizes: long is 32 bits on Windows and 64 bits on 64-bit Linux.

Exposing Rust to C

Mark a function extern "C" so it uses the C calling convention, and #[unsafe(no_mangle)] so its symbol keeps its plain name. (The attribute is written with unsafe(...) in the 2024 edition because two exported symbols with the same name are undefined behaviour.)

[lib]
crate-type = ["cdylib"]   # a .so / .dylib / .dll; use "staticlib" for a .a / .lib
use std::ffi::{CStr, CString, c_char};

#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a.wrapping_add(b)
}

/// Returns a newly allocated greeting. Free it with `greeting_free`.
///
/// # Safety
///
/// `name` must be null or a valid NUL-terminated string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn greeting_new(name: *const c_char) -> *mut c_char {
    if name.is_null() {
        return std::ptr::null_mut();
    }
    // SAFETY: checked for null above; the caller guarantees validity.
    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
    CString::new(format!("Hello, {name}!"))
        .map(CString::into_raw)
        .unwrap_or(std::ptr::null_mut())
}

/// # Safety
///
/// `s` must be null or a pointer returned by `greeting_new`, freed only once.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn greeting_free(s: *mut c_char) {
    if !s.is_null() {
        // SAFETY: the pointer came from CString::into_raw.
        drop(unsafe { CString::from_raw(s) });
    }
}

The matching C header and usage:

#include <stdint.h>
#include <stdio.h>

int32_t add(int32_t a, int32_t b);
char *greeting_new(const char *name);
void greeting_free(char *s);

int main(void) {
    printf("%d\n", add(2, 3));
    char *g = greeting_new("C");
    printf("%s\n", g);
    greeting_free(g);  /* never call free() on memory Rust allocated */
    return 0;
}

The rule of ownership across FFI: whoever allocates, frees. Memory from Rust's allocator goes back to Rust; memory from malloc goes back to free.

Generating bindings

Writing declarations by hand is error-prone for large APIs:

  • bindgen reads C headers and generates the Rust extern declarations (usually from build.rs). Wrap the raw -sys crate in a safe crate.
  • cbindgen does the reverse: it generates a C header from your Rust extern "C" functions and #[repr(C)] types.

Structs passed by value across the boundary must be #[repr(C)] so their field order and padding match C's.

Common mistakes

  • Writing extern "C" { ... } in the 2024 edition:
error: extern blocks must be unsafe
  • Passing CString::new(s).unwrap().as_ptr() directly. The temporary CString is dropped at the end of the statement, leaving a dangling pointer. Bind the CString to a variable first.
  • Letting a panic reach C. A panic that tries to unwind out of an extern "C" function aborts the process; catch it with std::panic::catch_unwind if the caller must see an error code.
  • Using Rust types without a stable layout (String, Vec, enums with data, structs without #[repr(C)]) in C signatures.
  • Mismatched integer sizes. Declare what the C header says, via std::ffi aliases.

Practice

Sort an array of i32 with the C standard library's qsort. Declare it in an unsafe extern "C" block and write the comparison callback as an extern "C" fn.

Show solution
use std::ffi::{c_int, c_void};

unsafe extern "C" {
    fn qsort(
        base: *mut c_void,
        count: usize,
        size: usize,
        compare: extern "C" fn(*const c_void, *const c_void) -> c_int,
    );
}

extern "C" fn compare_i32(a: *const c_void, b: *const c_void) -> c_int {
    // SAFETY: qsort passes pointers to elements of our i32 array.
    let (a, b) = unsafe { (*(a as *const i32), *(b as *const i32)) };
    a.cmp(&b) as c_int
}

fn main() {
    let mut values = [42, -3, 17, 0, 8];
    // SAFETY: base/count/size describe `values` exactly, and the callback
    // matches the element type.
    unsafe {
        qsort(
            values.as_mut_ptr().cast(),
            values.len(),
            std::mem::size_of::<i32>(),
            compare_i32,
        );
    }
    println!("{values:?}");
}

This prints [-3, 0, 8, 17, 42]. Ordering converts to -1, 0 or 1, which is exactly what qsort expects.