· 6 min read ·

What Rust's Inline Assembly Owes the Memory Model

Source: lobsters

Rust’s asm! macro has been stable since Rust 1.59.0, released in February 2022. Most people who use it focus on getting the register constraints right. What Ralf Jung’s recent post addresses is the harder question underneath: what does it mean, formally, for an assembly block to be correct within Rust’s abstract machine?

Why Inline Assembly Is Hard to Specify

The challenge is a mismatch of abstraction levels. Rust’s type system operates on an abstract machine with typed memory, ownership, and a memory model that governs which concurrent accesses constitute data races. Assembly operates on hardware: registers, raw bytes, and a flat address space. When you write unsafe Rust, you’re still working with the abstract machine’s rules; you’re just responsible for maintaining them yourself. When you write inline assembly, you’re working below the abstract machine entirely.

This gap shows up most clearly with memory. If your assembly block reads through a pointer, whose memory model governs that read? The hardware will do whatever you tell it. But the Rust compiler may have applied optimizations based on its understanding of the abstract machine’s memory state — it may have assumed a value is still in a register, or that a location hasn’t been written. If your assembly touches memory in ways the abstract machine didn’t anticipate, the result is undefined behavior, even if the hardware does exactly what you intended.

How GCC Handles This

GCC’s extended inline assembly addresses the memory problem through a constraint and clobber system. When your assembly touches memory the compiler doesn’t know about, you declare a "memory" clobber:

int result;
asm volatile (
    "movl (%1), %0"
    : "=r" (result)
    : "r" (ptr)
    : "memory");

The "memory" clobber tells GCC to treat all memory as potentially read and written, forcing the compiler to flush any cached values and reload after the asm. The compiler doesn’t know which memory was accessed; it conservatively invalidates everything. This was not designed with a formal memory model in mind. It was designed to interface with a register allocator, and "memory" is a workaround layered on top of that system to handle effects the constraint language cannot express precisely.

Rust’s asm! macro, specified in RFC 2873, inverts this. The default is that the asm may read and write arbitrary memory, and you opt into restricted modes with nomem or readonly:

use std::arch::asm;

let x: u64 = 42;
let result: u64;
unsafe {
    asm!(
        "imul {out}, {inp}, 2",
        out = out(reg) result,
        inp = in(reg) x,
        options(pure, nomem, nostack),
    );
}

With nomem, the compiler knows the asm touches no memory and can treat the block as a pure computation on registers. The absence of nomem makes the compiler assume the worst. This is already more principled than GCC’s approach, but it still does not specify what the asm is allowed to do with the memory it accesses, only whether it accesses memory at all.

The Storytelling Model

Ralf Jung’s research on Rust’s memory model introduces a concept of storytelling: the idea that a program execution is valid if you can construct a coherent narrative about it at the level of the abstract machine. Each memory access is a step in the story. For the story to be valid, each step must be authorized by some outstanding permission.

In Stacked Borrows, Jung’s aliasing model for Rust, each memory location maintains a stack of permission items. A read or write is valid if the pointer’s tag appears in the stack for that location with appropriate permissions. When you create a &mut T, you push a new Unique item onto the stack; any pointers that previously had access above that tag are invalidated. The model tracks who has permission to access what, and undefined behavior occurs when code performs an access the model’s story cannot accommodate.

Inline assembly complicates this because assembly does not participate in Rust’s tagging system. It operates on raw addresses. If you pass a pointer into an asm block, the assembly sees a number with no tag, no permission record, and no place in the borrow stack. When the assembly accesses memory through that pointer, the model has no direct way to narrate that access.

Jung’s approach is to say that the programmer, not the compiler, is responsible for providing the story. When you write inline assembly that accesses memory, you are asserting that there exists a valid story, a valid sequence of Rust-level permission operations, that justifies each access your assembly makes. The assembly is a compressed encoding of some Rust code that would produce the same memory effects, and the story is that Rust code. If you can construct such a story, the access is valid. If you cannot, the asm block is unsound, regardless of what the hardware does.

This framing makes the programmer’s obligations concrete. If your assembly reads from a pointer, you need to ensure that pointer has active read permission at the time the asm block executes. If it writes, the pointer needs write permission. If you’re working with a raw pointer derived from a mutable reference, you need to ensure the mutable reference hasn’t been invalidated by any intervening code. You cannot check the hardware behavior in isolation; you have to check the abstract machine’s state.

What the Unsafe Code Guidelines Say

The Unsafe Code Guidelines reference documents the rules for unsafe Rust, including inline assembly. The interaction between inline assembly and the aliasing model is currently marked as an open problem. Stacked Borrows and its successor Tree Borrows apply to Rust’s abstract machine, but the question of how assembly accesses are checked against those models has no complete specification.

Jung’s storytelling framework is a path toward filling that gap. Rather than trying to extend the aliasing model to cover raw machine instructions, which would require encoding the full semantics of every target architecture, the framework asks programmers to justify their assembly at the Rust level. The assembly is allowed to do what any equivalent Rust code could do. If you cannot construct the equivalent Rust code, the assembly is unsound.

The practical consequence is that Miri, the interpreter that enforces Stacked Borrows and Tree Borrows at runtime, has limited support for inline assembly precisely because there is no complete story for what assembly is allowed to do with memory. This is an area of active development.

Implications for Real Code

Most inline assembly in Rust falls into a few categories: SIMD operations, system call stubs, atomic primitives, and hardware-specific instructions like cpuid or rdtsc. For a rdtsc with nomem and nostack, the story is trivial because there is nothing to justify; the block reads a hardware counter into a register and touches nothing else.

A SIMD load that reads 32 bytes from a *const u8 requires a more careful story. The pointer must have active read permission for those 32 bytes, the memory must be initialized, and the alignment requirement must be satisfied. The asm! operand system provides the tools to declare these effects correctly, but the story, the justification that those declarations match the actual state of the abstract machine, is the programmer’s responsibility:

// This needs a story: ptr must be valid for 32 bytes, 32-byte aligned,
// and must not be exclusively borrowed elsewhere.
unsafe {
    asm!(
        "vmovdqa {out}, [{ptr}]",
        out = out(ymm_reg) result,
        ptr = in(reg) ptr,
        options(readonly, nostack, preserves_flags),
    );
}

Getting the story wrong is undefined behavior that the compiler is free to exploit. Getting it right requires thinking about the abstract machine’s state, not just the hardware state.

The Broader Point

What makes Jung’s storytelling approach valuable is that it gives programmers a concrete proof obligation rather than a vague appeal to careful unsafe code. The question is not whether the hardware does what you expect, but whether you can construct a valid narrative, in terms of Rust’s abstract machine, that authorizes every memory operation your assembly performs. The first can be answered with a debugger; the second requires reasoning about types, lifetimes, and aliasing rules.

Most inline asm programmers operate on hardware intuition and never encounter problems in practice; the optimizing compiler, however, operates on the abstract machine model, and when the two conflict, the formal model wins. The gap between the hardware doing what you expect and the abstract machine agreeing with you is exactly the gap that formal semantics work addresses. Inline assembly sits at the bottom of that gap, and understanding the story it needs to tell is what makes it safe to use in a language that takes safety seriously.

Was this interesting?