machdocs
Home Ecosystem Changelog

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.

Only an identifier inside braces names a local. Any other braced text belongs to the ISA's own syntax, such as the aarch64 register list {v0.16b}, and reaches its grammar untouched.

A body that binds {name} may not move the stack pointer

A {name} becomes a fixed displacement off a base register, measured once when the block is assembled. On aarch64 and riscv64 that base is the stack pointer, the only base whose displacement stays inside those ISAs' immediate forms however deep the frame is. A statement that moves the stack pointer would move every {name} in the block out from under its own address, so the compiler refuses the block rather than assembling a wrong one.

var x: i64 = 0;
asm aarch64 {
    ldr x9, {x}
    stp x1, x2, [sp, -16]!   # refused: this body binds {x}
}

The refusal covers the whole block, not just the statements after the write, because a backward branch reaches an earlier {name} again with the pointer already moved. Push and pop around the block instead, or drop the {name} and stage the address into a register yourself. A body that binds no {name} is unaffected, which is what a #[naked] function's hand-written prologue relies on.

What the compiler infers

Calls and jumps (x86-64)

call and jmp take the same 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

    jmp  some_symbol     # direct: E9 rel32
    jmp  rax             # indirect through a register
    jmp  [rax + 8]       # ... and the same memory forms
}

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] and jmp [symbol] are refused too: the rip-relative form would mean "transfer to the pointer stored at the symbol", which is not what the direct form beside it means.

Both indirect operands are fixed 64-bit in long mode, so a narrower register (jmp eax) is refused rather than widened. An indirect call clobbers exactly as a direct one does. The register or memory holding the target is read, not written.

Operand sizes (x86-64)

A register operand states its own width, so mov eax, [rcx] is a four-byte load and needs nothing else. A memory operand states none, and where the instruction does not settle it either, the width is written out in nasm's spelling.

asm x86_64 {
    movzx eax, word [rcx]     # a two-byte load, zero-extended into eax
    movsx rax, dword [rcx]    # a four-byte load, sign-extended (movsxd)
    mov dword [rcx], 1        # a four-byte store, not the machine word
    neg qword [rcx]           # an eight-byte read-modify-write
}

byte, word, dword and qword are accepted before a memory operand and nowhere else, so mov qword rax, rcx is refused rather than ignored. A vector instruction's memory operand takes xmmword instead (see below). A prefix that contradicts the instruction is a build error, not a dropped token, and what counts as a contradiction is per mnemonic:

ShapeRule
most instructionsevery operand shares one width, so a prefix must agree with any register operand, and with no register operand it sets the width
movzx / movsxthe source is narrower by design, so a memory source must be sized, and strictly narrower than the destination
push / pop, indirect call / jmpfixed 64-bit in long mode, so any narrower prefix names no instruction
lidtits pseudo-descriptor is ten bytes, which no keyword names

So mov eax, word [rcx] is refused (two widths for one access), and so is movzx eax, [rcx]: an unsized source names no width, and reading it as a same-width move would silently assemble a plain mov where a zero-extending load was written.

Vector registers

The x86-64 and aarch64 grammars take vector registers as operands. A vector register belongs to the floating-point and vector bank, so writing one adds it to the block's vector clobber set, and a live vector value crossing the block is kept the same way a general-purpose one is. The allocator and the call conventions both see those writes.

x86-64

The registers are xmm0 to xmm15. A memory operand of a vector instruction is a whole 128-bit vector, written bare or as xmmword [...]. A narrower width prefix is refused. [symbol] addresses RIP-relative data, and the relocation is correct after a trailing immediate.

FormMnemonics
move, either direction between a register and memorymovdqa movdqu movaps movups
xmm, xmm/m128 integer arithmetic: paddb paddw paddd paddq psubb psubw psubd psubq psubusb psubusw pmullw
logic: pand por pxor
compares: pcmpeqb pcmpeqw pcmpeqd pcmpgtb pcmpgtw pcmpgtd
interleave and saturate: punpcklbw punpcklwd punpckldq packsswb packssdw
float arithmetic: addps subps mulps divps addpd subpd mulpd divpd
conversions: cvtdq2ps cvttps2dq cvtdq2pd cvttpd2dq cvtps2pd cvtpd2ps
xmm, xmm/m128, imm8pshufd cmpps cmppd
asm x86_64 {
    movdqu xmm0, [rsi]            # load 16 bytes
    movdqu xmm1, xmmword [rdx]    # the same, with the width written out
    pxor xmm0, xmm1
    pshufd xmm0, xmm0, 0x1b       # reverse the four 32-bit lanes
    movdqu [rdi], xmm0            # store
}

aarch64

The registers are spelled vN.16b, vN.8h, vN.4s or vN.2d. The suffix is the lane arrangement, and every operand of one instruction shares it. add, sub, and, orr, eor and mov select their vector form when their operands are vector registers.

FormMnemonics
three registersadd sub mul, and orr eor, cmeq cmgt cmge cmhi cmhs, fadd fsub fmul fdiv, fcmeq fcmgt fcmge
two registersmov mvn
one element structureld1 {vT.<lanes>}, [Xn], st1 {vT.<lanes>}, [Xn]

Not every member exists at every arrangement:

MembersArrangements
and orr eor mov mvn.16b only
the float members (fadd through fcmge).4s and .2d only
mulevery arrangement except .2d

