<< Home

Chapter 15: Unsafe Rust and FFI

Rust’s safety guarantees are strict by design. When you work inside safe Rust the compiler prevents data races, null pointer dereferences, unaligned loads, and many other hazards. Sometimes you must perform operations that the compiler cannot verify. The unsafe keyword opens carefully controlled escape hatches. This chapter explains what unsafe permits, how raw pointers work, how to manage layouts and aliasing, how to call C code, and how to build safe abstractions that wrap unsafe internals.

💡 Unsafe does not mean unchecked chaos. The language still enforces many invariants. Unsafe blocks let you assert that specific operations uphold Rust’s rules even though the compiler cannot confirm them.

The unsafe keyword and guarantees

The unsafe keyword allows operations that require manual proof of correctness. These include dereferencing raw pointers, calling unsafe functions, accessing mutable static variables, implementing unsafe traits, and performing certain intrinsics. Outside these actions the compiler continues to enforce Rust’s standard checks. You must ensure that each unsafe action upholds memory safety, aliasing guarantees, and concurrency rules.

What unsafe can and cannot do

unsafe does not disable borrowing rules or allow unrestricted memory access. It grants permission only for narrowly defined operations. You still cannot violate Rust’s aliasing rules or type system. Unsafe code is responsible for maintaining invariants that safe code relies upon.

unsafe fn add_one(p: *mut i32) {
  // Caller must guarantee `p` is valid for writes.
  *p += 1;
}

fn main() {
  let mut x = 10;
  let p = &mut x as *mut i32;
  unsafe { add_one(p) };
  println!("{x}");
}

The surrounding safe code must uphold the assumptions required by the unsafe function. These assumptions form part of the function’s contract.

Unsafe traits

A trait becomes unsafe when incorrect implementations would break invariants that the compiler depends on. For example Send and Sync are unsafe traits because the compiler relies on their correctness for concurrency safety. You must audit unsafe trait implementations with care.

⚠️ Place the smallest amount of code inside an unsafe block. A tiny unsafe boundary is easier to audit.

Raw pointers and references

Rust has two raw pointer types: *const T and *mut T. They behave like C pointers. They can be null; they can be unaligned; they do not carry lifetimes; and they are not subject to borrow checking. Turning them into references safely requires checking alignment, validity, and aliasing conditions.

Creating and using raw pointers

You can obtain raw pointers from references with as casts. Converting back to references requires an unsafe block because the compiler cannot validate pointer correctness.

fn raw_pointer_roundtrip() {
  let mut x = 5;
  let p: *mut i32 = &mut x;

  unsafe {
    // safe because `p` points to a valid, aligned, writable i32
    *p = 20;
    let r: &i32 = &*p;
    println!("{}", r);
  }
}

Because raw pointers are not subject to Rust’s borrowing rules they enable low level data structures or bindings. You must preserve all safety invariants manually.

Null pointers and pointer arithmetic

Raw pointers permit null and arithmetic; references do not. Pointer arithmetic is unsafe because it risks leaving the valid object or slice bounds. In FFI contexts you often inspect C arrays through raw pointers and perform manual iteration.

unsafe fn sum(ptr: *const u32, len: usize) -> u32 {
  let mut acc = 0;
  for i in 0..len {
    acc += *ptr.add(i);
  }
  acc
}

This style parallels C code. You must ensure that the pointer covers len readable elements and that alignment requirements hold.

Layouts, repr, and aliasing

Rust’s default layout for structs and enums is implementation defined. When interfacing with foreign code you need predictable layouts. The repr attribute controls field ordering, padding, and ABI conventions. Aliasing rules affect how the compiler optimises loads and stores; violating them leads to undefined behaviour.

Controlling structure layout with repr

Use repr(C) to match C layouts. This arranges fields in a stable order with standard ABI alignment. Use repr(transparent) to wrap a single field without adding padding. Use repr(u8) or similar to fix discriminant sizes for fieldless enums.

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

This struct now matches an equivalent C struct with two double fields. Without repr(C) the Rust compiler may reorder or pad fields differently.

Aliasing and the strict provenance model

Rust assumes references obey strict aliasing. A mutable reference guarantees exclusive access to its target. Raw pointers may alias but converting them into references must not violate exclusivity. The strict provenance model requires that pointers originate from valid bases. Because of this optimisations assume no unexpected aliasing through mutable references.

