For a video version of this — https://www.youtube.com/watch?v=lfi2pCOaGGk&t=927s
- Simplified, control-flow-oriented representation.
- Closer to machine code than HIR.
- this is where—borrow checking, optimizations such as
ConstProp,CopyProp,dse, and monomorphization happens
- this is where—borrow checking, optimizations such as
- Right now — MIR is lowered to LLVM IR
- or CLIF IR if we’re using the cranelift backend
- But what if we could intercept MIR and do cool stuff with it
- like advanced analyses — formal verification (kani team at AWS driving this)
- support new hardware with different program execution models— i.e. write regular Rust that runs on accelerators (TPU, GPU etc.)
- That’s where
stable-mircomes in - MIR is rustc’s internal IR i.e. not meant to be stable and can (more like will) undergo changes between compiler versions.
“The goal of the
Stable MIRproject is to provide a stable interface to the Rust compiler that allow tool developers to develop sophisticated analysis with a reduced maintenance cost without compromising the compiler development speed.”
Added two crates to the Rust compiler,
-
stable_mirhas been renamed torustc_public -
rustc_smirhas been renamed torustc_public_bridge -
rustc_publicis the user facing public API. There’s a proposal to have two of these- One is to be published on crates.io. This will be the base of any minor update. This crate will compatible with multiple versions of the compiler. We will use conditional compilation based on the compiler version to do that.
- The other will be developed as part of rustc which will be kept up-to-date with the compiler, and it will serve as the basis for the next major release of
rustc_public. Thisrustc_publichas no compatibility or stability guarantees.
-
rustc_public_bridge—developed as part of the rustc library will interface with rustc’s internal APIs. Implements the interface between public APIs and the compiler internal APIs
#[macro_export]
macro_rules! run {
($args:expr, $callback_fn:ident) => {
$crate::run_driver!($args, || $callback_fn())
};
}The run! macro creates a Callbacks implementation that hooks into rustc's compilation pipeline at the after_analysis phase - after MIR generation but before codegen.
cd demo && cargo expand main 2>&1The expansion shows that run!(&rustc_args, start_demo) expands to the following:
- Defines a
RustcPublicstruct - Holds the callback and result - Implements
Callbackstrait - Hooks into rustc's compilation pipeline viaafter_analysis - Calls
run_compiler- Invokes rustc with the provided arguments - Executes your callback - Runs
start_demo()after analysis is complete - Returns the result - Wrapped in
Result<C, CompilerError<B>>
Macro instantiates the struct and and runs the driver at the end:
RustcPublic::new(|| start_demo()).run(&rustc_args)This creates the driver, passes the callback, and runs the compiler with the arguments.
scoped_tls::scoped_thread_local!(static TLV: Cell<*const ()>);
pub(crate) fn run<F, T>(interface: &dyn CompilerInterface, f: F) -> Result<T, Error>
where
F: FnOnce() -> T,
{
if TLV.is_set() {
Err(Error::from("rustc_public already running"))
} else {
let ptr: *const () = (&raw const interface) as _;
TLV.set(&Cell::new(ptr), || Ok(f()))
}
}Uses thread-local storage to maintain compiler context during analysis, preventing nested invocations.
pub fn run<F, T>(tcx: TyCtxt<'_>, f: F) -> Result<T, Error>
where
F: FnOnce() -> T,
{
let compiler_cx = RefCell::new(CompilerCtxt::new(tcx));
let container = Container { tables: RefCell::new(Tables::default()), cx: compiler_cx };
crate::compiler_interface::run(&container, || init(&container, f))
}The bridge maintains:
- Tables: Map between stable IDs and internal rustc representations
- CompilerCtxt: Wrapper around
TyCtxtfor safe access to compiler internals
cd rustc_public
cargo expand mir::visit
//! For every mir item, the trait has a `visit_<item>` and a `super_<item>` method.
//! - `visit_<item>`, by default, calls `super_<item>`
//! - `super_<item>`, by default, destructures the `<item>` and calls `visit_<sub_item>`
Provides a structured way to traverse and analyze MIR, similar to rustc's internal visitors.
- Compilation Phase: rustc compiles the target crate and generates MIR
- Hook Activation:
after_analysiscallback is triggered - Context Setup: Bridge establishes stable/unstable translation tables
- User Callback: Your analysis function runs with access to stable APIs
- Cleanup: Context is torn down, compilation continues or stops
This design ensures that external tools get a stable, safe interface to rustc's powerful analysis capabilities without directly depending on unstable rustc internals.
Types appear after the colon (:) in variable declarations and expressions:
()- unit type (the return type of main)i32- 32-bit signed integer(i32, bool)- tuple type for overflow checking results&i32- immutable reference to i32(&i32,)- single-element tuple containing a referencestd::fmt::Arguments<'_>- formatting arguments with lifetime[core::fmt::rt::Argument<'_>; 1]- array of 1 element&[&str; 2]- reference to array of 2 string slices&[core::fmt::rt::Argument<'_>; 1]- reference to array
All locals (_0 through _12) have explicit types declared[3].
Operations are the computational actions performed, categorized as Statements and Terminators:
- Assignments:
_2 = 42_i32;- assigns constant to local _3 = CheckedAdd(_2, 1_i32);- CheckedAdd operation that returns(result, overflow_flag)tuple_1 = move (_3.0: i32);- Move operation extracting tuple field_7 = &_1;- Borrow operation creating reference_6 = (move _7);- Aggregate operation constructing tuple_12 = CopyForDeref((_6.0: &i32));- CopyForDeref operation for tuple field access_8 = [move _9];- Array aggregate construction
assert(!move (_3.1: bool), ...)- Assert terminator checking overflow flag with success/unwind branches[5]_9 = core::fmt::rt::Argument::<'_>::new_display::<i32>(_12) -> [return: bb2, unwind unreachable];- Call terminator with return destinationreturn;- Return terminator ending function execution
- Constants:
42_i32,1_i32 - Binary operations:
CheckedAdd(other examples would includeSub,Mul, etc.) - References:
&_1 - Aggregates: tuples
(move _7), arrays[move _9] - Projections:
(_3.0: i32),(_3.1: bool),(_6.0: &i32)- tuple field accesses
These provide additional context but don't execute operations:
debug x => 42_i32;
debug y => _1;
debug args => _6;
debug args => _8;
These map source-level variable names (x, y, args) to MIR locals or values, enabling debuggers to show meaningful variable names[3][1].
{alloc4<imm>: &[&str; 2]}- allocation with immutability attribute- Type annotations:
(_3.0: i32)includes type information for clarity - Unwind attributes:
unwind unreachableindicates panic is not expected to be caught
[success: bb1, unwind unreachable]- branch targets for assert[return: bb2, unwind unreachable]- call return destinations
Each basic block is a region containing:
- Zero or more statements (operations without control flow)
- Exactly one terminator (control flow operation)
Variable declarations at the top serve as SSA-like values, though MIR technically allows reassignment (more like registers than pure SSA)[3].
When the run! macro is called, it triggers a chain of function calls that sets up the Rust compiler, runs analysis, and executes our callback with access to compiler internals.
#[macro_export]
macro_rules! run {
($args:expr, $callback_fn:ident) => {
$crate::run_driver!($args, || $callback_fn())
};
($args:expr, $callback:expr) => {
$crate::run_driver!($args, $callback)
};
}What it does: Simply delegates to run_driver! macro, wrapping function identifiers in closures.
macro_rules! run_driver {
($args:expr, $callback:expr $(, $with_tcx:ident)?) => {{
pub struct RustcPublic<B = (), C = (), F = fn(...) -> ControlFlow<B, C>>
where
B: Send,
C: Send,
F: FnOnce(...) -> ControlFlow<B, C> + Send,
{
callback: Option<F>,
result: Option<ControlFlow<B, C>>,
}
...Type Parameters:
B: Break value type (when callback returnsControlFlow::Break(B))C: Continue value type (when callback returnsControlFlow::Continue(C))F: The callback function type
Fields:
callback: Option<F>- Stores the user's callback (taken once during execution)result: Option<ControlFlow<B, C>>- Stores the callback's return value
pub fn run(&mut self, args: &[String]) -> Result<C, CompilerError<B>> {
let compiler_result = rustc_driver::catch_fatal_errors(|| -> interface::Result::<()> {
run_compiler(&args, self);
Ok(())
});
...
}What it does:
- Calls
rustc_driver::run_compiler()(from the actual Rust compiler) - Passes
self(which implements theCallbackstrait) - The compiler will call back into
after_analysis()at the right time
impl<B, C, F> Callbacks for RustcPublic<B, C, F>
where
B: Send,
C: Send,
F: FnOnce(...) -> ControlFlow<B, C> + Send,
{
fn after_analysis<'tcx>(
&mut self,
_compiler: &interface::Compiler,
tcx: TyCtxt<'tcx>,
) -> Compilation {
if let Some(callback) = self.callback.take() {
rustc_internal::run(tcx, || {
self.result = Some(callback(...));
})
.unwrap();
...
}
}
}What it does:
- This is called by rustc after type checking and analysis but before code generation
- Receives
TyCtxt<'tcx>- the compiler's type context with lifetime'tcx - Calls
rustc_internal::run()to set up the bridge
pub fn run<F, T>(tcx: TyCtxt<'_>, f: F) -> Result<T, Error>
where
F: FnOnce() -> T,
{
let compiler_cx = RefCell::new(CompilerCtxt::new(tcx));
let container = Container {
tables: RefCell::new(Tables::default()),
cx: compiler_cx
};
crate::compiler_interface::run(&container, || init(&container, f))
}CompilerCtxt<'tcx> (from rustc_public_bridge)
- Wraps the
TyCtxt<'tcx>from rustc - Provides methods to query compiler information
- Lifetime
'tcxties it to the compiler's type context
Tables<'tcx, B: Bridge> (from rustc_public_bridge)
- Bidirectional mapping between rustc internal types and stable API types
- Caches conversions to avoid redundant work
- Generic over
B: Bridgetrait
Container<'tcx, B: Bridge> (from rustc_public_bridge)
pub struct Container<'tcx, B: Bridge> {
pub tables: RefCell<Tables<'tcx, B>>,
pub cx: RefCell<CompilerCtxt<'tcx, B>>,
}Why RefCell?
- Allows interior mutability
- Multiple parts of code need mutable access to tables/context
- Checked at runtime (will panic if borrowed incorrectly)
demo/src/main.rs
main()
└─► run!(&rustc_args, start_demo) [macro expands]
└─► run_driver!(...) [creates RustcPublic callback wrapper]
└─► rustc_driver::run_compiler() [rustc compiles & analyzes code]
└─► after_analysis(tcx) [callback hook after analysis]
└─► rustc_internal::run(tcx, || callback())
│
├─ Creates: Container { tables, compiler_cx }
│
└─► compiler_interface::run(&container, || init(&container, f))
│ │
├─ OUTER: Sets CompilerInterface TLV │
│ │
└─────────────────────────────────────────┤
│
├─ INNER: Sets Container TLV
│
└─► f() → start_demo()
What Happens at rustc_internal::run
pub fn run<F, T>(tcx: TyCtxt<'_>, f: F) -> Result<T, Error> {
let compiler_cx = RefCell::new(CompilerCtxt::new(tcx));
let container = Container { tables: RefCell::new(Tables::default()), cx: compiler_cx };
crate::compiler_interface::run(&container, || init(&container, f))
// ^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
// OUTER SCOPE INNER SCOPE
}Two nested thread-local scopes:
-
OUTER:
compiler_interface::run(&container, ...)- Sets TLV = pointer to CompilerInterface
- Enables high-level API queries
-
INNER:
init(&container, f)- Sets TLV = pointer to Container (tables + compiler context)
- Enables translation between stable ↔ internal types
-
Finally: User Callback Executes
start_demo()runs with both thread-locals set- Can call
rustc_public::local_crate(),all_local_items(), etc. - These APIs use the thread-locals to access compiler state
The two-layer thread-local setup happens in this single line:
compiler_interface::run(&container, || init(&container, f))
// ^^^^^^^^^^^^^^^^^^^^
// Inner scope wraps user callbackBoth scopes need the same &container, but they set different thread-local variables to make different parts of the system work!
pub(crate) fn with_container<R, B: Bridge>(
f: impl for<'tcx> FnOnce(&mut Tables<'tcx, B>, &CompilerCtxt<'tcx, B>) -> R,
) -> R {
assert!(TLV.is_set());
TLV.with(|tlv| {
let ptr = tlv.get();
assert!(!ptr.is_null());
let container = ptr as *const Container<'_, B>;
let mut tables = unsafe { (*container).tables.borrow_mut() };
let cx = unsafe { (*container).cx.borrow() };
f(&mut *tables, &*cx)
})
}What it does:
- Retrieves the
Containerfrom thread-local storage - Borrows
tablesmutably andcximmutably - Calls the provided closure with both
pub(crate) fn with<R>(f: impl FnOnce(&dyn CompilerInterface) -> R) -> R {
assert!(TLV.is_set());
TLV.with(|tlv| {
let ptr = tlv.get();
assert!(!ptr.is_null());
f(unsafe { *(ptr as *const &dyn CompilerInterface) })
})
}What it does:
- Retrieves the
CompilerInterfacetrait object from thread-local storage - Calls the provided closure with it
| Function | TLV Used | What It Accesses | When Called |
|---|---|---|---|
with |
OUTER (compiler_interface) | &dyn CompilerInterface (Container) |
High-level API calls like local_crate(), all_local_items() |
with_container |
INNER (rustc_internal) | Tables + CompilerCtxt |
Type conversions between stable ↔ internal |
start_demo()
│
├─► rustc_public::local_crate()
│ └─► with(|cx| cx.local_crate())
│ └─► Accesses OUTER TLV → gets Container
│ └─► Container::local_crate() → queries CompilerCtxt
│
├─► rustc_public::all_local_items()
│ └─► with(|cx| cx.all_local_items())
│ └─► Accesses OUTER TLV → gets Container
│ └─► Container::all_local_items() → queries CompilerCtxt
│ └─► Internally may call .stable() on items
│ └─► with_container(|tables, cx| ...)
│ └─► Accesses INNER TLV → gets Tables + CompilerCtxt
│
└─► rustc_public::entry_fn()
└─► with(|cx| cx.entry_fn())
└─► Accesses OUTER TLV → gets Container
Both TLVs point to the same Container, but they're accessed through different scoped thread-local variables to separate concerns between:
- High-level queries (via
with) - Type translation (via
with_container)
pub(crate) trait CompilerInterface {
fn entry_fn(&self) -> Option<CrateItem>;
fn all_local_items(&self) -> CrateItems;
fn mir_body(&self, item: DefId) -> mir::Body;
fn has_body(&self, item: DefId) -> bool;
// ... many more methods
}Implemented by: Container<'tcx, BridgeTys>
What it provides:
- High-level API for querying compiler information
- All methods internally use
tablesandcxto convert between internal and stable types
User Code
↓
run!(args, callback)
↓
run_driver! macro
↓
RustcPublic::new(callback)
↓
RustcPublic::run(args)
↓
rustc_driver::run_compiler(args, self) ← Enters rustc
↓
[Rustc runs parsing, type checking, analysis...]
↓
RustcPublic::after_analysis(tcx) ← Callback from rustc
↓
rustc_internal::run(tcx, || { ... })
├─ Creates CompilerCtxt::new(tcx)
├─ Creates Container { tables, cx }
└─ Calls compiler_interface::run(&container, ...)
├─ Sets TLV #1 (compiler_interface::TLV) → pointer to Container as CompilerInterface
└─ Calls rustc_internal::init(&container, ...)
├─ Sets TLV #2 (rustc_internal::TLV) → pointer to Container
└─ Executes user callback
├─ User calls stable_mir APIs
├─ APIs call compiler_interface::with() → retrieves Container via TLV #1
├─ APIs call with_container() → retrieves Container via TLV #2
└─ Container uses tables + cx to convert types
-
Double Thread-Local Storage
- TLV #1 (
compiler_interface::TLV): Stores&dyn CompilerInterface - TLV #2 (
rustc_internal::TLV): Stores&Container<'tcx, B> - Both point to the same
Container, but provide different access patterns
- TLV #1 (
-
Interior Mutability with RefCell
ContainerusesRefCellfor bothtablesandcx- Allows multiple borrows throughout the call stack
- Runtime borrow checking prevents conflicts
-
Lifetime Management
'tcxlifetime ties everything to the compiler's type context- Ensures stable API types don't outlive the compiler session
- Scoped thread locals ensure cleanup
-
Bridge Pattern
Containeracts as a bridge between rustc internals and stable APITablescaches conversionsCompilerCtxtwrapsTyCtxtand provides query methods
- Safety: Thread-local storage ensures the compiler context is only accessible during valid compilation
- Ergonomics: Users don't need to pass context explicitly everywhere
- Flexibility: Two TLVs allow different access patterns (trait object vs concrete type)
- Performance:
Tablescaches conversions to avoid redundant work - Separation: Clear boundary between rustc internals and stable API
This architecture allows rustc_public to provide a stable API while internally working with rustc's unstable internals, all while maintaining safety and ergonomics.
The fundamental structure of stable_mir (now rustc_public) is very similar to unstable MIR, but with key differences focused on stability and API design[1][2]. Things we need to know about stable_mir/rustc_public for creating a dialect in pliron:
Stable_mir maintains the same conceptual model as unstable MIR with these key components[2][3]:
- Body: The IR representation of a single function
- BasicBlock: Control-flow graph nodes containing statements and terminators
- Local: Local variables with type information (indexed via
Localtype alias) - Place: Memory locations (variables, fields, derefs) with projections
- Type system: Full Rust type information (though simplified from HIR)
- Statements (
StatementKind): Non-control-flow operations like assignments, storage management (StorageLive/StorageDead), and no-ops - Terminators (
TerminatorKind): Control-flow operations (return, call, switch, goto, drop, etc.) - Rvalues: Right-hand side expressions including binary operations (
BinOp), unary operations (UnOp), aggregates, casts, and references - Operands: Values used in operations (constants, moves, copies)
- ProjectionElem: Field accesses, derefs, array indexing
- AggregateKind: Tuple, array, ADT construction
- CastKind: Type conversions
- BorrowKind, Mutability: Ownership and mutability annotations
- SourceInfo: Debug and span information
- VarDebugInfo: Variable debugging metadata
The main difference is that stable_mir/rustc_public aims to provide semantic versioning and a stable API surface[1][4][5]. The internal rustc MIR can change arbitrarily between compiler versions, while stable_mir will maintain backward compatibility.
- Context management: The
TyCtxtcompiler context is hidden from users in stable_mir, managed through thread-local storage and accessed viawith()function[1] - Cleaner interfaces: Simplified APIs that reduce the need to understand deep compiler internals
- Conversion layer: The
rustc_smircrate handles translation between internal MIR and stable_mir, isolating users from internal changes[4] - rustc_internal module: Provides
internal()andstable()methods for bidirectional conversion when needed (though unstable)[1]
Stable_mir currently has less coverage than full unstable MIR, focusing on what static analysis tools need[1][4]. Some advanced or compiler-internal features may not yet be exposed.
When modeling this in pliron:
- Operations: Create pliron ops for each
StatementKind(Assign, StorageLive/Dead, etc.) andTerminatorKind(Return, Call, Assert, Goto, etc.) - Types: Model MIR's type system as pliron types (primitives, tuples, references, arrays, ADTs)
- Attributes: Attach debug info (
VarDebugInfo), source spans (SourceInfo), mutability/borrow kinds, and allocation metadata as pliron attributes - Blocks/Regions: Map basic blocks to pliron blocks with appropriate control flow
- Operands: Model places (locals with projections) and constants as SSA values or special operand types
The key point is that statements and terminators are operations, locals and expressions have types, and debug/source/flow metadata are attributes.
The structure is conceptually the same—stable_mir just provides a stable, versioned API surface over the same underlying concepts that unstable MIR exposes[8][2][3].
- [1] Migrating to StableMIR - The Kani Rust Verifier https://model-checking.github.io/kani/stable-mir.html
- [2] rustc_public - Rust https://doc.rust-lang.org/nightly/nightly-rustc/rustc_public/index.html
- [3] rustc_public::mir https://doc.rust-lang.org/nightly/nightly-rustc/rustc_public/mir/index.html
- [4] StableMIR - Release and Stability Proposal https://hackmd.io/@celinaval/H1lJBGse0
- [5] Publish first version of StableMIR on crates.io - Rust Project ... https://rust-lang.github.io/rust-project-goals/2025h1/stable-mir.html
- [6] vaivaswatha/pliron: An Extensible Compiler IR Framework https://github.com/vaivaswatha/pliron
- [7] Pliron as the MLIR Alternative (No C/C++) – 1 https://www.youtube.com/watch?v=rRgYGBAhKQ0
- [8] The MIR (Mid-level IR) - Rust Compiler Development Guide https://rustc-dev-guide.rust-lang.org/mir/index.html
