· 8 min read ·

The Contracts Hidden in Rust's asm! Options

Source: lobsters

Inline assembly is fundamentally at odds with everything Rust stands for. The language’s entire value proposition rests on a type system that can reason about what code does, and assembly is, by definition, code that reasons about nothing. It manipulates registers and memory directly, ignores all type information, and can corrupt the stack, violate aliasing invariants, or read uninitialized values without any warning from the compiler.

That Rust has an inline assembly facility at all is a pragmatic concession to systems programming reality. That it has one with a coherent safety model is the more interesting story, and it’s the one Ralf Jung examines in his recent post on fitting inline assembly into the “storytelling” model for unsafe Rust.

What the Storytelling Model Actually Is

Jung has spent years formalizing what “correct unsafe Rust” means, through Stacked Borrows, Tree Borrows, and the Unsafe Code Guidelines working group. The central observation is that every unsafe block implicitly carries a justification: a chain of reasoning that connects the surrounding context to the preconditions required for the operation to be sound.

This justification is the “story.” When you write unsafe { *ptr }, the story might be: “this pointer was derived from a live Box<T>, the box has not been freed, no other &mut reference to this data exists, and the data is initialized.” The story is supposed to be true, and if it is, the operation is sound. If it isn’t, you have undefined behavior regardless of whether a crash actually occurs.

For most unsafe operations in Rust, the story is compact because the operation has a well-defined precondition. slice::from_raw_parts requires the pointer to be aligned, non-null, valid for len elements, and free from aliasing &mut references. You can check that story against the calling context.

Inline assembly is harder because there is no single precondition. Assembly is a language with arbitrary side effects, and the story has multiple independent chapters.

The asm! Macro and Its Options

The asm! macro, stabilized in Rust 1.59.0, has a deliberately structured interface:

use std::arch::asm;

unsafe {
    let x: u64 = 42;
    let y: u64;
    asm!(
        "mov {0}, {1}",
        "add {0}, 1",
        out(reg) y,
        in(reg) x,
        options(pure, nomem, nostack),
    );
    assert_eq!(y, 43);
}

The operands (in, out, inout) tell the compiler which values flow into and out of the block. The options clause is where the stories live.

options(nomem) is the claim that this assembly block does not access memory. The compiler takes this as permission to reorder memory operations around the block, keep values in registers across it, and generally treat it as invisible to the memory model. If your asm actually loads or stores memory, you have made a false claim, and the compiler will misoptimize code in ways that depend on that claim.

options(readonly) relaxes nomem in one direction: the asm may read memory but not write it. The compiler can assume that writes it has performed are visible to the asm, and that no memory the asm touches will change afterward. This is the right option for something like a CRC computation over a buffer.

options(pure) combined with either nomem or readonly declares the assembly to be deterministic: given the same input operands, it will always produce the same output operands and have the same memory effects. The compiler may eliminate calls whose results are unused, or deduplicate multiple calls with identical inputs. Lie about purity and the compiler will collapse or remove computations that should actually run.

options(nostack) promises that the assembly does not use stack memory below the current stack pointer. This matters on x86-64 with the System V ABI, where the red zone (128 bytes below RSP) is reserved for leaf functions. The compiler may spill values into the red zone. If your asm modifies those bytes, you silently corrupt the compiler’s temporaries. Signal handlers on Linux have been bitten by exactly this: the handler fires mid-leaf-function and its prologue clobbers the interrupted function’s red zone.

options(preserves_flags) tells the compiler the asm restores the CPU flags register to its state at entry. Without this option, the compiler assumes the asm may leave any flags in an arbitrary state, so it won’t carry any prior comparison result across the block. If you do preserve flags in practice but omit this option, you’re just leaving performance on the table. If you claim the option but modify flags, you break subsequent branches that depend on flag state.

The Register Operand Story

The operand declarations are not just for register allocation. They are the chapter of the story about data flow. in(reg) x says that x has been moved into a general-purpose register before the asm starts, and the asm may read it. out(reg) y says that after the asm finishes, whatever value is in the corresponding register will be assigned to y. inout(reg) z says the same register serves for both input and output.

If you declare out(reg) y but your assembly writes the result into a different register, the compiler reads the declared output register, not the one you actually wrote. The value y gets is whatever happened to be in that register for another reason. This is not a detectable bug at compile time; it’s a story that turned out to be a lie.