⚠️ Never create two mutable references to the same location at the same time. Doing so breaks Rust’s aliasing rules even inside unsafe blocks.

Alignment and uninitialised memory

Types have alignment requirements. Loading from misaligned addresses is undefined on many platforms. Use std::ptr::read_unaligned and friends for unaligned work. For uninitialised memory use MaybeUninit, which avoids undefined behaviour while constructing complex values.

use std::mem::MaybeUninit;

fn build_pair(a: i32, b: i32) -> (i32, i32) {
  let mut slot: MaybeUninit<(i32, i32)> = MaybeUninit::uninit();
  unsafe {
    slot.as_mut_ptr().write((a, b));
    slot.assume_init()
  }
}

MaybeUninit gives you space without telling Rust that the contents are valid until you initialise them manually.

Calling C from Rust

Foreign function interfaces allow Rust to call C libraries and vice versa. The extern "C" ABI ensures compatible call conventions. You declare function signatures in an extern block and link against system or bundled libraries. Arguments and return types must match the C definitions exactly.

Declaring C functions

Declare an external function with extern "C". Use primitive types or repr(C) structs to mirror the C side.

// Rust declaration
extern "C" {
  fn abs(x: i32) -> i32;
}

fn main() {
  unsafe {
    let v = abs(-10);
    println!("{v}");
  }
}

Calling foreign functions is unsafe because Rust cannot verify the validity of pointers or other invariants expected by the C function.

Passing pointers and buffers

FFI often involves passing pointers to buffers. You must ensure the lifetime, size, and alignment meet C’s expectations. A slice converts to a pointer plus length with methods such as as_ptr and len. For output buffers pass as_mut_ptr and guarantee that enough space exists.

extern "C" {
  fn fill(buf: *mut u8, len: usize);
}

fn call_fill() {
  let mut data = [0u8; 32];
  unsafe {
    fill(data.as_mut_ptr(), data.len());
  }
  println!("{:?}", &data[..]);
}

Memory ownership must match what the C function expects. If C allocates memory your Rust code must free it using the matching deallocator.

💡 Bindgen can generate Rust bindings from C headers automatically. Review generated code because FFI correctness depends on accurate types.

Exposing Rust functions to C

You can export Rust functions for use from C by applying #[no_mangle] and using extern "C". Use only FFI safe types. Wrap Rust specific behaviour behind safe boundaries.

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

Link the resulting library into a C program. For cross language data exchange use stable C structs or plain buffers.

Building safe abstractions over unsafe code

The best unsafe code hides behind a small safe API. The safe layer enforces invariants so users cannot misuse the underlying operations. This separation mirrors the design of Rust’s standard library. Many core types such as Vec, Box, and String use unsafe code internally while exposing safe interfaces.

Designing invariants

Before writing unsafe internals specify the invariants that must always hold. Examples include valid pointer ranges, correct lengths, null termination, or exclusive access. Encode these invariants into constructors and safe methods so callers cannot create invalid states.

pub struct Buffer {
  ptr: *mut u8,
  len: usize,
}

impl Buffer {
  pub fn from_raw(ptr: *mut u8, len: usize) -> Option<Self> {
    if ptr.is_null() { return None; }
    Some(Buffer { ptr, len })
  }

  pub fn get(&self, i: usize) -> Option<u8> {
    if i >= self.len { return None; }
    unsafe { Some(*self.ptr.add(i)) }
  }
}

This abstraction ensures callers cannot index out of bounds. The unsafe code enforces pointer safety behind the scenes.

Minimising unsafe blocks

Keep unsafe logic isolated. Place checks in safe wrappers, then perform the minimal necessary unsafe operations. Prefer explicit assertions over assumptions so that violations fail loudly in debug builds.

⚠️ Never assume pointer validity without justification. Document assumptions so future maintainers understand the reasoning.

Using types to express safety

Rich types reduce the need for unsafe code. Use enums to represent modes, newtypes to wrap raw handles, and lifetimes to prevent invalid references. When combined with unsafe internals these types enforce correct usage patterns statically.

Unsafe Rust and FFI integration require care but reward you with performance and flexibility. By isolating unsafe code, documenting invariants, and exporting safe APIs you can unlock low level capabilities while preserving Rust’s strong safety story.