· 6 min read ·

The Story Your Assembly Has to Tell: Inline Asm and Rust's Memory Model

Source: lobsters

Ralf Jung, author of the Stacked Borrows aliasing model and longtime contributor to Rust’s formal semantics, has a new post framing inline assembly through the lens of “storytelling” — the metaphor he’s long used to describe how unsafe Rust code must maintain a coherent narrative about memory. It’s a useful frame, and worth unpacking further, because the design of Rust’s asm! macro is more opinionated than it first appears.

What storytelling means in unsafe Rust

Rust’s ownership and borrowing system is, at its core, a formal claim about aliasing. When the compiler sees a &mut T, it’s entitled to believe that reference is the only path to that memory for the duration of its lifetime. When it sees a &T, it can assume no one is writing through any other pointer to the same location (unless the type contains interior mutability). These assumptions let the compiler reorder loads, eliminate redundant reads, and generally treat your code as if your stated invariants are true.

Writing unsafe code means telling a story that justifies those assumptions even when the normal rules don’t apply. Jung describes this in the context of Stacked Borrows: the compiler maintains a stack of permissions for each memory location, and every memory access must tell a story consistent with that stack. If your unsafe code creates an aliased mutable reference and then uses both aliases, the story breaks down — you’ve lied to the compiler, and the consequences are undefined behavior.

Inline assembly introduces a fundamentally different kind of narrator. The compiler has no visibility into what a block of assembly actually does. It can’t follow the assembly through the borrow checker. It can’t track what registers have been modified or which memory addresses have been touched. You, the programmer, have to supply that story explicitly, through the operands and options of the asm! macro.

What the asm! API actually encodes

Rust’s inline assembly, stabilized in Rust 1.59.0 following RFC 2873, takes a different approach than GCC-style inline asm. Rather than a free-form constraint string appended after the template, it uses structured operand kinds and a set of named options.

The operand kinds — in, out, inout, lateout, inlateout — encode a specific story about data flow. Consider:

use std::arch::asm;

let mut x: u64 = 5;
unsafe {
    asm!(
        "imul {0}, {0}, 3",
        inout(reg) x,
    );
}
// x == 15

The inout(reg) x declaration tells the compiler two things: the assembly reads the value of x from whatever register holds it, and after the assembly block completes, that same register holds the new value of x. The compiler is being handed a contract: no other register or memory location is involved with x during this block. It’s a narrow story, and it’s the programmer’s job to make it true.

The lateout variant tightens this further. With inout, the compiler must allocate distinct registers for inputs and outputs to prevent the output from overwriting an input before it’s read. With lateout, you’re telling the compiler the output won’t be written until all inputs are consumed, so it can reuse the same register. This is a performance promise embedded directly in the type of the operand.

The options clause and memory promises

Where the storytelling metaphor becomes most concrete is in the options clause. The available options for asm! include:

  • nomem: The assembly does not read from or write to any memory. It only touches registers.
  • readonly: The assembly may read memory, but does not write to it.
  • pure: The assembly has no observable side effects beyond its outputs, and its outputs depend only on its inputs. Combined with nomem or readonly, this allows the compiler to deduplicate or eliminate calls.
  • nostack: The assembly does not use the stack pointer.
  • preserves_flags: The assembly does not modify the condition flags register.
  • noreturn: The assembly does not return.

Each of these is a constraint you place on the story. Without nomem, the compiler must assume the assembly might read or write any memory it can reach, which prevents a wide class of optimizations around the asm block. Without preserves_flags, any condition code value computed before the assembly cannot be assumed valid after it.

unsafe {
    // A memory fence: reads and writes memory, but preserves flags
    asm!("mfence", options(nostack, preserves_flags));
    
    // A pure computation with no memory access or side effects
    let result: u64;
    asm!(
        "popcnt {0}, {1}",
        out(reg) result,
        in(reg) 0xdeadbeef_u64,
        options(nomem, nostack, pure, preserves_flags),
    );
}

The mfence case is instructive. You might think to add nomem since the instruction doesn’t move data, but mfence is a full memory barrier — its entire purpose is to create a happens-before relationship with respect to memory operations. Declaring nomem would be a lie, and the compiler could then hoist loads or stores across the fence, defeating its purpose.

The sym operand and symbol visibility

Rust also provides a sym operand type for referencing Rust symbols from assembly:

extern "C" fn my_callback() { /* ... */ }

unsafe {
    asm!(
        "call {0}",
        sym my_callback,
        options(nostack),
    );
}

This matters for the storytelling model because it lets you integrate assembly-level calls into Rust’s symbol resolution without bypassing the linker or using raw addresses. The compiler knows which symbol you’re referencing, which affects dead-code elimination and link-time optimization.

How this connects to the broader unsafe code guidelines work

Jung’s body of work on unsafe code guidelines is fundamentally about making the implicit stories explicit. The Stacked Borrows model and its successor Tree Borrows define precisely which access patterns are valid, so that Miri (Rust’s interpreter-based UB detector) can check whether a given unsafe program tells a consistent story.

Inline assembly has historically been difficult to integrate with Miri precisely because there’s no single correct model for what arbitrary assembly does to memory. Miri’s approach is to simulate the declared contract — if you say nomem, Miri trusts you, and if your assembly would have accessed memory, that’s an error you’ll only catch at runtime. The operand declarations become the boundary between verified-by-tool and trusted-by-programmer.

This is the heart of the storytelling requirement. Rust can verify that your safe code tells a consistent story. For unsafe code, you write the story yourself, and the compiler takes it on faith. For inline assembly, the story is encoded in the asm! syntax itself: the operand directions, the options, the clobber declarations via clobber_abi. Every omission is an implicit claim that the omitted effect doesn’t happen.

clobber_abi and calling conventions

One important addition is clobber_abi, which lets you tell the compiler that the assembly follows a known calling convention and thus clobbers all the caller-saved registers defined by that convention:

unsafe {
    asm!(
        "call some_c_function",
        clobber_abi("C"),
        options(nostack),
    );
}

Without this, you’d need to manually list every caller-saved register as an out clobber. With it, you’re delegating the register clobber story to a well-known convention. The compiler knows what clobber_abi("C") means on each target and inserts the appropriate saves and restores.

Practical implications

For most Rust code, inline assembly is a niche feature. But when you do reach for it — writing a spinlock, implementing a CPUID query, building a custom context switcher — the explicitness of the asm! API is an asset, not an obstacle. You’re forced to commit to a story about what your assembly does. The compiler enforces nothing about the correctness of that story, but the structure of the API makes the story legible, to the compiler, to Miri, and to future maintainers.

The contrast with C’s inline assembly is instructive. GCC-style asm volatile with constraint strings is expressive but largely unchecked at the semantic level. Nothing in the syntax forces you to think about whether your constraint string is lying about memory effects. Rust’s structured approach doesn’t eliminate the possibility of lying — that’s impossible with inline assembly — but it makes the lies harder to commit accidentally, because every silence about an effect is a deliberate choice in the API.

That’s a small but real shift. You’re not just writing assembly; you’re writing the story the compiler needs to hear.

Was this interesting?