Understanding Miri and Stacked Borrows in Rust — Why One Example Is UB and the Other Isn’t
Rust’s borrow checker guarantees memory safety at compile time, but some rules—especially around raw pointers—are too subtle for the compiler alone. That’s where Miri comes in. In this post, we’ll walk through two tiny Rust programs: one that Miri accepts, and one that Miri flags as Undefined Behavior. The difference comes down to how Stacked Borrows tracks pointer permissions.
Rust’s aliasing rules are strict by design. They prevent data races, ensure exclusive access through &mut, and make raw pointer usage safe only when you uphold the rules yourself. But how do you know whether your raw pointer code is actually valid?
Enter Miri, an interpreter for Rust’s mid‑level IR (MIR) that can detect Undefined Behavior at runtime—especially UB related to aliasing and borrowing.
To understand how Miri reasons about pointers, we need to understand Stacked Borrows, the model it uses to track which references are allowed to read or write memory at any moment.
Let’s explore a simple example.
fn main() {
let mut x = 42;
let r1 = &mut x;
let raw = r1 as *mut i32;
let r2 = &mut *r1;
*r2 = 7;
unsafe { *raw = 13; }
println!("{}", x); // UB
}
Lets run Miri on this code. To do this you have to install Miri first.
How to run Miri on this code
Install Miri:
rustup +nightly component add miri
Initialize Miri for your project:
cargo +nightly miri setup
Run your program under Miri:
cargo +nightly miri run
If the code is in a single file (e.g., main.rs), that’s all you need:
error: Undefined Behavior: attempting a write access using <336> at alloc149[0x0], but that tag does not exist in the borrow stack for this location
--> src\main.rs:7:14
|
7 | unsafe { *raw = 13; }
| ^^^^^^^^^ this error occurs as part of an access at alloc149[0x0..0x4]
What went wrong?
The key issue is the reborrow:
let r2 = &mut *r1;
This creates a new &mut with a new tag. Under Stacked Borrows, this new tag sits on top of the borrow stack. Any earlier mutable borrows—including the raw pointer derived from r1—are now inactive for mutation.
When you later write through raw, you violate the exclusivity of r2. Miri catches this instantly.
What happens in terms of Stacked Borrows
Let’s walk through the borrow stack for x:
let r1 = &mut x;→r1gets a unique tag; it is the active unique reference.let raw = r1 as *mut i32;→ raw pointer gets the same tag asr1, but raw pointers do not keep the borrow alive.let r2 = &mut *r1;→ This creates a new unique borrow, which pushes a new tag on top of the borrow stack. →r1is now inactive (superseded), and so israw.*r2 = 7;→ Valid:r2is the active unique reference.unsafe { *raw = 13; }→ UB:rawuses the old tag, which is no longer allowed to write. → Miri detects that you are writing through a pointer whose borrow has been invalidated by a later&mut.
This is exactly what Stacked Borrows is designed to catch.
Now let replace this code:
fn main() {
let mut x = 42;
let r1 = &mut x;
// r1: unique mutable reference, becomes the active tag for `x`
let raw = r1 as *mut i32;
// raw pointer inherits r1's tag, but raw pointers do NOT keep borrows alive
let r2 = &mut *r1;
// reborrow from r1: creates a NEW unique borrow with a NEW tag
// this new tag is now on top of the borrow stack, invalidating r1 and raw for mutation
*r2 = 7;
// valid: r2 is the active unique reference
unsafe { *raw = 13; }
// UB under Stacked Borrows:
// raw was created before r2, so its tag is no longer active
// writing through raw violates the uniqueness guarantee of r2
println!("{}", x);
// Miri reports Undefined Behavior before this point
}
With this one:
fn main() {
let mut x = 42;
let r1 = &mut x;
// r1: unique mutable reference, becomes the active tag for `x`
*r1 = 7;
// valid: r1 is the only active unique reference at this moment
let raw = r1 as *mut i32;
// raw pointer is created AFTER the last use of r1 as a mutable reference
// raw inherits r1's tag, and no new &mut is created after this point
// therefore raw's tag remains valid for mutation
unsafe { *raw = 13; }
// valid under Stacked Borrows:
// no later &mut has superseded raw's tag, so writing through it is allowed
println!("{}", x);
// prints 13; no UB, Miri accepts this program
}
Why is this one okay?
The crucial difference is no new &mut is created after the raw pointer. That means:
rawinherits the active tag fromr1- nothing supersedes that tag
- writing through
rawis still allowed
Miri sees no violation of aliasing rules, so the program is valid.
Code side by side
fn main() { | fn main() {
let mut x = 42; | let mut x = 42;
let r1 = &mut x; | let r1 = &mut x; // r1: unique mutable ref
*r1 = 7 | *r1 = 7;
let raw = r1 as *mut i32; | let raw = r1 as *mut i32; // raw pointer
let r2 = &mut *r1; /*new*/ | // no new `&mut` is created after!
*r2 = 7; |
unsafe { *raw = 13; } | unsafe { *raw = 13; }
println!("{}", x); /* UB */ | println!("{}", x); // No UB
} | }
Also could be interesting to explore the code above with
cargo +nightly rustc -- -Zunpretty=mir > mir.log