Inline Assembly

26 min read
On this page

asm! embeds target-architecture assembly into Rust-generated code and tells the compiler about its behavior via operands and options. It is not a "block all optimizations" wall; the optimizer still arranges surrounding code based on the inputs, outputs, and side effects you declare.

Before using it, check whether the standard library or architecture intrinsics already express the desired operation. Direct assembly is suitable for boundaries where specific target instructions or ABI requirements are necessary; ordinary arithmetic should not be rewritten for the sake of performance intuition.

InputsWhich values are readable upon entryOutputs and ClobbersWhich registers may be writtenMemory and FlagsMemory access and flagsControl Flow and StackReturns, stack use and ABIThe contract of inline assembly: the compiler only knows what you declareJust because assembly can be assembled doesn't mean these declarations are true; incorrect constraints will be exposed after optimization.

Start with a well-defined instruction

The following targets x86-64, using the default Intel syntax for asm!:

#[cfg(target_arch = "x86_64")]
fn add_wrapping(mut left: u64, right: u64) -> u64 {
    // SAFETY: Operates only on declared registers; does not access memory or stack; add modifies flags but does not declare preservation.
    unsafe {
        core::arch::asm!(
            "add {left}, {right}",
            left = inout(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    left
}

fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        assert_eq!(add_wrapping(4, 5), 9);
        assert_eq!(add_wrapping(u64::MAX, 1), 0);
    }
}

The function name clarifies the modulo 2^64 wrapping semantics; it cannot be treated as a fully equivalent replacement for ordinary Rust + when overflow checks are enabled. add modifies flags, so preserves_flags cannot be attached. pure here indicates that the output depends only on inputs and has no other observable side effects, allowing optimizations such as deleting unused results.

The key difference between inout and inlateout

OperandCommitment
in(reg)Initial input; registers not listed as outputs must satisfy conventions by the end of the assembly
out(reg)Output can be written early while input is still in use, avoiding improper overlap with inputs
inout(reg)Same operand serves as both input and output, with more conservative allocation constraints
lateout(reg)Written only after all inputs have been read, allowing reuse of input registers
inlateout(reg)Input/output, allowing reuse of input registers that meet the conditions

lateout allows reuse of input registers, provided the write timing satisfies the constraints. If a multi-instruction template first modifies the output and then continues reading another input, incorrect use of lateout/inlateout may yield wrong results after the optimizer reuses registers. Rust Inline Assembly

reg is allocated by the compiler, while explicit ones like "eax" specify physical registers. Explicit register operands cannot be referenced via template placeholders; instead, write the register name directly in the template; sub-register widths must also correspond to the instruction and Rust type.

Instruction 1: mov output, leftWrites output; left has been readInstruction 2: add output, rightStill needs to read original rightMulti-instruction template: when output starts writing, are there still inputs unread?output cannot overwrite right's register prematurelyUse out(reg) to preserve inputs not yet read by the second instructionReturning the output to Rust at the end of the template is a different concept from when the register is first written inside the template.

Multi-instruction outputs cannot unconditionally be changed to lateout

#[cfg(target_arch = "x86_64")]
fn sum(left: u64, right: u64) -> u64 {
    let output: u64;
    // SAFETY: All read/write registers are declared; output is written early, use out to avoid overwriting unread inputs.
    // Performs only register calculations, does not access memory/stack; add modifies flags.
    unsafe {
        core::arch::asm!(
            "mov {output}, {left}",
            "add {output}, {right}",
            output = out(reg) output,
            left = in(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    output
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    for left in [0, 1, 7, u64::MAX, 1 << 63] {
        for right in [0, 1, 7, u64::MAX, 1 << 63] {
            assert_eq!(sum(std::hint::black_box(left), std::hint::black_box(right)),
                       left.wrapping_add(right));
        }
    }
}

If output and right are allocated to the same register, the first mov will destroy right before it is used in the second instruction. Marking the output as lateout allows the compiler to permit this reuse; it is not safe just because variable names differ or debug builds happen to allocate different registers. Operand Constraint Examples

Rust type widths and template register widths are checked together

#[cfg(target_arch = "x86_64")]
fn add_u32(mut left: u32, right: u32) -> u32 {
    // SAFETY: The e modifier selects the 32-bit sub-register; only modifies declared outputs and allowed flags.
    unsafe {
        core::arch::asm!(
            "add {left:e}, {right:e}",
            left = inout(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    left
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        assert_eq!(add_u32(10, 20), 30);
        assert_eq!(add_u32(u32::MAX, 1), 0);
    }
}

:e specifies the 32-bit name in x86 register templates. Do not assume that placing a u32 into reg means the higher bits of the wider register printed by default in the template contain a dependable value; instruction width should match the actual input. Modifiers and register classes vary across architectures, so choose according to the target documentation.

Does instruction two read the original input?

Different variable names do not imply different physical registers. Deliberately sharing output and right makes the first mov corrupt the second add’s input. This counterexample gives concrete meaning to the separation preserved by out.

Preparing the visual
Does instruction two read the original input?

Writing output early can destroy unread right; this template needs out, not an arbitrary lateout.

RDTSC: Low bits in EAX, High bits in EDX

The following only reads the count value, not a complete micro-benchmark timing boundary. The runtime environment must allow user-mode reading of the TSC:

#[cfg(target_arch = "x86_64")]
fn read_tsc() -> u64 {
    let low: u32;
    let high: u32;
    // SAFETY: In target environments allowing user-mode RDTSC, only clobbers declared EAX/EDX.
    unsafe {
        core::arch::asm!(
            "rdtsc",
            out("eax") low,
            out("edx") high,
            options(nomem, nostack, preserves_flags),
        );
    }
    ((high as u64) << 32) | low as u64
}

fn main() {
    #[cfg(target_arch = "x86_64")]
    println!("{}", read_tsc());
}

You cannot declare multiple conflicting outputs for RAX, nor can you claim to have automatically assembled a 64-bit result by reading only RAX. RDTSC varies with external counters and cannot be marked pure.

RDTSC does not provide full instruction serialization; to measure code before and after, you need to design ordering, timer reads, and compiler visibility according to the processor manual, and evaluate core migration, virtualization, and frequency. TSC ticks are not equivalent to every actual execution cycle on the current core. Intel SDM

AArch64's CNTVCT_EL0 is a virtual system counter, with its own conventions for frequency and access permissions; it should not be broadly called a "core cycle counter." Cross-architecture porting requires rebuilding the measurement definition, not just replacing one mrs instruction. Arm System Counter

options are not performance hints, but dependable declarations

nomem indicates no read/write of external memory visible to assembly, providing corresponding guarantees that allow the compiler to handle surrounding memory operations more aggressively; it is not "flush or do not flush CPU cache." readonly and pure also have specific requirements; instructions that write buffers or access devices cannot be marked as pure read or pure computation.

nostack commits to not using stack space beyond what is specified, including the target ABI's red zone rules; preserves_flags commits to preserving the corresponding architecture flags. noreturn declares that control will not return to subsequent Rust code. False declarations may cause undefined behavior, even if debug builds appear normal.

Ordinary memory reads/writes, volatile, compiler reordering constraints, and CPU memory barriers are different levels; the default conservative memory impact of asm! does not automatically make non-atomic shared accesses thread-safe.

Passing an address does not equate to declaring a memory read

#[cfg(target_arch = "x86_64")]
fn load(value: &u64) -> u64 {
    let output: u64;
    // SAFETY: value is a valid, aligned, and conflict-free u64 during the call;
    // mov reads this address, does not write memory/stack, and does not modify flags.
    unsafe {
        core::arch::asm!(
            "mov {output}, qword ptr [{address}]",
            address = in(reg) value as *const u64,
            output = lateout(reg) output,
            options(readonly, nostack, preserves_flags),
        );
    }
    output
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        let values = [0u64, 42, u64::MAX];
        for value in &values { assert_eq!(load(value), *value); }
    }
}

address is a register input, [address] performs the memory read, so nomem cannot be marked. A single mov reads the address operand before writing back to the output, so lateout here can be reused with the address input; this differs from the two-instruction template earlier.

readonly indicates no writing to relevant external memory, but reading still occurs. This safety entry point accepts &u64, using the guarantees of the reference to constrain the read range and lifetime; if replaced with an arbitrary raw address, the wrapper must add additional call prerequisites. MMIO, volatile, and thread synchronization cannot be replaced by this ordinary read template.

Actual BehaviorConsiderations for Declaration
Register-only computationInputs, outputs, flags; can be pure + nomem if conditions met
Reading ordinary memory via pointerCannot be nomem; read prerequisites established by interface
Writing to bufferCannot be readonly; requires correct exclusive access or synchronization and valid range
Reading changing hardware counterCannot be marked pure just because it doesn't write memory
Calling external functionABI, register clobbers, stack, memory, and unwinding conventions must be checked together

Calling functions in assembly carries full ABI obligations

clobber_abi("C") declares the caller-saved register set for the specified ABI, but does not place arguments for you, maintain stack alignment, reserve Windows shadow space, or implement unwind information. There are rules for using it with outputs, such as explicit registers; check item by item according to the documentation.

Therefore, a single call plus one clobber_abi is not a safe call wrapper that can be copied anywhere. Ordinary function calls should usually be handed over to Rust extern declarations; consider global_asm!/naked_asm! and corresponding target constraints only when hand-writing entry points or complete functions is truly necessary.

Verification should not rely solely on "it compiles"

Test boundary inputs on the target platform, comparing pure computations and implementations with explicit Rust semantics; check at least optimized builds, as register reuse errors may only appear after optimization. Inspect the final disassembly to confirm instructions, register widths, and surrounding code arrangement.

The compiler can detect some conflicts or illegal operands, but usually cannot prove consistency between the template and clobbers/options. Miri has limited support for inline assembly; you cannot prove assembly safety using uncovered execution results. Conditional compilation protects architecture syntax; runtime conditions for CPU instruction extensions are handled separately, see cfg.

How to verify without turning micro-benchmarks into instruction myths

First, write a safe Rust reference implementation of the computational semantics, testing zero values, boundary values, identical inputs, and overflows, then compare results across different optimization levels. Identical inputs can particularly affect the compiler's register sharing choices; tests should not only cover a few small, mutually unequal numbers.

You can use rustc --edition=2024 -O --emit=asm example.rs to output optimized assembly, checking operand allocation and code surrounding the template. Viewing a specific assembly snippet is only evidence for the current target, version, and optimization configuration; do not hardcode register names into language contracts.

When measuring performance, additionally consider preventing useless computations from being deleted, measurement overhead, branch distribution, cache, and frequency. black_box can help avoid some optimizations, but does not guarantee precise timing, nor is it a safety boundary; subtracting a few RDTSC readings does not automatically exclude these variables. If standard Rust already generates the same instructions, hand-written asm only increases maintenance and cross-platform costs.