rust · level 9

unsafe & FFI

When to reach for unsafe, raw pointers, extern "C", ABI matching.

175 XP

unsafe & FFI

unsafe is the most misunderstood keyword in Rust. It does not turn off the type system, the borrow checker, or move semantics. It unlocks five specific operations the compiler cannot verify on its own. Everything else still applies.

The five superpowers of an unsafe block:

  1. Dereference a raw pointer (*const T, *mut T).
  2. Call an unsafe fn or unsafe trait method.
  3. Access or modify a mutable static (static mut).
  4. Implement an unsafe trait.
  5. Access fields of a union.

That's it. Inside an unsafe block, you still cannot leak memory through Rc cycles, you still cannot have two &mut to the same value, you still cannot use a moved value. The compiler doesn't go on holiday — it just lets you do five specific things you couldn't otherwise do.

When to reach for unsafe

In application code: almost never. The standard library and ecosystem cover essentially everything you need.

In library code: when you must, with extreme care:

  • Implementing a data structure that can't be expressed with safe primitives (e.g. an intrusive linked list, a custom allocator).
  • Calling foreign functions (extern "C" blocks).
  • Accessing CPU intrinsics (SIMD, atomic operations, inline assembly).
  • Optimising a hot loop with bounds-check elision (get_unchecked).

The pattern: implement the unsafe primitive, then wrap it in a safe API that upholds the necessary invariants. Auditors only need to verify the unsafe block, not every caller.

Raw pointers

let mut x = 5;
let p_const: *const i32 = &x;            // immutable raw pointer
let p_mut: *mut i32 = &mut x;            // mutable raw pointer

// Reading or writing through them requires unsafe:
unsafe {
    println!("{}", *p_const);
    *p_mut = 99;
}

Raw pointers can be null, dangling, unaligned, or aliased — the compiler doesn't track their validity. You take responsibility.

The *const T / *mut T distinction documents intent but doesn't enforce mutability — both are convertible. They are NOT references, so they don't have lifetimes and don't participate in borrow checking.

unsafe fn

A function whose preconditions cannot be expressed in the type system:

unsafe fn read_bytes(ptr: *const u8, len: usize) -> Vec<u8> {
    let slice = std::slice::from_raw_parts(ptr, len);
    slice.to_vec()
}

// Caller takes responsibility for the contract:
let v = vec![1u8, 2, 3];
let copy = unsafe { read_bytes(v.as_ptr(), v.len()) };

The function declares: "I require ptr to be non-null, properly aligned, and pointing to at least len valid bytes. Pinky-swear and you're fine; lie and it's UB."

FFI — calling C from Rust

Declare the foreign function in an extern "C" block:

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

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

fn main() {
    let s = CString::new("hello from C").unwrap();
    unsafe {
        puts(s.as_ptr());
    }
}

Three rules:

  1. The extern "C" block declares the ABI — "C" is the universal default. Other ABIs include "system" (Win32), "cdecl", "stdcall".
  2. Calling any function from the block is unsafe because the compiler can't check what foreign code does.
  3. Argument and return types must be FFI-safe — c_int, c_char, raw pointers, #[repr(C)] structs, etc. Don't cross the boundary with String, Vec, Option<&T>, etc.

Calling Rust from C

Export a function with extern "C" and a name-mangling escape:

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

#[unsafe(no_mangle)] keeps the symbol name as rust_add (rather than something like _ZN5myapp9rust_add17h...E). The unsafe(...) wrapper around the attribute is the Rust 1.82+ form — it makes the safety contract explicit (you've taken responsibility for any name collisions).

In your Cargo.toml:

[lib]
crate-type = ["cdylib"]    # or "staticlib"

Then C code:

#include <stdint.h>
int32_t rust_add(int32_t a, int32_t b);

int main() {
    return rust_add(1, 2);
}

#[repr(C)] — layout-compatible structs

By default, Rust may reorder struct fields and choose its own padding. To pass a struct across the FFI boundary, opt into the C layout:

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

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

#[repr(C)] guarantees:

  • Fields are in declaration order.
  • Each field is at the alignment a C compiler would choose.
  • Trailing padding to the size a C compiler would compute.

Other layout repr attributes:

  • #[repr(transparent)] — single-field newtype that has the same layout as its inner type. Use for newtype wrappers around *mut T etc.
  • #[repr(u8)] etc. on enums — fix the discriminant size.
  • #[repr(packed)] — no padding (rare; can produce unaligned reads).

Strings across FFI

C uses null-terminated bytes; Rust uses length-prefixed UTF-8. The bridge is CString / CStr:

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

extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

let cs = CString::new("hello").expect("no interior null");
let n = unsafe { strlen(cs.as_ptr()) };
println!("{}", n);   // 5

// Going the other way — C → Rust:
extern "C" {
    fn get_message() -> *const c_char;
}
unsafe {
    let cstr = CStr::from_ptr(get_message());
    let s: &str = cstr.to_str().unwrap();    // bytes → utf-8 str
    println!("{}", s);
}

CString::new fails if the input contains an interior null byte. CStr::to_str fails if the bytes aren't valid UTF-8.

Don't unwind into C

Panicking across the FFI boundary is undefined behaviour. Two strategies:

  1. Set panic = "abort" in [profile.release] so panics terminate the process instead of unwinding.

  2. Wrap any panicking code in std::panic::catch_unwind and convert to an error code:

    #[unsafe(no_mangle)]
    pub extern "C" fn rust_op() -> i32 {
        std::panic::catch_unwind(|| { /* code that may panic */ })
            .map(|_| 0)
            .unwrap_or(-1)
    }
    

Tools to make FFI bearable

  • bindgen — generate Rust bindings from C/C++ headers. Output is extern "C" blocks plus #[repr(C)] structs — eliminates the manual transcription that breaks when the upstream header changes.
  • cbindgen — generate C/C++ headers from a Rust crate's extern "C" API. The reverse direction.
  • miri — interpret Rust to detect undefined behaviour in unsafe code. cargo miri test is a near-essential pre-merge check for any crate with non-trivial unsafe.

Auditing checklist for unsafe

When reviewing an unsafe block, ask:

Question Why
Are all raw pointers non-null, aligned, valid? Dereferencing otherwise is UB.
Is the lifetime of any constructed reference correct? A returned &'static T from leaked memory must really be valid forever.
Does the safety contract cover every reachable code path? Especially error paths and early returns.
Is the unsafe block as small as possible? Smaller blocks are easier to audit.
Does the safe wrapper preserve the invariants the unsafe block assumes? Documented in # Safety doc-comments.

Treat unsafe like a # SAFETY: mini-proof. The reviewer should be able to read 5 lines of comment and be convinced.

What unsafe is not

A common confusion: unsafe fn does NOT mean "this function might be wrong". It means "this function has preconditions the type system can't express, and the caller must uphold them". Vec::set_len is unsafe not because Rust's authors don't know how to set a length, but because growing len past the actual initialised data is UB.

The discipline: use unsafe when you must, audit it carefully, expose a safe API. Most production Rust crates have very little unsafe — and when they do, it's concentrated in a few well-commented blocks.

Tools in the wild

4 tools
  • bindgenfree tier

    Generate Rust FFI bindings from C/C++ headers automatically.

    cli
  • cbindgenfree tier

    Generate C/C++ headers from a Rust crate's `extern "C"` API. The reverse of bindgen.

    cli
  • mirifree tier

    Interpret Rust to detect undefined behaviour in unsafe code. Run as `cargo miri test`.

    cli
  • crossfree tier

    Cross-compile Rust to other platforms — essential for shipping FFI libraries.

    cli