Skip to content
rusthacks.dev
Systems

Writing a Safe Wrapper Around Unsafe Code in Rust Explained

The goal of unsafe code is to contain it. A safe wrapper has a small unsafe core, private fields that protect invariants and an API that cannot be misused from safe code.

15 min read
Summarize in AI
Flat illustration for the post “Writing a Safe Wrapper Around Unsafe Code in Rust Explained”, composed around embedded.

Unsafe code in Rust is not meant to be sprinkled through a program. It is meant to live in a small module, behind an interface that cannot be misused, so that the rest of the program can stay in the safe subset and still use the result. That arrangement is called a safe abstraction, and building one is the core skill of unsafe Rust. The standard library is full of them: Vec manages raw memory, Mutex hands out references across threads, Rc counts owners by hand, and callers of all three write no unsafe.

This guide builds one from scratch: a fixed-capacity ring buffer that stores its items inline in an array, with no heap allocation. It is small enough to read in a sitting, it needs real unsafe code, and it has three distinct ways to get wrong that Miri can show you. Everything compiled and ran on Rust 1.99 (edition 2024) and on the nightly toolchain's Miri; the output blocks are what they printed.

The problem the abstraction solves

A ring buffer is a queue with a fixed number of slots. Pushing writes at the tail, popping reads at the head, and both indices wrap around the end of the array. It is the standard structure for a producer and consumer that must never allocate, such as an interrupt handler feeding a main loop, or an audio callback.

In safe Rust, the obvious design is [Option<T>; N]. It works and costs a discriminant per slot, and for a type like String or Box, where Option can reuse the null pointer, the cost is zero. For a type with no spare bit pattern, like u32, every slot grows by a word. More importantly, [Option<T>; N] requires initializing every slot to None up front and cannot move a value out of a slot without take(). The design this guide builds uses MaybeUninit<T>: a slot is raw, uninitialized storage, and the ring itself tracks which ones currently hold values. That tracking is the invariant the unsafe code depends on, and it is what the whole abstraction exists to protect.

State the invariant first

Before writing a line, write down what must always be true. This is the step that separates an abstraction from a collection of unsafe blocks.

The ring has three fields: slots, head and len. The invariant is:

  1. head is always less than N, and len is always at most N.
  2. The len slots starting at head, counting around the end of the array, are initialized.
  3. Every other slot is uninitialized and is never read and never dropped.

Every method has to preserve it, and every unsafe block is justified by it. If a method breaks it even briefly, even in a state no one can observe, a panic at the wrong moment can expose the broken state. The code below is written so that no method can panic between changing the indices and reading the slot.

The implementation

src/main.rsrust
use std::mem::MaybeUninit;

/// A fixed-capacity FIFO queue that stores its items inline, with no heap allocation.
///
/// Invariant: exactly the slots in the logical range `head .. head + len` (mod N)
/// are initialized. Every other slot is uninitialized and must never be read or dropped.
pub struct Ring<T, const N: usize> {
    slots: [MaybeUninit<T>; N],
    head: usize,
    len: usize,
}

impl<T, const N: usize> Ring<T, N> {
    pub fn new() -> Self {
        Ring { slots: [const { MaybeUninit::uninit() }; N], head: 0, len: 0 }
    }

    pub fn len(&self) -> usize {
        self.len
    }

    /// Returns the item back if the ring is full.
    pub fn push(&mut self, item: T) -> Result<(), T> {
        if self.len == N {
            return Err(item);
        }
        let tail = (self.head + self.len) % N;
        self.slots[tail].write(item); // writing a MaybeUninit slot is safe
        self.len += 1;
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        let index = self.head;
        self.head = (self.head + 1) % N;
        self.len -= 1;
        // SAFETY: `index` was inside the initialized range before `len` shrank, and
        // moving `head` forward means no later call can reach this slot again until
        // `push` rewrites it. So the value is read out exactly once.
        Some(unsafe { self.slots[index].assume_init_read() })
    }

    pub fn peek(&self) -> Option<&T> {
        if self.len == 0 {
            return None;
        }
        // SAFETY: `head` is inside the initialized range because `len > 0`.
        Some(unsafe { self.slots[self.head].assume_init_ref() })
    }
}

impl<T, const N: usize> Drop for Ring<T, N> {
    fn drop(&mut self) {
        // Drop only the initialized items. `MaybeUninit` never drops its contents on its own.
        while self.pop().is_some() {}
    }
}

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("dropping {}", self.0);
    }
}

