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 ISA tag is mandatory. Bare
asm { ... }does not exist. - The tag comes from a closed set:
x86_64,aarch64,riscv64,riscv32. Each has a working assembler that emits native bytes; the first three run in CI, riscv64 under qemu. - The RISC-V tag names the machine, not the family. An
asm riscv64block is refused on a riscv32 target and the other way round, so a body reaching the assembler was written for the register width it is being assembled for. Gate the two with$if ($mach.build.arch == $mach.arch.riscv32)when a routine needs both. An RV64-only spelling -ld,sd, the*Wgroup, the doubleword atomics - is refused under ariscv32tag, naming the instruction and the machine, because each instruction states the register widths it exists at and the assembler reads that rather than a second list of its own. The shift-amount field goes the same way:slli a0, a1, 32is refused on rv32, where its sixth bit would land infunct7and decode as a different instruction. - Each line is an instruction in the ISA's native syntax.
#introduces a line comment: everything from#to the end of the line is ignored, whatever it contains.
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;
}
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
- Operand direction. Position within an instruction determines whether an operand is read or written.
- Clobber set. The compiler reads each instruction, knows what registers and flags it touches, and adds them to the surrounding function's clobber set.
- Memory clobber. Every
asmblock is conservatively assumed to modify arbitrary memory.
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`
}
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:
| Directive | x86-64 | aarch64 / riscv64 |
|---|---|---|
.byte | 1 byte | 1 byte |
.word | 2 bytes | 4 bytes |
.long | 4 bytes | 4 bytes |
.quad | 8 bytes | 8 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.
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
- Truly target-specific operations: syscalls, register reads, stack-frame surgery.
- Anything that does not have a one-to-one standard-library wrapper.
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
- Decorators -
#[naked], whose body may hold onlyasm - Secrecy - how an
#[oblivious]function validates anasmblock - Comptime control flow -
$ifover$mach.build.arch