From Clobber Lists to Storytelling: How Rust Gave Inline Assembly a Safety Model
Source: lobsters
Inline assembly has always occupied an uncomfortable position in systems programming. It is simultaneously the most powerful tool in the toolbox and the one most likely to produce bugs that are invisible until they corrupt memory in production. Languages have dealt with this discomfort in various ways, mostly by bolting annotations onto an inherently unstructured facility and hoping programmers read the documentation carefully.
Rust took a different path. The stabilization of the asm! macro in Rust 1.59 in February 2022 was not just a syntax change from the earlier unstable llvm_asm! interface. It was the introduction of a structured contract system for inline assembly, one that Ralf Jung’s recent post situates within his broader storytelling model for unsafe Rust.
Understanding why this matters requires looking at where we came from.
The C Model: Listing What You Destroy
GCC’s extended asm syntax works by enumeration of damage. You write the assembly, specify input and output operands, and then list everything the assembly destroys that wasn’t listed as an output:
int result = initial;
asm volatile (
"addl %1, %0"
: "+r" (result) /* output, also used as input */
: "r" (addend) /* input */
: "cc" /* clobbers: condition codes */
);
The model is additive from a damage perspective. Anything not clobber-listed is assumed to survive unchanged. If your assembly touches a register you forgot to list, the compiler will happily assume that register still holds whatever it held before, and the resulting miscompilation may only surface under specific optimization levels or inlining decisions.
The volatile keyword addresses a separate concern: it tells the compiler the assembly block has side effects and should not be eliminated or duplicated even if its outputs are unused. This is the blunt instrument for saying “this matters, do not touch it.” A volatile ("memory") clobber escalates further, declaring that the assembly may have read or written any memory location, forcing the compiler to flush and reload everything.
This model has served decades of systems programming, but it has a structural problem. The programmer reasons in terms of what they are destroying, which is a poor fit for communicating intent. The semantics of volatile conflate two distinct concepts, side-effect opacity and ordering, into one keyword. And there is no formal framework for saying what the assembly is supposed to do, only what it happens to wreck.
The Rust Model: Stating What You Claim
Before the current asm! macro, Rust had llvm_asm!, which was essentially a thin wrapper over LLVM’s inline assembly syntax. It inherited the same additive-damage model, the same clobber lists, the same volatile semantics. It worked, in the same way C’s extended asm works: through careful programmer discipline and a lot of opportunities to introduce subtle undefined behavior.
The new asm! macro inverted the model. Instead of listing damage, you state explicit claims:
use std::arch::asm;
unsafe {
let val: u64;
asm!(
"rdtsc",
"shl rdx, 32",
"or rax, rdx",
out("rax") val,
out("rdx") _,
options(nomem, nostack, preserves_flags),
);
println!("TSC: {}", val);
}
Each element of the options clause is a positive claim about what this block does or does not do. nomem is not “I haven’t listed any memory clobbers”; it is “I am asserting that this block does not access memory.” The compiler treats that assertion as a fact it can optimize on, and the programmer is responsible for the assertion being true.
This is a meaningful distinction. In C, a missing clobber is an omission that happens to be wrong. In Rust, writing options(nomem) when your assembly touches memory is a lie you have told to the compiler, and the compiler will act on that lie.
What the Options Actually Mean
Ralf Jung’s storytelling model frames unsafe Rust as a system of justifications. Every unsafe block requires a “story”: a chain of reasoning that connects the surrounding context to the preconditions the operation requires. For most unsafe operations, the story is compact because there is a single documented precondition. For asm!, the story has multiple independent chapters, one per options claim.
options(nomem) is the claim that the assembly is invisible to the memory model. The compiler may reorder memory operations around it, keep values in registers across it, and treat it as a pure computation in terms of memory effects. The story required to tell this truthfully: you have audited every instruction in the block and confirmed none of them load from or store to any memory address. This includes implicit memory accesses, segment register operations on some architectures, and any instruction that touches the stack through a mechanism other than explicit operands.
options(readonly) relaxes nomem in one direction. The assembly may read memory but not write it. The compiler can assume that any writes it performed before the block are visible to the assembly, and that memory state after the block is the same as before. This is appropriate for something like a hardware-accelerated checksum over a buffer you pass in: reading is fine, writing would be a lie.
options(pure) combined with either nomem or readonly declares that the assembly is deterministic. Same inputs produce the same outputs and the same memory effects, every time. The compiler may eliminate dead calls, hoist the block out of loops, or merge multiple calls with identical inputs into one. If your assembly reads from a hardware register that changes over time, a timestamp counter or a random number generator, pure is a false claim. The compiler will merge consecutive calls or hoist them out of tight loops, and you will get one value where you expected many.
options(nostack) carries a contract that is easy to misunderstand. It promises that the assembly does not use stack memory below the current stack pointer. On x86-64 with the System V ABI, the red zone extends 128 bytes below the stack pointer and is available for leaf functions to use as scratch space without adjusting the stack pointer. The compiler may spill register values into the red zone as an optimization. If assembly inside an unsafe block modifies those bytes, it silently corrupts the compiler’s temporaries.
This is not theoretical. Signal handlers on Linux have historically corrupted interrupted leaf functions by treating the red zone as available stack space. The handler fires between two instructions of a leaf function that has spilled values into the red zone, the handler’s prologue overwrites those values, and the interrupted function resumes with garbage where its temporaries were. nostack in asm! is the programmer asserting that this specific block will not cause that class of bug.
options(preserves_flags) states that the assembly leaves the CPU’s flags register in the same state it was in on entry. Without this claim, the compiler must assume the assembly may have set any flag to any value, so it cannot carry a prior comparison result across the block. In practice this means it may reload and re-evaluate comparisons that logically should survive. Claiming preserves_flags when you do not actually restore flags will corrupt subsequent branches; omitting it when you do preserve flags just prevents an optimization.
The Register Operand Story
The operand declarations, in, out, inout, lateout, are not just for register allocation. They are the part of the story about data flow, and they are easy to get wrong in subtle ways.
out(reg) y is the claim that after the assembly executes, the value in the register allocated for y will be assigned to y. If your assembly writes its result into a different register than the one the compiler allocated, you read the wrong register. You do not get an error; you get whatever happened to be in the declared register for an unrelated reason. The story was told, it was false, and the consequences are silent.
lateout is for the case where you can share a physical register between an input and an output without using inout, because the output register is not written until all input registers have been consumed. This is relevant for multi-operand instructions where register pressure makes sharing desirable. The story: all inputs are read before any outputs are written.
What Miri Can and Cannot See
Miri, the Rust interpreter for catching undefined behavior, has partial support for inline assembly. On x86-64, it can simulate many common instructions and observe whether the behavior matches declared options. If you declare options(nomem) and your assembly makes a memory access, Miri can catch that at test time.
But Miri cannot check everything, and it never will. Instructions it has not modeled, hardware-specific behavior, memory-mapped I/O, the semantics of serializing instructions like mfence in the presence of out-of-order execution: these all require the story to be correct by construction. No static or dynamic tool can fully verify what happens when software meets hardware at the instruction level.
This is not a gap in the implementation; it is a reflection of the problem domain. The storytelling model is not a claim that every story can be automatically verified. It is a claim that formalizing what must be true, and requiring programmers to state it explicitly, is better than the alternative. When something breaks, there is a written story to check against reality. When a story is false, the resulting undefined behavior is specified rather than vague.
Why This Matters for the Ecosystem
Inline assembly in Rust appears in a relatively small number of crates, but those crates are foundational. The standard library uses it for atomic operations on some targets. Cryptographic libraries use it for constant-time implementations of algorithms where the compiler’s optimizations would break the security properties. The raw-cpuid crate uses it to query processor features. Low-level OS and bootloader code uses it for everything from setting up page tables to context switching.
Every caller of these abstractions inherits the safety assumptions in the stories those crates tell. If a cryptographic library falsely claims options(pure) on an assembly block that actually reads from a hardware entropy source, the compiler may deduplicate calls in security-critical code. If a context-switching implementation gets the lateout story wrong, the bug manifests in context switches under specific register pressure conditions.
The storytelling model does not prevent these bugs, but it makes the contracts explicit at the point where they must be stated. The options are not comments explaining intent; they are inputs to the compiler with documented consequences for violations. That is a small but real improvement over a clobber list that might be missing an entry because the programmer forgot.
Ralf Jung’s ongoing work on the Unsafe Code Guidelines is an attempt to specify, formally, what all these stories must contain to be sound. The application of that framework to asm! is the most recent chapter. It will not be the last, because the hardware keeps changing and the gap between what assembly can express and what formal tools can verify will always require human judgment. What the model provides is a principled way to structure that judgment and communicate it to the compiler and to future readers of the code.