fn main() {
    let mut ring: Ring<String, 3> = Ring::new();
    for word in ["a", "b", "c"] {
        ring.push(word.to_string()).unwrap();
    }
    println!("full push rejected: {:?}", ring.push("d".to_string()));
    println!("peek: {:?}", ring.peek());
    println!("pop:  {:?}", ring.pop());
    ring.push("e".to_string()).unwrap(); // wraps around the end of the array
    let drained: Vec<_> = std::iter::from_fn(|| ring.pop()).collect();
    println!("drained in order: {drained:?}");

    let mut noisy: Ring<Noisy, 4> = Ring::new();
    noisy.push(Noisy("first")).ok();
    noisy.push(Noisy("second")).ok();
    noisy.push(Noisy("third")).ok();
    let _ = noisy.pop();
    println!("leaving main with {} items still queued", noisy.len());
}

Read the code with the invariant open beside it.

new creates N uninitialized slots with [const { MaybeUninit::uninit() }; N]. The const block is how you repeat a value that is not Copy in an array expression. Creating uninitialized storage is safe, because MaybeUninit makes no promise about its contents.

push checks capacity, computes the tail as (head + len) % N, and writes with MaybeUninit::write. Writing is safe for the same reason: it initializes the slot, it cannot expose anything. If the ring is full, push returns the item back inside Err, which is the idiomatic way to refuse without losing the caller's value. Only after the write does it increase len, which keeps the order of operations safe: the invariant never claims a slot is initialized before it is.

pop is the first place unsafe code appears, and it follows a strict order. It advances head and decrements len first, which removes the slot from the initialized range, and only then reads it with assume_init_read. The comment states the argument: the slot was inside the range, and shrinking the range means no later call can reach it before push rewrites it, so the value is read out exactly once. assume_init_read performs a bitwise copy and leaves the slot logically uninitialized again, which is exactly the move semantics wanted.

peek returns a reference to the front item using assume_init_ref. Its comment cites the invariant: with len > 0, the slot at head is initialized. The reference borrows &self, so the borrow checker prevents using it across a push or pop, which could invalidate it. That is the type system enforcing part of the invariant for free, and the next section shows the compiler doing it.

Drop is the part people forget. A MaybeUninit field never drops its contents, because it does not know whether they are initialized. The ring knows, so its Drop impl pops every remaining item, which runs each item's destructor exactly once, and leaves the uninitialized slots alone. The loop reuses pop instead of repeating the unsafe read, which keeps the number of unsafe blocks at two.

What running it shows

Text
full push rejected: Err("d")
peek: Some("a")
pop:  Some("a")
drained in order: ["b", "c", "e"]
dropping first
leaving main with 2 items still queued
dropping second
dropping third

Walk through it. The ring has three slots, so the fourth push is refused and the string comes back in Err("d"). Peeking and popping both return "a", the oldest item, so the order is first in, first out. After the pop, pushing "e" wraps around to the slot "a" left behind, and draining returns ["b", "c", "e"] in the right order, which exercises the wraparound arithmetic. The second half uses a type that prints when it is dropped. dropping first appears immediately when let _ = noisy.pop() discards the value. At the end of main two items are still queued, and the ring's Drop impl prints dropping second and dropping third. Every value was dropped exactly once, none twice and none never.

This ran under Miri with no error. That is the standard to hold the abstraction to: Miri passing on tests that cover wraparound, a full ring, an empty ring and drop.

The borrow checker protects part of the invariant

The first draft of main printed the peeked value and the popped value in one statement:

src/main.rsrust
println!("peek: {:?}, pop: {:?}", ring.peek(), ring.pop());
Text
error[E0502]: cannot borrow `ring` as mutable because it is also borrowed as immutable
  --> src/bin/ring.rs:76:52
   |
76 |     println!("peek: {:?}, pop: {:?}", ring.peek(), ring.pop());
   |     -----------------------------------------------^^^^^^^^^^-
   |     |                                 |            |
   |     |                                 |            mutable borrow occurs here
   |     |                                 immutable borrow occurs here
   |     immutable borrow later used here

This was an accident, and a good one. peek returns a &T pointing into the slot, and that reference is still alive when pop runs. If the program were allowed to compile, pop would move the value out of the slot while the reference still points at it, and the reference would see memory that no longer holds a valid T. The compiler rejected it because peek takes &self and returns a reference tied to that borrow, while pop takes &mut self. You did not write any rule saying "do not pop while peeking"; the signatures encode it. That is what a good safe interface does: it makes a class of misuse a compile error.

