Lesson 81 of 94
Unsafe Rust & FFI
Everything so far has been safe Rust: the compiler guarantees no dangling pointers, no data races, no use-after-free, no buffer overflows. Some code can't be expressed within those rules: talking to C libraries, writing a new collection with raw memory, or squeezing the last bit of performance from a hot loop. For that there's unsafe.
unsafe doesn't turn the checks off. It unlocks five extra operations that the compiler can't verify, and makes you responsible for their correctness:
- Dereferencing a raw pointer (
*const T,*mut T). - Calling an
unsafe fn(including foreign C functions). - Reading or writing a
static mut. - Implementing an
unsafe trait(likeSendandSync). - Reading a field of a
union.
The borrow checker, type checker, and bounds checks all still apply inside unsafe blocks.
Rust marks every spot where you step outside the compiler's guarantees with the unsafe keyword, so reviewers know exactly where to look.
The goal of unsafe Rust is almost always to build a safe abstraction: a small unsafe core, carefully reasoned about, wrapped in an API that can't be misused. Vec, String, Rc, Mutex, and HashMap are all built this way.
Part 1: Unsafe Rust
1. Raw pointers: creating is safe, dereferencing isn't
Raw pointers are like C pointers: they can be null, dangle, or alias a &mut. Creating one is safe (it's just an address). Reading through it needs unsafe, because the compiler can't prove it points to valid data.
fn main() {
let mut score = 10;
let p_write: *mut i32 = &raw mut score; // a raw pointer, made without creating a reference
let p_read: *const i32 = p_write; // a second pointer to the same place
unsafe {
// SAFETY: both pointers come from `score`, which is alive, and no reference to
// `score` is used while they are.
*p_write += 5;
println!("score via raw pointer: {}", *p_read);
}
println!("score: {score}");
}score via raw pointer: 15
score: 15Deriving p_read from p_write matters. Writing let p_read = &score as *const i32; first and then taking &mut score would compile, but the &mut would invalidate p_read, making the read undefined behavior. (Miri, from example 16, catches exactly that.)
The // SAFETY: comment is the convention (enforced by Clippy lints in many projects) for explaining why each unsafe block is correct.
2. Raw pointers can be null
Unlike references, raw pointers can be null. Check with is_null(), or convert to an Option<&T> with as_ref(), which does the null check for you.
use std::ptr;
fn describe(p: *const String) -> String {
// SAFETY: callers pass either null or a pointer to a live String.
match unsafe { p.as_ref() } {
Some(s) => format!("points to {s:?}"),
None => String::from("null pointer"),
}
}
fn main() {
let name = String::from("ana");
println!("{}", describe(&name));
println!("{}", describe(ptr::null()));
}points to "ana"
null pointer3. Pointer arithmetic
p.add(n) moves a pointer n elements forward (not bytes). It's how slices and iterators work underneath. Going outside the allocation is undefined behavior, so it's unsafe.
fn sum_raw(data: *const u32, len: usize) -> u32 {
let mut total = 0;
for i in 0..len {
// SAFETY: the caller guarantees `data` points to `len` initialized u32 values.
total += unsafe { *data.add(i) };
}
total
}
fn main() {
let readings = [3u32, 5, 8, 13];
println!("{}", sum_raw(readings.as_ptr(), readings.len()));
println!("{}", sum_raw(readings[1..].as_ptr(), 2));
}29
13sum_raw should really be an unsafe fn, since its correctness depends on the caller (next example). As written, a safe caller could pass a bad pointer.
4. Unsafe functions and # Safety docs
An unsafe fn has requirements the compiler can't check; callers must uphold them and need an unsafe block to call it. Document the requirements under a # Safety heading. In Rust 2024, the body of an unsafe fn still needs its own unsafe { } blocks around the unsafe operations.
/// Sums `len` values starting at `data`.
///
/// # Safety
///
/// `data` must be non-null, aligned, and point to at least `len` initialized `u32`s
/// that are not being mutated during the call.
unsafe fn sum_raw(data: *const u32, len: usize) -> u32 {
// SAFETY: the caller upholds the contract above.
let slice = unsafe { std::slice::from_raw_parts(data, len) };
slice.iter().sum()
}
fn main() {
let v = vec![1u32, 2, 3, 4];
// SAFETY: `v` holds 4 initialized u32s and isn't mutated during the call.
let total = unsafe { sum_raw(v.as_ptr(), v.len()) };
println!("{total}");
}105. Unchecked operations for speed
The standard library has unsafe versions of some checked operations, like get_unchecked. They skip a bounds check when you can prove it's unnecessary. Measure first: the optimizer usually removes redundant checks on its own.
fn dot(a: &[f64], b: &[f64]) -> f64 {
let n = a.len().min(b.len());
let mut sum = 0.0;
for i in 0..n {
// SAFETY: i < n <= a.len() and i < n <= b.len().
sum += unsafe { a.get_unchecked(i) * b.get_unchecked(i) };
}
sum
}
fn main() {
println!("{}", dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]));
println!("{}", dot(&[1.0, 2.0], &[10.0, 20.0, 30.0]));
}32
506. Some safe APIs can't be written with safe code
split_at_mut returns two &mut slices into the same array. It's sound because the halves don't overlap, but the borrow checker can't see that, because it treats the whole slice as one borrow:
fn split_in_two(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
(&mut values[..mid], &mut values[mid..])
}
fn main() {
let mut v = [1, 2, 3, 4];
let (a, b) = split_in_two(&mut v, 2);
a[0] = 10;
b[0] = 30;
}error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src/main.rs:2:31
|
1 | fn split_in_two(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
2 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices7. Building a safe abstraction
With raw pointers we can write it anyway, and wrap it in a safe function. The assert! makes the unsafe code's precondition always true, so no caller can trigger undefined behavior. This is essentially how the standard library implements it.
fn split_in_two(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid <= len, "mid {mid} out of bounds for length {len}");
let ptr = values.as_mut_ptr();
// SAFETY: [0, mid) and [mid, len) are in bounds (checked above) and don't overlap,
// so the two &mut slices never alias.
unsafe { (std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), len - mid)) }
}
fn main() {
let mut v = [1, 2, 3, 4, 5];
let (a, b) = split_in_two(&mut v, 2);
a[0] *= 10;
b[0] *= 100;
println!("{v:?}");
}[10, 2, 300, 4, 5]The unsafe block is small, the reasoning is written down, and the public signature is exactly as safe as any other function. That's the pattern to follow.
8. static mut
A static mut is a global variable anyone can change, which makes data races possible, so every access is unsafe. In Rust 2024, even taking a reference to one is rejected by default (static_mut_refs), because references to data that might be mutated concurrently are undefined behavior.
static mut REQUESTS: u32 = 0;
fn record_request() {
// SAFETY: this program is single-threaded, so there's no concurrent access.
unsafe { REQUESTS += 1 };
}
fn main() {
for _ in 0..3 {
record_request();
}
let n = unsafe { REQUESTS }; // copy the value out; don't create a reference
println!("requests: {n}");
}requests: 3Prefer the safe alternatives: an atomic (static REQUESTS: AtomicU32), a Mutex, or OnceLock/LazyLock (Atomics, Send & Sync). There's almost never a reason for static mut in new code.
9. Unsafe traits: Send and Sync by hand
An unsafe trait has guarantees the compiler can't check; implementing it is a promise. Send and Sync are the common ones: a type containing a raw pointer is neither by default, and you opt in when you know it's safe.
use std::thread;
/// A heap buffer owned exclusively through a raw pointer.
struct RawBuffer {
ptr: *mut u8,
len: usize,
}
impl RawBuffer {
fn new(len: usize) -> Self {
let boxed: Box<[u8]> = vec![0u8; len].into_boxed_slice();
RawBuffer { ptr: Box::into_raw(boxed) as *mut u8, len }
}
fn fill(&mut self, byte: u8) {
// SAFETY: ptr/len describe the allocation made in `new`, and `&mut self` is exclusive.
unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) }.fill(byte);
}
fn sum(&self) -> u32 {
// SAFETY: as above; `&self` means no one is writing.
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }.iter().map(|&b| b as u32).sum()
}
}
impl Drop for RawBuffer {
fn drop(&mut self) {
// SAFETY: reconstructs exactly the Box created in `new`, once.
unsafe { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(self.ptr, self.len))) };
}
}
// SAFETY: RawBuffer owns its allocation exclusively (like Box<[u8]>), so moving it
// to another thread is safe.
unsafe impl Send for RawBuffer {}
fn main() {
let mut buf = RawBuffer::new(4);
buf.fill(7);
let total = thread::spawn(move || buf.sum()).join().unwrap(); // needs Send
println!("sum computed on another thread: {total}");
}sum computed on another thread: 28Without the unsafe impl Send, thread::spawn would reject the closure, as in the Threads lesson's Rc example.
10. UnsafeCell: the base of all interior mutability
Changing data behind a shared reference &T is undefined behavior, with one exception: data inside an UnsafeCell<T>. Cell, RefCell, OnceCell, Mutex, RwLock, and the atomics are all built on it. UnsafeCell::get(&self) returns a *mut T; the unsafe part is guaranteeing that nobody else reads or writes the value at the same time. Here is a minimal Cell:
use std::cell::UnsafeCell;
struct MyCell<T> {
value: UnsafeCell<T>,
}
impl<T: Copy> MyCell<T> {
fn new(value: T) -> MyCell<T> {
MyCell { value: UnsafeCell::new(value) }
}
fn get(&self) -> T {
// SAFETY: `UnsafeCell` makes `MyCell` !Sync, so only one thread can reach it,
// and no reference into the value is ever handed out.
unsafe { *self.value.get() }
}
fn set(&self, value: T) {
// SAFETY: same as `get`: no other access can overlap this write.
unsafe { *self.value.get() = value }
}
}
struct PageView {
hits: MyCell<u32>,
}
impl PageView {
fn record(&self) {
// `&self`, yet the counter changes.
self.hits.set(self.hits.get() + 1);
}
}
fn main() {
let page = PageView { hits: MyCell::new(0) };
page.record();
page.record();
page.record();
println!("hits: {}", page.hits.get());
}hits: 3T: Copy matters: get returns a copy, so no reference into the cell ever exists while set writes. RefCell adds a runtime borrow counter to hand out references safely, and Mutex adds a lock so the type can also be Sync.
11. Unions and why you rarely need them
A union stores different types in the same memory, like a C union. Reading a field is unsafe, because Rust can't know which field was last written. They exist mainly for C interop; for everything else, an enum is the safe tagged version, and bit conversions have safe methods.
#[repr(C)]
union FloatBits {
f: f32,
bits: u32,
}
fn main() {
let u = FloatBits { f: 1.0 };
// SAFETY: every bit pattern is a valid u32, so reading `bits` is fine.
let bits = unsafe { u.bits };
println!("1.0f32 as bits: {bits:#010x}");
// The safe way to do the same thing:
println!("to_bits(): {:#010x}", 1.0f32.to_bits());
println!("from_bits(): {}", f32::from_bits(0x4049_0fdb));
}1.0f32 as bits: 0x3f800000
to_bits(): 0x3f800000
from_bits(): 3.141592712. transmute and its safe replacements
std::mem::transmute reinterprets the bits of one type as another. It's among the most dangerous functions in Rust (an invalid bool or enum value is instant undefined behavior). For almost every use, there's a safe alternative.
fn main() {
// Bytes <-> integers: use from_le_bytes / to_be_bytes instead of transmute.
let header = [0x2A, 0x00, 0x00, 0x00];
println!("{}", u32::from_le_bytes(header));
println!("{:?}", 0x0102_0304u32.to_be_bytes());
// Integer -> enum: use a checked conversion instead of transmute.
#[derive(Debug)]
#[repr(u8)]
enum Level {
Low = 0,
High = 1,
}
let from_byte = |b: u8| match b {
0 => Some(Level::Low),
1 => Some(Level::High),
_ => None, // transmute would produce an invalid Level here: undefined behavior
};
println!("{:?} {:?}", from_byte(1), from_byte(7));
// Where transmute is legitimately used, the types must have the same size.
// SAFETY: [u8; 4] and u32 have the same size, and every bit pattern is valid for both.
let n: u32 = unsafe { std::mem::transmute([1u8, 0, 0, 0]) };
println!("{n}");
}42
[1, 2, 3, 4]
Some(High) None
113. MaybeUninit: memory that isn't initialized yet
Rust never lets you read uninitialized memory. When you need to allocate first and initialize later (for performance, or because C will fill it), MaybeUninit<T> holds possibly-uninitialized data. assume_init is the unsafe step where you promise it's now initialized.
use std::mem::MaybeUninit;
fn squares<const N: usize>() -> [u64; N] {
let mut arr: [MaybeUninit<u64>; N] = [const { MaybeUninit::uninit() }; N];
for (i, slot) in arr.iter_mut().enumerate() {
slot.write((i as u64 + 1).pow(2));
}
// SAFETY: the loop wrote every element.
arr.map(|slot| unsafe { slot.assume_init() })
}
fn main() {
println!("{:?}", squares::<6>());
}[1, 4, 9, 16, 25, 36]In practice, std::array::from_fn(|i| ...) does this safely, and it's what you should use. MaybeUninit matters when C code fills a buffer for you.
14. ptr::read, ptr::write, and ptr::swap
These move values through raw pointers without running destructors or checks, the building blocks of collections. Here they rotate three elements of a slice in place.
use std::ptr;
fn rotate3<T>(v: &mut [T]) {
assert!(v.len() >= 3);
let p = v.as_mut_ptr();
// SAFETY: indices 0..3 are in bounds (asserted). We read element 0 out (leaving a
// logical hole), shift 1 and 2 down, then write the saved value into the hole,
// so every element ends up owned exactly once.
unsafe {
let first = ptr::read(p);
ptr::copy(p.add(1), p, 2);
ptr::write(p.add(2), first);
}
}
fn main() {
let mut names = vec![String::from("ana"), String::from("ben"), String::from("cy"), String::from("dee")];
rotate3(&mut names);
println!("{names:?}");
// The safe standard method for this:
names[..3].rotate_left(1);
println!("{names:?}");
}["ben", "cy", "ana", "dee"]
["cy", "ana", "ben", "dee"]If a panic happened between read and write, the value in slot 0 would be dropped twice. Unsafe code has to think about panics too; this example has none in the critical section.
15. What undefined behavior looks like
Undefined behavior (UB) isn't a crash you can catch. The compiler assumes it never happens and optimizes accordingly, so a program with UB might work in debug builds, fail in release, or break after a compiler upgrade. This compiles, and is wrong:
fn dangling() -> *const i32 {
let local = 42;
&local as *const i32 // pointer to a stack slot that dies when the function returns
}
fn main() {
let p = dangling();
// UB: reading freed stack memory. It might print 42, garbage, or anything else.
println!("{}", unsafe { *p });
}Common sources of UB: dereferencing dangling or misaligned pointers, creating two &mut to the same data, data races, reading uninitialized memory, producing invalid values (a bool that's 2, a null reference), and calling a function with the wrong ABI.
16. Testing unsafe code with Miri
Miri is an interpreter for Rust's intermediate code that detects most undefined behavior at runtime: use-after-free, out-of-bounds access, invalid aliasing, uninitialized reads, and data races. Run your tests under it whenever you write unsafe.
rustup +nightly component add miri
cargo +nightly miri test # run the test suite under Miri
cargo +nightly miri run # run the binary under MiriRunning the previous example under Miri stops with an error like Undefined Behavior: memory access failed: ... has been freed, pointing at the exact line. Rust prevents these bugs at compile time in safe code and gives you Miri for the unsafe parts.
Part 2: FFI, calling C
17. Calling C functions
extern "C" blocks declare functions defined in C. The C standard library is linked into every Rust program, so functions like abs and strlen are available directly. In Rust 2024 the block is written unsafe extern "C", and functions you've checked to be safe for every input can be marked safe fn.
use std::ffi::{c_char, c_int};
unsafe extern "C" {
safe fn abs(x: c_int) -> c_int; // safe for any input, so callable without `unsafe`
fn strlen(s: *const c_char) -> usize; // needs a valid NUL-terminated string
}
fn main() {
println!("abs(-42) = {}", abs(-42));
let text = c"hello, C"; // a C string literal: &CStr, NUL-terminated
// SAFETY: `text` is a valid NUL-terminated string that outlives the call.
let n = unsafe { strlen(text.as_ptr()) };
println!("strlen = {n}");
}abs(-42) = 42
strlen = 818. CString and CStr: crossing the string boundary
Rust strings aren't NUL-terminated and may contain \0; C strings are the opposite. CString is an owned C string you can pass to C (it adds the terminator and rejects interior NULs). CStr is a borrowed view of a C string you received.
use std::ffi::{CStr, CString, c_char};
unsafe extern "C" {
fn strlen(s: *const c_char) -> usize;
}
fn c_len(s: &str) -> Result<usize, String> {
let c = CString::new(s).map_err(|e| format!("interior NUL at byte {}", e.nul_position()))?;
// SAFETY: `c` is NUL-terminated and alive until the end of this function.
Ok(unsafe { strlen(c.as_ptr()) })
}
fn main() {
println!("{:?}", c_len("héllo")); // bytes, not chars: é is 2 bytes in UTF-8
println!("{:?}", c_len("bad\0string"));
let raw: &CStr = c"from C";
let back: &str = raw.to_str().unwrap(); // validates UTF-8
println!("{back:?}, {} bytes plus the NUL", raw.count_bytes());
}Ok(6)
Err("interior NUL at byte 3")
"from C", 6 bytes plus the NUL19. The dangling as_ptr() trap
CString::new(...).unwrap().as_ptr() in one expression is a classic bug: the CString is a temporary, freed at the end of the statement, leaving the pointer dangling. The compiler warns about it; keep the CString in a variable instead.
use std::ffi::{CString, c_char};
unsafe extern "C" {
fn puts(s: *const c_char) -> i32;
}
fn main() {
// BUG: the CString is dropped at the `;`, so `p` points to freed memory.
let p = CString::new("hello").unwrap().as_ptr();
unsafe { puts(p) };
// Correct: the owner lives as long as the pointer is used.
let owned = CString::new("hello").unwrap();
unsafe { puts(owned.as_ptr()) };
}20. Reading strings that C owns
getenv returns a pointer into memory owned by the C library (or null). Wrap it with CStr::from_ptr for the duration of the borrow, and copy it into a String if you need to keep it.
use std::ffi::{CStr, c_char};
unsafe extern "C" {
fn getenv(name: *const c_char) -> *const c_char;
}
fn env_var(name: &std::ffi::CStr) -> Option<String> {
// SAFETY: `name` is NUL-terminated; getenv returns null or a valid C string that we
// copy immediately (no other thread modifies the environment in this program).
unsafe {
let p = getenv(name.as_ptr());
if p.is_null() { None } else { Some(CStr::from_ptr(p).to_string_lossy().into_owned()) }
}
}
fn main() {
// SAFETY: single-threaded at this point; no one else reads or writes the environment.
unsafe { std::env::set_var("APP_MODE", "production") };
println!("{:?}", env_var(c"APP_MODE"));
println!("{:?}", env_var(c"SURELY_NOT_SET_12345"));
}Some("production")
None(In real code, use std::env::var, which does this safely. Note that set_var itself became unsafe in Rust 2024, because modifying the environment while another thread reads it through C is a data race.)
21. Passing a Rust callback to C
C APIs often take function pointers. An extern "C" fn defined in Rust has the C calling convention and can be passed directly. Here, C's qsort sorts a Rust array using a Rust comparator.
use std::ffi::{c_int, c_void};
unsafe extern "C" {
fn qsort(base: *mut c_void, count: usize, size: usize, compare: extern "C" fn(*const c_void, *const c_void) -> c_int);
}
extern "C" fn by_value_desc(a: *const c_void, b: *const c_void) -> c_int {
// SAFETY: qsort passes pointers to elements of the i32 array we gave it.
let (a, b) = unsafe { (*(a as *const i32), *(b as *const i32)) };
b.cmp(&a) as c_int // Ordering is -1, 0, 1
}
fn main() {
let mut scores = [42, 7, 99, 15, 63];
// SAFETY: base/count/size describe `scores` exactly; the comparator matches the element type.
unsafe { qsort(scores.as_mut_ptr().cast(), scores.len(), size_of::<i32>(), by_value_desc) };
println!("{scores:?}");
}[99, 63, 42, 15, 7]A panic must never unwind out of an extern "C" fn into C code. Since Rust 1.81 such a panic aborts the process instead, so keep callbacks simple or catch panics with std::panic::catch_unwind.
22. #[repr(C)]: a layout C understands
Rust may reorder struct fields to reduce padding. #[repr(C)] fixes the order and layout to match C's rules, which is required for any struct shared with C.
use std::mem::{align_of, size_of};
struct RustOrder {
a: u8,
b: u32,
c: u8,
}
#[repr(C)]
struct COrder {
a: u8,
b: u32,
c: u8,
}
fn main() {
// Rust packs the two u8 fields together; C keeps declaration order, adding padding.
println!("Rust layout: {} bytes", size_of::<RustOrder>());
println!("C layout: {} bytes (align {})", size_of::<COrder>(), align_of::<COrder>());
let _ = (RustOrder { a: 0, b: 0, c: 0 }.a, COrder { a: 0, b: 0, c: 0 }.c);
}Rust layout: 8 bytes
C layout: 12 bytes (align 4)23. A C library with Rust structs: strtol
Many C functions report errors through out-parameters. Here's strtol (parse an integer), wrapped in a safe Rust function that turns its conventions into a Result.
use std::ffi::{CStr, CString, c_char, c_int, c_long};
unsafe extern "C" {
fn strtol(s: *const c_char, end: *mut *mut c_char, base: c_int) -> c_long;
}
/// Parses an integer in `base`, requiring the whole string to be consumed.
fn parse_int(s: &str, base: u32) -> Result<i64, String> {
let c = CString::new(s).map_err(|_| String::from("contains NUL"))?;
let mut end: *mut c_char = std::ptr::null_mut();
// SAFETY: `c` is a valid C string; `end` is a valid place for strtol to write.
let value = unsafe { strtol(c.as_ptr(), &mut end, base as c_int) };
// SAFETY: strtol sets `end` to a position inside `c` (or its start).
let rest = unsafe { CStr::from_ptr(end) }.to_string_lossy();
if end.cast_const() == c.as_ptr() {
Err(format!("{s:?}: no digits"))
} else if !rest.is_empty() {
Err(format!("{s:?}: trailing characters {rest:?}"))
} else {
Ok(value as i64)
}
}
fn main() {
println!("{:?}", parse_int(" -1234", 10));
println!("{:?}", parse_int("ff", 16));
println!("{:?}", parse_int("12abc", 10));
println!("{:?}", parse_int("xyz", 10));
}Ok(-1234)
Ok(255)
Err("\"12abc\": trailing characters \"abc\"")
Err("\"xyz\": no digits")The safe wrapper is the whole point: callers of parse_int can't misuse strtol.
24. Exposing Rust to C: extern "C" and #[unsafe(no_mangle)]
To let C call Rust, give the function the C ABI and a stable symbol name. Build the crate as a cdylib (shared library) or staticlib. Rust objects go to C as opaque pointers made with Box::into_raw, with a matching free function.
use std::ffi::{CStr, c_char};
pub struct Counter {
name: String,
count: u64,
}
#[unsafe(no_mangle)]
pub extern "C" fn counter_new(name: *const c_char) -> *mut Counter {
// SAFETY: the C caller passes a valid NUL-terminated string.
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
Box::into_raw(Box::new(Counter { name, count: 0 }))
}
#[unsafe(no_mangle)]
pub extern "C" fn counter_increment(c: *mut Counter) -> u64 {
// SAFETY: `c` came from counter_new and hasn't been freed.
let c = unsafe { &mut *c };
c.count += 1;
c.count
}
#[unsafe(no_mangle)]
pub extern "C" fn counter_free(c: *mut Counter) {
if !c.is_null() {
// SAFETY: `c` came from counter_new; this is the only place it's freed.
let c = unsafe { Box::from_raw(c) };
println!("freeing counter {:?} at {}", c.name, c.count);
}
}
fn main() {
// Simulate the C side calling our API.
let c = counter_new(c"page_views".as_ptr());
counter_increment(c);
counter_increment(c);
println!("count = {}", counter_increment(c));
counter_free(c);
}count = 3
freeing counter "page_views" at 3[lib]
crate-type = ["cdylib"] # produces libcounter.so / counter.dll / libcounter.dylib/* counter.h, written by hand or generated with the `cbindgen` tool */
typedef struct Counter Counter;
Counter* counter_new(const char* name);
uint64_t counter_increment(Counter* c);
void counter_free(Counter* c);25. Realistic: a fixed-capacity vector with MaybeUninit
A StackVec<T, N> stores up to N elements inline (no heap), like the arrayvec crate. The unsafe core tracks which slots are initialized; the public API is entirely safe, including dropping only the initialized elements.
use std::mem::MaybeUninit;
pub struct StackVec<T, const N: usize> {
items: [MaybeUninit<T>; N],
len: usize, // invariant: items[..len] are initialized, items[len..] are not
}
impl<T, const N: usize> StackVec<T, N> {
pub fn new() -> Self {
StackVec { items: [const { MaybeUninit::uninit() }; N], len: 0 }
}
pub fn push(&mut self, value: T) -> Result<(), T> {
if self.len == N {
return Err(value); // full: hand the value back
}
self.items[self.len].write(value);
self.len += 1;
Ok(())
}
pub fn pop(&mut self) -> Option<T> {
if self.len == 0 {
return None;
}
self.len -= 1;
// SAFETY: slot `len` was initialized (invariant) and is now outside the live range,
// so it's read exactly once.
Some(unsafe { self.items[self.len].assume_init_read() })
}
pub fn as_slice(&self) -> &[T] {
// SAFETY: items[..len] are initialized, and MaybeUninit<T> has the same layout as T.
unsafe { std::slice::from_raw_parts(self.items.as_ptr().cast::<T>(), self.len) }
}
}
impl<T, const N: usize> Drop for StackVec<T, N> {
fn drop(&mut self) {
for slot in &mut self.items[..self.len] {
// SAFETY: exactly the initialized slots are dropped, once.
unsafe { slot.assume_init_drop() };
}
}
}
fn main() {
let mut v: StackVec<String, 3> = StackVec::new();
for name in ["ana", "ben", "cy", "dee"] {
if let Err(rejected) = v.push(name.to_string()) {
println!("full, rejected {rejected:?}");
}
}
println!("{:?}", v.as_slice());
println!("popped {:?}", v.pop());
println!("{:?}", v.as_slice());
} // remaining Strings are dropped correctlyfull, rejected "dee"
["ana", "ben", "cy"]
popped Some("cy")
["ana", "ben"]26. Realistic: a queue built on raw pointers
A singly linked queue needs O(1) push at the back and pop at the front, so it keeps pointers to both ends. Safe Box links can't express "the list owns every node, and I also point at the last one". The robust approach is to manage nodes entirely through raw pointers: Box::into_raw to allocate, Box::from_raw to take ownership back and free.
use std::ptr;
struct Node<T> {
value: T,
next: *mut Node<T>,
}
pub struct Queue<T> {
head: *mut Node<T>, // null when empty
tail: *mut Node<T>, // null when empty
len: usize,
}
impl<T> Queue<T> {
pub fn new() -> Self {
Queue { head: ptr::null_mut(), tail: ptr::null_mut(), len: 0 }
}
pub fn push(&mut self, value: T) {
let node = Box::into_raw(Box::new(Node { value, next: ptr::null_mut() }));
if self.tail.is_null() {
self.head = node;
} else {
// SAFETY: `tail` is the last live node, allocated by `push` and not yet freed.
unsafe { (*self.tail).next = node };
}
self.tail = node;
self.len += 1;
}
pub fn pop(&mut self) -> Option<T> {
if self.head.is_null() {
return None;
}
// SAFETY: `head` came from Box::into_raw in `push` and is freed only here, once.
let node = unsafe { Box::from_raw(self.head) };
self.head = node.next;
if self.head.is_null() {
self.tail = ptr::null_mut(); // the list is empty: don't leave `tail` dangling
}
self.len -= 1;
Some(node.value)
}
pub fn len(&self) -> usize {
self.len
}
}
impl<T> Drop for Queue<T> {
fn drop(&mut self) {
while self.pop().is_some() {} // free every remaining node
}
}
fn main() {
let mut jobs = Queue::new();
jobs.push("resize");
jobs.push("compress");
println!("{:?} (len {})", jobs.pop(), jobs.len());
jobs.push("upload");
while let Some(job) = jobs.pop() {
println!("running {job}");
}
jobs.push("again"); // pushing after emptying relies on `tail` being reset
jobs.push("left over"); // freed by Drop
println!("{:?}", jobs.pop());
}Some("resize") (len 1)
running compress
running upload
Some("again")A tempting shortcut, owning nodes through Option<Box<Node>> links while keeping a raw tail pointer into them, compiles and seems to work, but it's undefined behavior: moving a Box asserts unique access to its contents, which invalidates other pointers into it. Miri reports that version immediately. It's a good illustration of why unsafe code needs Miri. For real code, std::collections::VecDeque is the right queue.
27. Checklist for writing unsafe code
- Avoid it if you can. Look for a safe standard-library method or a well-reviewed crate first.
- Keep unsafe blocks tiny, and put them behind a safe API that enforces the preconditions (with
assert!if needed). - Write a
// SAFETY:comment for everyunsafeblock and a# Safetysection for everyunsafe fn. - Uphold invariants at every step, including when a panic can happen in the middle.
- Test with Miri, and consider
cargo carefulor sanitizers for FFI code. - For FFI: use
#[repr(C)]types,CString/CStr, check nulls, never let a panic cross into C, and pair everyBox::into_rawwith exactly oneBox::from_raw.
Summary
unsafeunlocks five operations: dereferencing raw pointers, calling unsafe functions, accessingstatic mut, implementing unsafe traits, and reading union fields. All other checks remain.- Raw pointers may be null, dangling, or aliased; creating them is safe, using them isn't.
- The purpose of unsafe code is to build safe abstractions (
split_at_mut,Vec,StackVec), with the reasoning documented inSAFETYcomments. - Prefer atomics or locks to
static mut, safe conversions totransmute, andarray::from_fntoMaybeUninit, unless you truly need them. - Undefined behavior is not a runtime error; it's a broken program. Miri detects most of it.
- FFI uses
unsafe extern "C"blocks to call C,CString/CStrfor strings,extern "C" fnfor callbacks,#[repr(C)]for shared structs, and#[unsafe(no_mangle)] pub extern "C" fnplus opaqueBoxpointers to expose Rust to C.
Next: no_std: core and alloc: Rust without an operating system.
