When MIR Goes Rogue: The Real Middle Layer of Rust Compilation
Most Rust developers know about LLVM — the industrial-grade backend that turns your .rs files into blazing-fast machine code. And some know about the HIR (High-level Intermediate Representation) — the simplified syntax tree Rust uses after parsing.
But almost nobody talks about MIR — the Middle Intermediate Representation — Rust’s hidden middle layer where safety guarantees actually get enforced.
And when MIR goes rogue? That’s when you start seeing bizarre compile errors that make you question your life choices — like “borrowed value does not live long enough” or “cannot move out of borrowed content.” Let’s pull back the curtain and see what MIR really does — how it works, how it keeps Rust safe, and why it sometimes drives you insane.
The Three-Layer Compilation Flow
Before we talk about MIR, let’s see where it fits in the pipeline. Rust doesn’t compile directly from your source code to assembly. It goes through a carefully designed multi-step transformation pipeline:
Source Code (.rs)
↓
HIR (High-level IR)
↓
MIR (Mid-level IR)
↓
LLVM IR (Low-level IR)
↓
Machine Code
Each layer strips away complexity.
- HIR removes syntax sugar like pattern matching and desugars for loops and closures.
- MIR flattens control flow and makes borrow checking, moves, and lifetimes explicit.
- LLVM IR takes that MIR and optimizes it for CPU-level execution.
The real safety logic — ownership, borrowing, and lifetimes — lives right here in MIR.
What Is MIR, Really?
MIR (Mid-level Intermediate Representation) is a simplified, SSA-style (Static Single Assignment) representation of your program. Think of it as Rust’s internal bytecode — not too high-level to be full of syntax fluff, and not too low-level to lose meaning.
It’s the language the compiler actually reasons about. Let’s take a simple example:
fn add_one(x: i32) -> i32 {
x + 1
}
At the HIR level, this still looks like an expression tree. At the MIR level, it becomes something like this:
fn add_one(_1: i32) -> i32 {
let mut _0: i32; // return value
bb0: {
_0 = _1 + const 1_i32;
return;
}
}
This is already in SSA form — every variable (_0, _1) is assigned only once. You can think of _0 as the return slot and _1 as the function argument. This makes it trivial for the compiler to track:
- which variables are live,
- where ownership moves,
- and when something is dropped.
The Architecture of MIR
Let’s visualize how MIR fits into the compiler pipeline.
┌────────────────────┐
│ Parser & HIR Gen │
└────────┬───────────┘
↓
┌────────────────────┐
│ MIR Construction │ ← Ownership, Lifetime, Borrow Info
└────────┬───────────┘
↓
┌────────────────────┐
│ MIR Validation │ ← Drop checking, Move analysis
└────────┬───────────┘
↓
┌────────────────────┐
│ MIR Optimizations │ ← Simplify control flow, inline constants
└────────┬───────────┘
↓
┌────────────────────┐
│ LLVM Codegen │ ← Lower MIR to LLVM IR
└────────────────────┘
MIR is not just a representation — it’s a checkpoint where the compiler ensures your code’s logic can’t violate Rust’s safety guarantees.
Example: When MIR Finds Borrowing Bugs You Can’t See
Let’s look at a real-world example that’s perfectly legal in C++, but Rust’s MIR screams about.
fn main() {
let mut v = vec![1, 2, 3];
let x = &v[0];
v.push(4);
println!("{}", x);
}
To us, this looks harmless. But when Rust compiles this, MIR sees something else entirely:
bb0: {
_1 = Vec::<i32>::new(); _2 = &(_1[0]); Vec::push(move _1, 4_i32); _3 = (*_2); ...
}
In MIR, the compiler can see that _2 (a reference to an element of _1) is still live when _1 is mutated. That mutation (Vec::push) could trigger a reallocation, invalidating _2. Boom: lifetime violation. That’s where the borrow checker catches you.
When MIR “Goes Rogue”
Here’s the emotional part: sometimes, MIR is too smart. Rust’s borrow checker runs on top of MIR, and sometimes the compiler’s assumptions about lifetimes get stricter than they need to be. Example: fn foo(x: &mut Vec<i32>) -> &i32 {
x.push(10); &x[0]
}
This feels like it should work — you mutate, then take a reference. But the borrow checker panics: error[E0502]: cannot borrow `*x` as immutable because it is also borrowed as mutable In the MIR world, x’s mutable borrow lasts until the end of the function’s scope. So when you take an immutable borrow (&x[0]), it conflicts with the still-active mutable one. It’s not that your logic is wrong — it’s that MIR models borrows conservatively to avoid UB (undefined behavior). That’s what developers mean when they say “MIR went rogue.”
MIR Optimizations: Rust’s Hidden Compiler Magic
Once borrow checking passes, the MIR goes through several optimization passes — similar to LLVM, but language-aware.
Some include:
- Const Propagation: Replace constant expressions (x + 0 → x).
- Simplify Branches: Merge blocks that have identical paths.
- Inlining: Small functions get merged directly into callers.
- Dead Store Elimination: Removes unused variable assignments.
Example:
fn compute(x: i32) -> i32 {
let y = x + 1; y
} After MIR optimizations: _0 = _1 + const 1_i32; return;
This is already optimized before it ever hits LLVM. LLVM then just focuses on low-level CPU ops — not ownership logic.
Architecture Diagram — MIR in Context
┌────────────────────────────┐
│ Rust Source │
└────────────┬───────────────┘
↓
┌────────────────────────────┐
│ HIR (Desugared AST) │
└────────────┬───────────────┘
↓
┌────────────────────────────┐
│ MIR (Ownership, Borrows) │
│ + Drop & Move Analysis │
└────────────┬───────────────┘
↓
┌────────────────────────────┐
│ LLVM IR (Low-level IR) │
└────────────┬───────────────┘
↓
┌────────────────────────────┐
│ Machine Code / Binary │
└────────────────────────────┘
Why MIR Matters So Much
The MIR layer is what makes Rust fundamentally different from languages like C, C++, or Go. Without MIR:
- Rust couldn’t have static borrow checking.
- The compiler couldn’t reason about moves, drops, and aliasing.
- Unsafe blocks would be indistinguishable from safe ones.
MIR is the soul of Rust’s safety story.
What’s Next: Polonius and Beyond
The next evolution of MIR is Polonius — Rust’s next-gen borrow checker built on Datalog. It uses MIR as its foundation but applies dataflow analysis instead of simple lifetimes. That means fewer false positives and smarter aliasing detection. In the future, MIR + Polonius will make code like this finally compile without workarounds. Final Thoughts MIR is where Rust stops being a language and starts being a system. It’s the compiler’s conscience — the layer that refuses to trust you, no matter how smart you think you are. It’s the reason your Rust code doesn’t segfault at 3 a.m., even when it feels like it hates you. So next time you see a confusing lifetime error — don’t blame Rust. Blame MIR. It’s just trying to keep you safe… even if it drives you mad in the process.
Read the full article here: https://medium.com/@theopinionatedev/when-mir-goes-rogue-the-real-middle-layer-of-rust-compilation-1f7ade9e3679