The lateout variant matters for instruction-level scheduling: it tells the compiler that the output register is not written until after all inputs are consumed, so the same physical register can be reused for an input and the output even without inout. The story here is about the timing of writes within the asm block, which the compiler cannot observe.

Memory Aliasing: The Hard Chapter

The most difficult part of the inline assembly story is what happens when the asm dereferences pointers. Consider:

unsafe fn zero_u64(ptr: *mut u64) {
    asm!(
        "mov qword ptr [{0}], 0",
        in(reg) ptr,
        // no nomem: we do touch memory
        options(nostack, preserves_flags),
    );
}

The implicit story here includes: the pointer is valid for a write of 8 bytes, is properly aligned, and the write does not violate any aliasing invariant. But the compiler cannot verify this. It sees a pointer value flow into an asm block and trusts the programmer’s story about what happens to it.

Rust’s aliasing model, formalized through Stacked Borrows and its successor Tree Borrows, is precise about what “valid for writes” entails. If ptr was derived from a &mut u64 that some other part of the code still holds, writing through it in assembly violates exclusive ownership. The borrow checker would catch this for ordinary Rust code; it cannot see through asm!.

This is the tension that makes inline assembly fundamentally different from other unsafe operations. When you call ptr::write, the contract is stated explicitly in the documentation and partially checked by Miri. When you write to memory in asm!, the contract is what you privately believe to be true, expressed only in whatever comments you left behind.

Comparison With C’s Extended Asm

GCC’s extended asm takes a different approach to the same problem:

int result = initial;
asm volatile (
    "addl %1, %0"
    : "+r" (result)   /* output (also input) */
    : "r"  (x)        /* input */
    : "cc"            /* clobbers: condition codes */
);

C’s model is additive: you list what the asm destroys. Everything not in the clobber list is assumed preserved. Rust’s model is declarative in a different direction: you list what the asm uses, then make categorical promises about side-effect classes via options. Rust forces you to be positive about resource flows (what enters and leaves the block) while C forces you to be negative (what gets destroyed).

Neither model is strictly better, but Rust’s is more amenable to formal reasoning. In C, a missing clobber is a silent lie; in Rust, an undeclared output operand simply means the value is discarded rather than making the wrong thing appear. The options clause in Rust also cleanly separates concerns (memory, stack, flags, purity) that in C are conflated in the clobber list and the volatile keyword.

Zig’s asm statement takes a similar declarative approach to Rust but without the separation of memory categories, leaning on the caller to mark asm as volatile or not.

Miri and the Limits of Mechanical Checking

Miri, the Rust interpreter built for catching undefined behavior, has partial support for inline assembly. On x86-64, it can simulate common instructions in asm! blocks, which means some story violations can be caught at test time. If you declare options(nomem) but your assembly actually reads memory, Miri can observe the memory access and report it as a violation.

But Miri cannot check everything. Architecture-specific instructions it doesn’t model, memory-mapped I/O, CPU feature detection via cpuid, fence semantics - these all require the human-written story to be correct by construction. The coverage is growing, but there will always be a gap between what a formal tool can verify and what assembly can express.

This is not a flaw in the system; it reflects the fundamental nature of the problem. Assembly is the level at which hardware meets software, and no amount of type-theoretic machinery can fully verify what happens when you touch MMIO registers or issue a serializing instruction. The storytelling model does not pretend otherwise. It provides a framework for documenting the claims that must be true, so that when something breaks, there is at least a specified story to check against reality.

Why This Matters Beyond Inline Assembly

The storytelling model’s application to asm! is worth understanding even if you never write a line of inline assembly, because it illustrates how Rust handles the general problem of unsafe code in a principled way. The options clause is not documentation; it is a contract with defined semantics that the compiler acts on. Violating the contract produces undefined behavior by specification, not by accident.

This is the shape of how Rust’s safety story is supposed to work at every level: each unsafe operation has a stated contract, the programmer is responsible for satisfying it, and violations have a well-defined meaning (undefined behavior) rather than a vague one (“something bad might happen”). Formalizing that same structure for inline assembly, where the contracts are more numerous and harder to state, is the work Ralf Jung is doing. It is painstaking work and it matters, because every unsafe abstraction in the ecosystem ultimately rests on stories like these being true.

Was this interesting?