February 24, 2022·6 min read·engineeringrustassemblyunsafecompiler

Rust 1.59 gives inline assembly an explicit compiler contract

Stable inline assembly describes register inputs and outputs alongside machine instructions. Rust 1.59 also adds destructuring assignment and defaults for const parameters.

Machine instructions inside a function do not explain their effect to an optimizer. The compiler needs to know which values enter the block, which registers change and what memory the instructions may touch. Rust 1.59's stable inline assembly is significant because it provides a supported way to state that contract alongside the instructions.

The 24 February announcement stabilizes inline assembly on x86, x86-64, ARM, AArch64 and RISC-V. It also introduces destructuring assignment and const-generic defaults. The assembly work is the more consequential change for code that needs hardware-specific instructions or precise low-level behavior.

I find Rust particularly compelling where the alternative is code written in C for systems constraints. A stable assembly interface matters in that comparison because it lets low-level operations remain inside a Rust implementation with an explicit boundary. It does not imply that the compiler can establish the correctness of arbitrary instructions.

The syntax was designed for stabilization

The inline assembly RFC, started in January 2020, explains why the previous nightly interface was unsuitable as a permanent design. It was a thin interface to LLVM's assembly syntax. The new syntax aims to be easier to use and more suitable for implementations using different compiler backends.

That history matters because stabilizing an interface creates obligations beyond accepting a useful example. The language needs a way to describe operands and effects that is not merely an accidental reflection of one backend's input format. Users also need declarations whose meaning they can inspect without inferring register behavior from an opaque string.

The template uses formatting-style placeholders for operands. A register class such as reg lets the compiler select an appropriate register, while an explicit register names the location required by a particular instruction. The template describes the instruction text after the compiler substitutes operand locations.

This is a separate use of familiar brace syntax. It should not be confused with capturing arbitrary Rust bindings from an ordinary format string. The assembly declaration needs the operand's direction and register requirements, which a name in the template alone cannot express.

Inputs and outputs define the data flow

An input operand supplies a value when the assembly begins. Output operands supply destinations that receive register values when the assembly finishes. An inout operand combines those roles, with the incoming and outgoing value using the same allocated register.

For example, this x86-64 fragment writes a known value to its declared output. The safety comment states the limited behavior of the specific instructions, rather than relying on the existence of an unsafe block as an explanation.

use std::arch::asm;

let value: u64;
// SAFETY: The instruction initializes the declared output register
// and does not access memory or change the stack pointer.
unsafe {
    asm!("mov {value}, 7", value = out(reg) value);
}
assert_eq!(value, 7);

The declaration lets the compiler choose the register and treat its final contents as the value of the Rust variable. Hardcoding an unrelated register in the instruction without declaring its effects would give the compiler an incomplete description. The surrounding Rust code may depend on values the compiler believes remain intact.

A scratch register is also an output even when the program does not need its final contents. The interface permits discarding that output with an underscore. Discarding the value does not remove the need to tell the compiler that the register changes.

This is one of the useful distinctions in the design. The Rust program's interest in a result and the optimizer's need to account for a modification are different questions. An unused scratch result still has consequences for register allocation.

Explicit register operands have their own template rule. The instruction names the register directly instead of referring to that operand through a placeholder. This is useful for instructions whose inputs or outputs are fixed by the architecture. The declaration tells the compiler about those fixed locations even when the instruction spelling already makes them apparent to a human reader.

Register aliases also matter to validation. The RFC treats overlapping views of the same register as the same storage when checking incompatible operand assignments. Two different textual names do not establish that the machine has two independent locations. The compiler's understanding must follow the architecture's register relationships.

Late outputs make a stronger promise

The RFC distinguishes ordinary outputs from late outputs. An ordinary output may be written before all inputs have been consumed, so its register cannot generally share an input location. A late output promises that its writes occur only after the inputs have been read, allowing more reuse by the register allocator.

That is an optimization opportunity with a correctness obligation. A block containing several instructions may overwrite its output during the first instruction while still needing another input in the second. Declaring that output late can permit register sharing that changes the second instruction's input.

The RFC illustrates the problem with equal-valued inputs. The compiler can know that two inputs contain the same value and assign them the same register. If an output also reuses that register too early, the assembly changes a value the later instruction still expects to read.

The issue can remain hidden in a particular build because the allocator happens to choose different registers. Successful execution of one example does not validate the stronger promise. The declaration needs to be correct for the allocation choices the interface permits.

My preference is to treat each such qualifier as part of the algorithm's proof obligations. A late output is useful when the instruction order justifies it. Adding it simply because it sounds more efficient makes the source communicate a claim the instructions may not satisfy.

Effect options are assertions about the block

The RFC describes a conservative default in which assembly may have memory effects and observable side effects. Options can supply stronger information to the optimizer. For example, nomem says the block does not read or write memory, while pure describes a result determined by its inputs without other observable effects.

These options influence what transformations the compiler may perform. A pure block whose result is unused may be removable. A false claim about memory effects can allow surrounding operations to move in ways the author did not intend. The options therefore belong to the correctness argument, alongside register declarations.

The same reasoning applies to stack use. An option saying that the block does not use the stack allows optimizations based on that restriction. Whether the instruction sequence actually meets the restriction has to be established for the selected target and instructions.

This is why stable inline assembly does not eliminate the unsafe boundary. The compiler can check portions of the declaration and assemble the instructions, but the author still states facts about their behavior. A wrapper can concentrate that responsibility and expose a smaller safe operation when its preconditions are enforceable.

Destructuring assignment removes a different inconsistency

Rust 1.59 also lets tuple, slice and structure patterns appear on the left side of assignment. The release explains that let bindings already supported this kind of decomposition. Assignment can now express related updates using a corresponding structure.

This matters when a function returns several values that should replace existing bindings. The code can describe their association together instead of inventing intermediate names solely to transfer each component. The resulting assignment still needs to make the relationship between the returned values and the destinations clear.

The release explicitly excludes compound forms such as destructuring with an addition-assignment operator. That limit prevents an ordinary assignment example from being generalized into syntax the stable compiler does not support. Familiarity with one pattern context is useful, but the supported operation still has its own rules.

Const defaults improve the declaration of fixed storage

Generic types can now give const parameters default values. A type representing fixed-capacity storage can select a conventional capacity while allowing callers to supply another value. The capacity remains part of the type's compile-time declaration, so a default does not make it a dynamically chosen allocation size.

The release also relaxes the requirement that all type parameters precede const parameters. Interleaving them allows a declaration to keep an element type close to its associated array length. That can improve the explanation of a function accepting several arrays with different element types and sizes.

I regard those declaration changes as useful companions to the assembly work. They improve the expression of programs whose storage and machine behavior need precise descriptions. The larger responsibility remains with the author of the low-level operation, who must make the operand and effect declarations match every instruction the compiler is asked to embed.