Three ways the abstraction breaks

Now the failures, because an abstraction is only as good as the bugs it prevents. Each one below was made by changing the working code in one place and running Miri.

Forgetting Drop

Delete the Drop impl. The ring now never drops its items. For Copy types that is invisible. For String, every item still queued at the end owns a heap buffer that is never freed:

Text
Some("alpha")
error: memory leaked: alloc410 (Rust heap, size: 4, align: 1), allocated here:
   --> .../library/alloc/src/raw_vec/mod.rs:485:41
    |
485 |             AllocInit::Uninitialized => alloc.allocate(layout),

Miri reports the leak at exit, with the allocation's size. Nothing crashed and the output looked right. Leaks of this kind pass every ordinary test and surface as slowly growing memory in production, which is why running Miri on unsafe containers is worth the minutes it takes.

Reading a slot twice

Remove the two lines in pop that advance head and shrink len. Now popping reads the same slot each time while still claiming it holds a value:

Text
Some("alpha")
error: Undefined Behavior: constructing invalid value of type &[u8]: encountered a dangling reference (use-after-free)
    --> .../library/alloc/src/vec/mod.rs:1784:13
     |
1784 |             &*core::intrinsics::aggregate_raw_ptr::<*const [T], _, _>(self.as_ptr(), self.len)
     |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here

The first pop printed Some("alpha") and dropped the returned string. The second pop read the same bytes again, producing a second String whose buffer had already been freed. Miri catches it at the moment the freed buffer is touched. In a normal build this is a double free: the allocator's bookkeeping is corrupted, and the crash happens later, somewhere unrelated. The bug is a single missing statement in a safe-looking method, which is the main hazard of unsafe code: the unsafe block was fine, and the safe code around it broke the invariant it depends on.

Dropping the wrong slots

The third failure is the subtle one, and it is a bug you cannot demonstrate by deleting a line. If the Drop impl iterated over every slot and called assume_init_drop on each, it would drop uninitialized memory for the empty ones, which is undefined behavior for any T with a destructor. The while self.pop().is_some() {} loop avoids it by construction: pop only touches slots the invariant says are initialized. When you write an unsafe collection, prefer expressing cleanup in terms of your own safe-to-reason-about operations over repeating index arithmetic.

Tests that make each failure fail

The three failures above were found by hand-editing the code. A proper test suite finds them for you. Four tests cover the cases that matter for this container, and the third one is the drop-count test that would have caught both the missing Drop and the double read.

src/main.rsrust
#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;
    use std::rc::Rc;

    /// Counts how many times it has been dropped, through a shared cell.
    struct Counted(Rc<Cell<usize>>);

    impl Drop for Counted {
        fn drop(&mut self) {
            self.0.set(self.0.get() + 1);
        }
    }

    #[test]
    fn fifo_order_survives_wraparound() {
        let mut r: Ring<u32, 3> = Ring::new();
        for round in 0..10 {
            r.push(round).unwrap();
            r.push(round + 100).unwrap();
            assert_eq!(r.pop(), Some(round));
            assert_eq!(r.pop(), Some(round + 100));
        }
        assert_eq!(r.pop(), None);
    }

    #[test]
    fn full_ring_returns_the_item() {
        let mut r: Ring<String, 1> = Ring::new();
        r.push("kept".into()).unwrap();
        assert_eq!(r.push("rejected".into()), Err("rejected".to_string()));
    }

    #[test]
    fn every_item_is_dropped_exactly_once() {
        let drops = Rc::new(Cell::new(0));
        {
            let mut r: Ring<Counted, 4> = Ring::new();
            for _ in 0..4 {
                r.push(Counted(drops.clone())).ok().unwrap();
            }
            drop(r.pop()); // one dropped by the caller
            assert_eq!(drops.get(), 1);
            r.push(Counted(drops.clone())).ok().unwrap(); // wraps
        } // the ring drops the remaining four
        assert_eq!(drops.get(), 5);
    }

    #[test]
    fn zero_sized_items_work() {
        let mut r: Ring<(), 2> = Ring::new();
        r.push(()).unwrap();
        r.push(()).unwrap();
        assert!(r.push(()).is_err());
        assert_eq!(r.pop(), Some(()));
    }
}

Run them under Miri, not just cargo test. A normal test run would pass a ring that leaks or double-drops in many cases, because the damage is silent; Miri turns each into an error.

Shell
cargo +nightly miri test
Text
running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s

