Unsafe & FFI 🧨
The keyword with the scariest reputation in Rust, and
the most misunderstood. unsafe is not a cheat code that turns
Rust into C. It is a contract, and it is how Rust talks to the entire
outside world.
What unsafe actually means 📜
Some true things cannot be proven by the borrow checker. The classic
example: taking two mutable references into different halves of one array
is perfectly fine, but the checker cannot always see that they never
overlap. unsafe is you telling the compiler:
"I have checked the part you cannot. Hold me to everything else."
Inside an unsafe block you gain exactly five
extra abilities, and not one more:
- Dereference a raw pointer
- Call an
unsafefunction (including foreign C functions) - Implement an
unsafetrait - Access or modify a mutable
staticvariable - Access the fields of a
union
Everything else still applies. Ownership: still checked. Borrows: still
checked. Types: still checked. unsafe does not turn off Rust;
it turns off one very specific safety net, inside a clearly fenced area
you can grep for.
Raw pointers 📌
References (&T, &mut T) are pointers with
seatbelts: always valid, always aligned, aliasing rules enforced. Raw
pointers (*const T, *mut T) are the pointers C
has: just an address, no promises. Making one is safe;
reading through it is where the contract begins:
fn main() {
let x = 42;
let p = &raw const x; // creating a raw pointer is safe
unsafe {
// reading through it is not: that needs the keyword
println!("x through a raw pointer: {}", *p);
}
}
Why the split? Because pointer arithmetic and address juggling are fine to compute. Only the dereference can explode, so only the dereference needs the fence around it.
The safety comment: unsafe culture 📝
An unsafe fn says "calling me has conditions." The conditions
go in a # Safety doc section, and every call site writes a
// SAFETY: comment proving it meets them. This convention is
near-universal in serious Rust code, and clippy can enforce it:
/// # Safety
/// `i` must be less than `slice.len()`.
unsafe fn peek(slice: &[i32], i: usize) -> i32 {
unsafe { *slice.get_unchecked(i) }
}
fn main() {
let nums = [10, 20, 30];
// SAFETY: 1 < nums.len(), which is 3
let n = unsafe { peek(&nums, 1) };
println!("{n}");
}
Safe abstractions: the real pattern 🧯
Here is the move that makes Rust work: wrap the unsafe part in a function whose interface makes misuse impossible, and nobody upstream ever touches the keyword. You have been using such wrappers all along:
fn main() {
let mut v = [1, 2, 3, 4, 5, 6];
let (left, right) = v.split_at_mut(3); // two &mut into one array!
left[0] = 100;
right[0] = 400;
println!("{v:?}");
}
split_at_mut contains unsafe internally (the
borrow checker cannot prove the halves are disjoint, but the bounds check
can), yet the function itself is safe to call: there is no input that makes
it misbehave. Vec, String, Mutex,
every collection you have used is unsafe machinery behind a safe interface.
The standard library is proof that the pattern scales.
FFI: calling C 🌉
FFI, the foreign function interface, is how Rust calls code
written in other languages. Declare the foreign function's signature in an
extern "C" block, and Rust can call straight into any C
library on the system. All foreign calls are unsafe by definition: the C
compiler made no Rust promises:
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
// SAFETY: C's abs reads one integer and returns one integer
let n = unsafe { abs(-3) };
println!("|-3| = {n}");
}
That is a real call into the C standard library, no crate needed. This
door swings both ways (extern "C" fn plus
#[unsafe(no_mangle)] exports Rust to C), and it is
exactly how Rust is creeping into the Linux kernel, Firefox, and decades-old
codebases: one wrapped C library at a time, safe interfaces spreading
outward.
miri tool (rustup component add miri, then
cargo miri run) interprets your program and catches undefined
behavior in unsafe code at runtime: use-after-free, out-of-bounds reads,
the works. If you write unsafe code, miri is your conscience.
Wrap C's sharp edge
C's abs has a genuine trap: calling it on
INT_MIN (Rust's i32::MIN) is undefined behavior,
because -i32::MIN does not fit in an i32. Write a
safe wrapper fn safe_abs(n: i32) -> Option<i32>
that refuses the poison value and proves its call with a
// SAFETY: comment. Test both cases.
Reveal solution
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
/// C's abs is undefined behavior on INT_MIN, so the safe wrapper refuses it.
fn safe_abs(n: i32) -> Option<i32> {
if n == i32::MIN {
return None;
}
// SAFETY: abs is well-defined for every value except i32::MIN,
// which we just excluded
Some(unsafe { abs(n) })
}
fn main() {
println!("{:?}", safe_abs(-7));
println!("{:?}", safe_abs(i32::MIN));
}
This tiny function is the whole philosophy in miniature: a dangerous
thing, a guard that makes the danger unreachable, and a safe signature
so callers cannot get it wrong. Congratulations, you just built your
first safe abstraction over C. 🧱 (In real code you would just call
Rust's built-in n.checked_abs(), which returns exactly this
Option.)
unsafe is a fence, not a free-for-all: five extra powers,
grep-able boundaries, safety comments as receipts, and safe wrappers so
the danger never leaks upstream. Most Rustaceans ship for years without
writing any. Now, when you finally need it, you know the rules of the
contract.