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.
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.
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.
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.
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.
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.