The tests earn their keep through what they check. fifo_order_survives_wraparound pushes and pops ten rounds through a three-slot ring, so the head index wraps around the array several times; an off-by-one in the modulo shows up as a wrong value. full_ring_returns_the_item pins the contract that a refused push hands the value back rather than dropping it. every_item_is_dropped_exactly_once counts destructor calls through a shared Rc<Cell<usize>>: one drop from the caller's drop(r.pop()), then four more when the ring goes out of scope, five in total for the five items pushed. If Drop were missing, the final assertion would read 1; if pop read a slot twice, Miri would stop the test first. And zero_sized_items_work covers Ring<(), 2>, a case people forget, since a zero-sized T makes pointer arithmetic degenerate and MaybeUninit<()> take no space at all.

Panic safety: what if a destructor panics?

One more property separates a container that is correct from one that is sound under every input. User code runs inside your methods whenever you call T::drop, and that code can panic. In Ring::drop, the loop calls pop, which moves the value out and then lets the caller's Option drop it. If item two's destructor panics, the unwinding begins while items three and four are still queued. What happens to them?

With this implementation, the while self.pop().is_some() {} loop is abandoned mid-way by the panic, and the remaining items are leaked: their destructors do not run. A leak is safe in Rust, so the abstraction is still sound, merely wasteful in a rare case. The alternative, a guard object whose own Drop continues draining even while unwinding, makes the container leak-free at the price of more code and the possibility of a second panic during unwinding, which aborts the process. The standard library's Vec::drain and VecDeque::drop use that guard technique because they are part of the foundation. For a container of your own, the pragmatic rule is to guarantee soundness always and leak-freedom where it is cheap. What you must never do is leave the invariant false when a panic unwinds out of a method: if push incremented len before it wrote the item, and the write could panic (it cannot here, but a clone before the write could), a later pop would read an uninitialized slot. That is why the order inside each method matters as much as the code in it.

Deciding whether the type is Send and Sync

A struct made of safe types gets Send and Sync automatically. Ring<T, N> holds [MaybeUninit<T>; N] and two usize, and MaybeUninit<T> is Send when T is and Sync when T is, so the compiler infers that the ring is Send if T: Send and Sync if T: Sync. That inference is correct here, since nothing in the ring is tied to a thread.

You override the automatic rules, with unsafe impl Send, only when the struct contains a raw pointer or other type the compiler refuses to assume is thread-safe, and then the promise is yours. The question to answer is "if two threads each had access, could they cause a data race or break an invariant?" For a Ring, a shared &Ring offers only len and peek, which read; push and pop need &mut, which is exclusive. So Sync is sound without any synchronization, and the type system keeps writers exclusive. The moment you want a ring that two threads push into and pop from at the same time, you need atomics for head and len, and the invariant argument becomes a lock-free design problem, a different and much harder one.

A checklist for any safe abstraction over unsafe code

  • Write the invariant as a numbered list in the doc comment of the type, before writing methods.
  • Keep the fields private. A public field lets any caller break the invariant without writing unsafe.
  • Order operations so the invariant never claims more than is true: initialize before increasing the count, decrease the count before reading out.
  • Put a // SAFETY: comment on every unsafe block that cites the invariant and the specific condition.
  • Implement Drop explicitly, and test it with a type that prints or counts drops, so a leak and a double drop both fail a test.
  • Consider panic safety. If a user-supplied Clone or Drop can panic halfway through your method, ensure the invariant still holds when the panic unwinds.
  • Run Miri on the tests, including the edge cases: empty, full, wraparound, zero-sized types, and a type with a destructor.
  • Check the auto traits. Confirm Send and Sync do what you mean, or assert them with a compile-time test.

When to use a crate instead

Before shipping a hand-written container, ask whether one already exists with a larger test suite. For this particular structure, heapless::spsc::Queue is the established no-alloc queue for embedded code, arrayvec gives a stack-allocated Vec, and std::collections::VecDeque is a growable ring buffer when allocation is acceptable. Write your own when none fits, or to learn. If you do, the discipline above is what makes it safe to depend on, and the first thing a reviewer should read is the comment that states the invariant. For the language rules the whole exercise rests on, read unsafe Rust explained, and for wrapping C instead of a data structure, safe Rust bindings for a C library.

Keep the finished type small and finish it with documentation a stranger can audit: the invariant list on the struct, the safety comment on each block, and the test names that read as claims. If another person can verify the argument without running anything, the abstraction has done its job.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.