ld1 and st1 post-index their base, either by the structure size (, 16) or by an X register (, x9). Either form writes the base register.

asm aarch64 {
    ld1 {v0.16b}, [x1], 16      # load 16 bytes, then x1 += 16
    ld1 {v1.16b}, [x1], 16
    eor v0.16b, v0.16b, v1.16b
    st1 {v0.16b}, [x2], x9      # store, then x2 += x9
}

The braces in {v0.16b} are aarch64's register-list syntax, not a substitution: only an identifier inside braces names a local.

Constant-time checking

The multiply and float members are variable-latency operations, so the constant-time check in an #[oblivious] function treats them as it treats their scalar counterparts. The check tracks secrets per register bank, so a secret in xmm3 is never mistaken for a secret in rbx, which shares its index.

Instruction-set availability

The vector tables above hold only each architecture's baseline set: SSE2 on x86-64 and AdvSIMD on aarch64. An asm block may use any listed form on any target of its architecture, with no declaration.

Privileged and system instructions

The privileged and system instruction families are reachable from inline assembly on every target: the x86-64 systems instructions below, aarch64 mrs / msr and the exception conduits, and the RISC-V CSR family.

Systems instructions (x86-64)

asm x86_64 {
    cli                       # clear the interrupt flag
    sti                       # ... and set it
    cld                       # clear DF before entering a program
    hlt                       # park the core
    in al, dx                 # port i/o, through dx or by immediate port
    out dx, al
    rdtsc                     # the time-stamp counter
    rdmsr                     # the model-specific registers
    wrmsr
    lidt [rax]                # install an interrupt descriptor table
    pushfq                    # save RFLAGS
    popfq                     # ... and restore it
    swapgs                    # per-CPU state on a syscall entry
    iretq                     # return from an interrupt handler
    mov rax, cr2              # the faulting address in a page-fault handler
    mov cr3, rax              # switch page tables
    mov eax, cs               # the live selector, for programming STAR
}

Control registers are cr0, cr2, cr3, cr4 and cr8, and a control-register move takes a 64-bit general-purpose register on its other side. Segment registers (es, cs, ss, ds, fs, gs) can be read into a general-purpose register but not written through mov.

None of these writes a register the allocator tracks except rdtsc and rdmsr, which land their result in EDX:EAX. iretq does not fall through, but the effect model cannot say so: statements after it are unreachable without the compiler reporting it.

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.

Exception conduits and waits (aarch64)

asm aarch64 {
    svc 0                     # the kernel, at EL1
    hvc 0                     # the hypervisor, at EL2
    smc 0                     # the secure monitor, at EL3
    wfi                       # wait for an interrupt: the correct idle loop
    wfe                       # wait for an event
}

hvc and smc are how PSCI is reached, which is the only way to power off or restart a virt board. Both follow the SMC Calling Convention, so the compiler declares them as destroying x0 to x17. x18 to x30 and SP survive, which makes a conduit cheaper than an ordinary bl. The waits write nothing, so an idle loop holds every live value across them. yield is the weaker hint - it may do nothing at all.

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 non-negative value is read unsigned (.quad 0xFFFFFFFFFFFFFFFF is a legal address), and a negative one is its two's complement at the directive's width. 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 (.byte 0x1f, 0x20, 0x03 is refused there), and x86-64 has no such constraint.

Cost

A raw encoding is an instruction stream the parser cannot read, so by default 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.

Declaring a raw encoding's effects

That default is correct and expensive: with nothing held live across the statement, the allocator spills every value in a callee-saved register and the prologue saves every callee-saved register the function could reach. A raw encoding may instead state what it writes, with a :: clause on the directive.

asm x86_64 {
    mov dx, {port}
    .byte 0xee :: writes()                 # out dx, al - reads dx and al, writes nothing
    .byte 0x0f, 0x31 :: writes(rax, rdx)   # rdtsc - lands its result in EDX:EAX
}

asm aarch64 {
    .word 0xd4000002 :: writes(x0, x1, x2, x3)   # hvc #0, returning per SMCCC
}

asm riscv64 {
    .word 0xc0102573 :: writes(a0)               # csrr a0, time
}

writes() with an empty list is a declaration that the encoding writes no register. Leaving the clause off keeps the conservative default. Registers are named in the target's own vocabulary, in either bank:

TargetGeneral-purposeFloat / vector
x86-64rax to r15xmm0 to xmm15
aarch64x0 to x30, sp, xzrv0 to v31
riscv64x0 to x31, psABI aliases (a0, t0, sp, ...)f0 to f31, psABI aliases (fa0, ft0, ...)

A width is not a register: eax, w0 and v2.4s are refused, because the allocator tracks each register's bits as a unit. A malformed clause, an unknown register, and an unknown attribute all fail the build. A clause may only follow a data directive, so it can only narrow the conservative default and never contradict an effect the compiler derived from a mnemonic. writes(...) is the only attribute today.

Not checked

The compiler does not decode the payload, so it cannot verify that the bytes write only what the clause says. Under-declaring is a miscompile: the allocator will keep a value in a register the encoding overwrites. Declare everything the instruction's manual says it writes, including implicit destinations. A declaration buys allocation quality, not verification, and an #[oblivious] function still refuses a data directive whether or not it is declared.

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