141. Unsafe Rust
clox reads its bytecode through a C pointer and checks nothing. drip-rs checks every read, and forbids itself the keyword that would let it stop. This chapter is about that keyword: what it unlocks, what it leaves switched on, and what giving it up cost.
- Create raw pointers, and dereference them inside an unsafe block.
- Say what unsafe allows, and which checks it leaves on.
- Wrap an unchecked operation in a safe function with a SAFETY comment.
- Explain what
One byte, two ways
Every instruction the Drip VM runs starts by reading a byte. clox, the C
VM from Crafting Interpreters, does it with *ip++: read through a
pointer, move it along, and trust that it is still inside the chunk.
drip-rs does this (from vm.rs):
fn read_byte(&mut self) -> u8 {
let byte = self.chunk.code[self.ip];
self.ip += 1;
byte
}
code[self.ip] compares ip with the length first, every time, and
panics if it’s past the end. Could Rust read the C way? Yes, with one
keyword. drip-rs chose not to, and an attribute at the top of its
lib.rs, just after the doc comment, makes that choice permanent: #![forbid(unsafe_code)].
Safe Rust is a building where every door checks your badge. unsafe is
a staff-only door: behind it, a few extra rooms open, and in those rooms
nobody checks badges for you. You sign the logbook on the way in, and
whatever happens in there is on you.
Raw pointers
A reference, &T, is a pointer with promises attached: it is never null,
it points at a live T, and the borrowing rules hold. A raw pointer
is the same address with the promises removed. You make one with
&raw const x or &raw mut x, or from a slice with xs.as_ptr():
let x = 5;
let p: *const i32 = &raw const x;
println!("{}", *p);
Making p is fine. Reading through it is not, outside unsafe (Rust
1.97, trimmed):
error[E0133]: dereference of raw pointer
is unsafe and requires unsafe function
or block
= note: raw pointers may be null,
dangling or unaligned; they can violate
aliasing rules and cause data races
fn main() {
let x = 5;
let p = &raw const x;
// E0133: needs an unsafe block.
println!("{}", *p);
}A raw pointer is *const T (read through it) or *mut T (read and
write through it). It may be null, dangling or misaligned, it has no
lifetime, and copying or dropping it does nothing to the value it points
at[3]. Creating, copying and comparing raw
pointers is safe; dereferencing one is an unsafe operation.
The run below makes a *const and a *mut to the same variable, which
two references could never do, then reads and writes through them:
use std::mem::size_of;
fn main() {
let mut n = 41;
// Making raw pointers is safe, even
// a *const and a *mut to one place.
let r = &raw const n;
let w = &raw mut n;
unsafe {
println!("{} {}", *r, *w);
*w += 1;
}
println!("{n}");
let z = std::ptr::null::<i32>();
println!("null? {}", z.is_null());
let s = size_of::<*const i32>();
println!("{s} bytes");
let xs = [1.5, 2.5, 4.0];
let p = xs.as_ptr();
// SAFETY: 2 < xs.len(), so p.add(2)
// stays inside xs.
let x = unsafe { *p.add(2) };
println!("{x}");
}What unsafe unlocks, and what it doesn’t
The Rust book lists five operations that need an unsafe block or
function: dereferencing a raw pointer, calling an unsafe function
(such as p.add(i) or xs.get_unchecked(i)), using a static mut,
implementing an unsafe trait, and reading a union
field[1]. The Reference’s list adds a few rarer
ones, such as declaring extern blocks for C code[2].
What unsafe does not do is switch anything off. Types are still
checked, and so are borrows. Take a reference into a Vec, push, then
use the reference, all inside unsafe, and Rust 1.97 still says
(trimmed):
error[E0502]: cannot borrow `v` as
mutable because it is also borrowed
as immutable
It adds a warning that the unsafe block is unnecessary.
So the promise is narrow. In the extra rooms, you must make sure that
every raw pointer you read is valid and every unsafe function’s rules
are kept. Break that, and the result is undefined behaviour. Not a
panic: the compiler optimizes on the assumption that it never happens,
so anything may follow[4].
let xs = [1.5, 2.5, 4.0];xs[2]xs[3]unsafe { *p.add(2) }unsafe { *p.add(3) }
An unsafe block marks code where you uphold rules the compiler
can’t check. Inside it you may use the unsafe operations; everything else
is checked as usual. An unsafe fn is one whose callers must keep a
promise, so calling it needs unsafe too. A // SAFETY: comment above
the block says what the promise is and why it holds.
Safe on the outside
You rarely want unsafe scattered through a program. The usual shape is
a small, safe function with the check inside it and one unchecked step:
if i >= xs.len() {
return None;
}
// SAFETY: i < xs.len()
Some(unsafe { *xs.get_unchecked(i) })
No caller can make this misbehave, whatever i they pass. That is how
Vec, slices and String are built: safe methods over a little audited
unsafe code inside std[1]. You will write one in
the exercise.
Forbidding it: drip-rs
#![forbid(unsafe_code)] turns the unsafe_code lint up to its
strictest level for the whole crate. Any unsafe block is an error, and
unlike deny, a #[allow(unsafe_code)] lower down can’t undo it: Rust
1.97 answers error[E0453]: allow(unsafe_code) incompatible with previous forbid.
What did that cost? Not pointers between heap objects: chapter 138’s
arenas and Gc<T> handles replaced them. The run-time cost is the
checks. read_byte checks ip, and every heap[handle] checks the
index and that the slot is still Some, because freed slots become
None. To put a number on it, I copied drip-rs, deleted the forbid,
and replaced five checked reads with unsafe unchecked ones:
read_byte, read_constant, peek, and the arena’s two index methods.
Then I timed both builds, alternating, seven runs per program, on the
book’s shared 4-vCPU cloud VM (release builds, rustc 1.97). Minimum
times in ms, checked / unchecked:
| program | round 1 | round 2 |
|---|---|---|
| fib | 1400 / 1288 | 1404 / 1237 |
| method_call | 280 / 276 | 314 / 265 |
| properties | 730 / 655 | 706 / 654 |
| zoo | 520 / 503 | 506 / 493 |
| instantiation | 558 / 518 | 557 / 502 |
The unchecked build was 1 to 16 percent faster on minimums; the medians
of round 1 went both ways. A third round, rebuilt with plain rustc -C opt-level=3 while this chapter was checked, gave 4 to 12 percent. Checks that never fail are cheap: the branch
predictor learns that the index is always in bounds (chapter 20).
What the checks saved is harder to time but easy to point at. The
collector of chapter 58 frees objects, so a missed root leaves a handle
to a freed slot. In C that is a use-after-free that reads whatever moved
in. In drip-rs, the stress and debug modes never reuse freed slots, so
the next heap[handle] finds None and panics with a message. The 95
stress-GC programs run with a collection before every allocation, and a
missed root fails loudly. The forbid only covers drip-rs’s own source:
Vec and HashMap are unsafe inside std. engine/rust has no
unsafe blocks in its crates either, but no forbid, and its
dependencies use it: in oxc’s arena allocator, 376 source lines contain
the word.
Your turn
Write read(xs, i), which returns Some(xs[i]) when i is in bounds and None otherwise, without using indexing or get. Read through the raw pointer p instead: p.add(i) is a pointer to element i, and both add and the dereference need unsafe. Check the bounds before the unsafe block, and write a // SAFETY: comment saying why the read is fine.
fn read(xs: &[f64], i: usize)
-> Option<f64> {
let p: *const f64 = xs.as_ptr();
todo!()
}What you learned
- A raw pointer,
*const Tor*mut T, is an address without a reference’s promises. Making one is safe; dereferencing it is not. unsafeunlocks a few operations, chiefly raw-pointer dereferences and calls tounsafe fns. It switches no checks off.- Breaking an unsafe promise is undefined behaviour, not a panic.
- Keep
unsafesmall and wrap it in a safe function whose own checks make the promise true, with a// SAFETY:comment. - drip-rs’s
#![forbid(unsafe_code)]cost it a few percent of run time and bought collector bugs that panic instead of corrupting memory.
Check your understanding
0/6- 1.
Which line needs an
unsafeblock? - 2.
Inside
unsafe { ... }you take&v[0]and then callv.push(4)while the reference is still used. What happens? - 3.
What is true of a
*const f64? - 4.
drip-rs’s
lib.rshas#![forbid(unsafe_code)]at the top. Someone adds#[allow(unsafe_code)]on one function. What happens? - 5.
In this chapter’s experiment, replacing five checked reads in drip-rs with unchecked ones made it…
- 6.
What is a “safe abstraction” over unsafe code?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
- [2] The Rust Reference contributors. Unsafety. The Rust Reference. doc.rust-lang.org/reference/unsafety.html (checked 2026-10-31)
- [3] The Rust Reference contributors. Pointer types: raw pointers (*const and *mut). The Rust Reference. doc.rust-lang.org/reference/types/pointer.html#raw-pointers-const-and-mut (checked 2026-10-31)
- [4] The Rust Reference contributors. Behavior considered undefined. The Rust Reference. doc.rust-lang.org/reference/behavior-considered-undefined.html (checked 2026-10-31)