machdocs
Home GitHub

Inline assembly

Mach has one inline-assembly form: an ISA-tagged block of raw instructions with local-variable substitution. The compiler parses the instruction stream and infers operand direction and clobbers from the opcode semantics - no in / out declarations, no clobber list.

Grammar

asm <isa> {
    # raw instructions, one per line, # for comments
    mov rcx, {ptr}
    mov rax, [rcx]
    mov {result}, rax
}

The three ISAs share one statement grammar, one effect model, and one numeric-local-label scope; each supplies only its mnemonic table and encoder.

Operand substitution

{name} substitutes a local in scope. The compiler resolves the reference to a memory or register operand based on liveness and the instruction's expected operand class.

pub fun add_via_asm(a: i64, b: i64) i64 {
    var result: i64 = 0;
    asm x86_64 {
        mov rax, {a}
        add rax, {b}
        mov {result}, rax
    }
    ret result;
}
Note

In practice a {name} binds the local's storage - typically a stack slot - so a pointer local's pointee is reached by staging the pointer through a scratch register first (mov rcx, {ptr} then mov rax, [rcx]), never by a direct [{ptr}] indirection.

What the compiler infers

Calls (x86-64)

call takes three shapes, and which one a statement means is read off the operand.

asm x86_64 {
    call some_symbol     # direct: E8 rel32, relocated against the symbol
    call rax             # indirect through a register
    call [0x100018]      # indirect through an absolute address
    call [rax + 8]       # indirect through a computed address
}

The absolute form exists for a fixed-address ABI - one whose entry points are addresses rather than symbols. Its displacement is sign-extended to 64 bits, so an address outside signed 32-bit range is refused rather than silently truncated. call [symbol] is refused too: the rip-relative form would mean "call the pointer stored at the symbol", which is not what the call symbol beside it means.

An indirect call clobbers exactly as a direct one does. The register or memory holding the target is read, not written.

Privileged and system instructions

The privileged and system instruction families are reachable from inline assembly on every target: x86-64 port I/O (in / out), cli / sti / lidt, and rdtsc / rdmsr / wrmsr; aarch64 mrs / msr; and the RISC-V CSR family.

System registers (aarch64)

mrs and msr name a system register by its architectural name, in either case.

asm aarch64 {
    mrs x0, cntvct_el0        # the virtual counter
    mrs x1, CNTFRQ_EL0        # ... and its frequency, capitalized as ARM spells it
    msr vbar_el1, x2          # install an exception vector base
    msr daifset, 0xf          # mask every interrupt
}

The named set covers what freestanding code reaches for. It is deliberately not exhaustive: any system register is also nameable by its encoding, exactly as ARM and GNU as spell it, which is what makes the surface complete rather than a list that always lags the architecture.

asm aarch64 {
    mrs x0, s3_3_c14_c0_2     # the same register as `mrs x0, cntvct_el0`
}

A field the architecture cannot hold is refused rather than truncated, because a truncated selector would name a different register than the text does. msr <field>, #imm writes a PSTATE field (daifset, daifclr, spsel, pan, uao, ssbs, dit, tco); the architecture spells these by name only, so there is no numeric escape for that form.

Control-and-status registers (riscv64)

The Zicsr extension's six instructions - read-write, read-set, and read-clear, each taking its source from a register or a five-bit immediate - reach a CSR by name.

asm riscv64 {
    csrrw a0, mstatus, a1   # read mstatus into a0, write a1 into it
    csrr  a0, mtvec         # the read-only pseudo
    csrw  stvec, a1         # install a trap vector
    rdtime a0               # the unprivileged counters
}

The privileged spec defines several hundred addresses across three privilege levels, so any CSR is also reachable by its numeric address. RISC-V spells no separate escape syntax for this - a CSR operand simply parses as the ordinary integer literal it looks like, bounded to the twelve bits a CSR address occupies.

asm riscv64 {
    csrr a0, 0xc01   # the same register as `csrr a0, time`
}
Restriction

Access permission is not checked, on either target: whether a register is readable or writable depends on the exception or privilege level the code runs at, which the compiler does not know. Accessing one the current level cannot reach traps at run time, as the architecture defines.

Raw encodings

Four data directives emit their values verbatim, for an encoding the ISA's mnemonic table does not name. They work on every target.

asm x86_64 {
    .byte 0x0f, 0x01, 0xd0    # xgetbv
}

asm aarch64 {
    .word 0xd53be040          # mrs x0, cntvct_el0
}

The widths are GNU as's, per target - .word is the one that differs:

Directivex86-64aarch64 / riscv64
.byte1 byte1 byte
.word2 bytes4 bytes
.long4 bytes4 bytes
.quad8 bytes8 bytes

Values are written in the target's byte order, so .word 0xd503201f is the aarch64 nop as its manual prints it. A value the width cannot hold is refused rather than truncated, and one directive carries a whole sequence - up to 256 payload bytes - not four. On aarch64 and riscv64 a statement must emit a whole number of instruction words; x86-64 has no such constraint.

Cost

A raw encoding is an instruction stream the parser cannot read, so the block's clobber set becomes every register in every bank, and an #[oblivious] function may not contain one at all. A real mnemonic is always preferable where one exists.

Multi-arch dispatch

Different architectures use different mnemonics, registers, and calling conventions. There is no nested arch-block construct inside asm; instead, wrap each block in $if on $mach.build.arch.

$if ($mach.build.arch == $mach.arch.x86_64) {
    asm x86_64 { ... }
}
$or ($mach.build.arch == $mach.arch.aarch64) {
    asm aarch64 { ... }
}

The discarded branches do not compile, so each asm block only needs to be valid for its tagged ISA.

When to use it

For operations that exist as named standard-library functions - atomics, fences, traps, the SIMD long tail - use the library API. Those wrappers already contain the arch-dispatched asm.

See also