FFI — Calling C from Rust

Rust's guarantees stop at the C boundary. The compiler can't see into a C function, so it can't verify lifetimes, aliasing, or whether a pointer is valid, every FFI call is an unsafe call for exactly that reason. Unsafe Rust Basics covered what unsafe unlocks; FFI is where those raw pointers and unsafe fns show up in real code. This tutorial covers declaring foreign functions, matching C's memory layout, passing data (especially strings) across the line, and the ownership rules that prevent leaks and double-frees.


extern Blocks: Declaring Foreign Functions

An extern "C" block declares functions that exist in a C library, telling Rust their signatures and that they use the C calling convention. The declarations are just promises; the actual linking happens against a library you tell Cargo about (typically via a build.rs and the cc crate, or a system library).

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

extern "C" {
    fn abs(input: c_int) -> c_int;
    fn sqrt(input: f64) -> f64;
}

fn main() {
    unsafe {
        println!("{}", abs(-5));   // 5
        println!("{}", sqrt(2.0)); // 1.414...
    }
}

Every call sits inside unsafe: Rust is trusting that the signature you wrote matches the real C function exactly. Get the type wrong, declare abs as taking an f64, and there's no compile error, just undefined behavior at runtime, because the compiler has no C source to check against. The types in std::os::raw (c_int, c_char, c_void, ...) exist precisely so you can spell out C's types accurately rather than guessing that c_int is always i32.


#[repr(C)]: Matching C's Memory Layout

Rust makes no guarantee about how it lays out struct fields, it may reorder them for packing efficiency. C has a fixed, predictable layout. Any struct that crosses the FFI boundary must be annotated #[repr(C)] to force the C-compatible layout, otherwise the two sides disagree about where each field lives in memory.

#[repr(C)]
struct Point {
    x: f64,
    y: f64,
}

extern "C" {
    fn distance(a: *const Point, b: *const Point) -> f64;
}

#[repr(C)] guarantees field order matches declaration order and that alignment/padding follow C's rules, so a Point written by Rust and read by C (or vice versa) agree on the byte layout. The same attribute applies to enums crossing the boundary: a plain Rust enum has no guaranteed integer representation, so use #[repr(C)] or an explicit #[repr(u8)]/#[repr(i32)] to pin it to a known C-compatible integer type.


Strings: The Most Common Boundary Hazard

Rust String/&str are UTF-8 and carry a length; they are not null-terminated. C strings are null-terminated char* with no length. They are fundamentally different representations, you can't pass one as the other, and this is where most FFI bugs originate. The std::ffi module provides the two bridge types: CString (an owned, null-terminated string you build to pass into C) and CStr (a borrowed view used to read a string C handed back).

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

extern "C" {
    fn puts(s: *const c_char) -> c_int;
    fn getenv(name: *const c_char) -> *const c_char;
}

fn print_via_c(text: &str) {
    let c_string = CString::new(text).expect("text contained an interior null byte");
    unsafe {
        puts(c_string.as_ptr());
    }
    // c_string stays alive until here; dropping it frees the buffer puts() read
}

fn read_env(name: &str) -> Option<String> {
    let c_name = CString::new(name).ok()?;
    unsafe {
        let ptr = getenv(c_name.as_ptr());
        if ptr.is_null() {
            return None;
        }
        Some(CStr::from_ptr(ptr).to_string_lossy().into_owned())
    }
}

Two things to internalize here. First, CString::new can fail, if the input contains an interior null byte, because that would truncate the string from C's perspective; it returns a Result, not a panic. Second, lifetime is everything: c_string.as_ptr() borrows the CString's buffer, so the CString must outlive the C call that reads through the pointer.

Gotcha: puts(CString::new(text).unwrap().as_ptr()) on one line is a use-after-free. The CString is a temporary that drops at the semicolon, before puts runs, so puts reads a freed buffer. Always bind the CString to a let first (let s = CString::new(text)?;) so it outlives the call, then pass s.as_ptr().


Ownership Across the Boundary: Who Frees What

The compiler can't track ownership through C, so you must establish, by reading the C library's documentation, who is responsible for freeing each allocation. Get this wrong and you either leak (nobody frees) or double-free (both sides free). There are two directions:

C allocates, C frees. If a C function returns a pointer to memory it owns, you must call the library's corresponding free function when done, not let Rust try to free it. Rust never frees a pointer it didn't allocate.

Rust allocates, C borrows (or takes ownership). When you hand Rust-owned memory to C, you must keep it alive for as long as C uses it. If C takes ownership (will free it later itself), you have to prevent Rust's destructor from running, this is the std::mem::forget / ManuallyDrop case from Drop, RAII, and Resource Cleanup. Box::into_raw is the idiomatic way to do this: it converts a Box into a raw pointer and deliberately leaks ownership, handing the cleanup obligation to whoever receives the pointer.

// hand ownership of a heap value to C; C (or a later FFI call) must free it
let boxed = Box::new(Point { x: 1.0, y: 2.0 });
let raw: *mut Point = Box::into_raw(boxed);  // Rust will NOT free this now

// ... later, to reclaim and free it on the Rust side:
unsafe {
    let _reclaimed = Box::from_raw(raw);  // ownership back in Rust; freed at end of scope
}

Box::into_raw and Box::from_raw are an exact pair: into_raw releases Rust's ownership, from_raw takes it back. Every pointer that crosses into C must have a clearly-decided answer to "which side frees this, and when", that decision is yours to make and document, because no tool will check it for you.


Wrapping Unsafe FFI in a Safe API

The same principle from Unsafe Rust Basics applies with full force here: contain the unsafe inside a safe wrapper so callers never touch it. The read_env function above is a small example, its callers get a clean Option<String> and never see a raw pointer, a CStr, or an unsafe block. A well-designed FFI binding crate exposes only safe functions and types, with all the pointer juggling, null checks, lifetime management, and ownership decisions sealed inside. This is exactly what -sys crates plus their safe wrapper crates do across the ecosystem: the -sys crate is the raw extern declarations, and the wrapper turns them into an idiomatic, safe Rust API.


Quick Reference

Crossing the boundaryReach for
Declare a C functionextern "C" { fn ... } (every call is unsafe)
C-compatible typesstd::os::raw (c_int, c_char, c_void)
C-compatible struct/enum layout#[repr(C)] (or #[repr(u8)] for enums)
Pass a Rust string into CCString (bind to a let, then .as_ptr())
Read a C string backCStr::from_ptr(...).to_string_lossy()
Hand heap ownership to CBox::into_raw
Reclaim heap ownership from CBox::from_raw (matched pair)

Always decide and document which side frees each pointer — no tool checks it for you.


Key Takeaways

  • Every FFI call is unsafe because the compiler can't verify a C function's contract; the signature you write in an extern "C" block is an unchecked promise, a mismatch is undefined behavior, not a compile error.
  • Use std::os::raw types (c_int, c_char, ...) for accuracy, and annotate any struct or enum crossing the boundary with #[repr(C)] so Rust and C agree on memory layout.
  • Rust strings and C strings are different representations (UTF-8 + length vs. null-terminated). Bridge with CString (to pass into C) and CStr (to read from C); CString::new fails on interior null bytes, and the CString must outlive any pointer borrowed from it.
  • Decide and document who frees each allocation crossing the boundary. Use Box::into_raw/Box::from_raw as a matched pair to transfer heap ownership; never free a pointer the other side allocated.
  • Seal all the unsafety, pointers, null checks, and ownership rules inside a safe wrapper API, so callers work with ordinary Rust types and never write unsafe themselves (the -sys + safe-wrapper crate split).