Chapter 14: Inline Assembly
Some instructions have no Loup equivalent: cli and sti, rdmsr/wrmsr, lidt, reading cr2, mrs/msr on Arm, csrr/csrw on RISC-V, the barrier instructions, the first few instructions after reset. For those, freestanding code uses asm { } blocks (Language Reference §3-3.7). This chapter covers the block's grammar, operands and clobbers, which modes accept it, what the 1.25 checker verifies about it and what it leaves to you, and examples for x86_64, AArch64 and RISC-V.
The basic form
An asm { } block holds one or more string literals — the instructions, one per string — optionally followed by operand lists. With no operands:
@mode(freestanding)
func disableInterrupts(): void { asm { "cli" } }
func enableInterrupts(): void { asm { "sti" } }
func cpuRelax(): void { asm { "pause" } }
# Park the CPU until the next interrupt, forever.
func idle(): void {
while (true) {
asm { "hlt" }
}
}
Listing 14-1: One-instruction wrappers
The instructions are passed to the target's assembler as written; loup check does not assemble them, so a typo in an instruction shows up only when you build for the target. Several strings form a sequence, and numeric local labels (1: … jmp 1b) work as in GNU assembler syntax. On x86_64 the syntax is Intel (mov rax, cr2, destination first).
Operands — out:, in:, clobbers:
After the instruction strings come up to three lists, in this order:
out: name = "constraint", …— binds a Loup variable to an output register;in: "constraint" = expression, …— loads a Loup value into a register before the instructions run;clobbers: ["…", …]— names registers and"memory"that the instructions change without listing them as outputs.
func readMsr(msr: uint32): uint64 {
var lo: uint32 = 0
var hi: uint32 = 0
asm {
"rdmsr"
out: lo = "=a", hi = "=d"
in: "ecx" = msr
clobbers: []
}
return ((hi as uint64) << 32) | (lo as uint64)
}
func writeMsr(msr: uint32, value: uint64): void {
let lo = (value & 0xFFFF_FFFFu64) as uint32
let hi = (value >> 32) as uint32
asm {
"wrmsr"
in: "ecx" = msr, "eax" = lo, "edx" = hi
clobbers: ["memory"]
}
}
# Port I/O: the 16550 UART on a PC is at I/O port 0x3F8.
func outb(port: uint16, value: uint8): void {
asm { "out dx, al" in: "dx" = port, "al" = value clobbers: [] }
}
func inb(port: uint16): uint8 {
var value: uint64 = 0
asm { "in al, dx" out: value = "=a" in: "dx" = port clobbers: [] }
return (value & 0xFFu64) as uint8
}
Listing 14-2: Model-specific registers and port I/O
Constraint strings follow the GCC/LLVM conventions: "=a" is "output in rax/eax/al", "=d" the rdx family, "r" any general register, and a constraint may simply name a register — "ecx", "dx", "al" on x86, "x0" on AArch64, "a0" on RISC-V. Output variables must be vars. Clobber "memory" whenever the instructions read or write memory the compiler cannot see (a TLB flush, a control-register write, a barrier), so it does not keep values cached in registers across the block.
The Language Reference also defines "all" (every caller-saved register) and per-architecture presets such as "msr-read" and "cpuid" for the clobber list; the checker accepts clobbers: ["all"].
Two syntax habits that do not carry over
The operand lists live inside the braces, after the instructions. GCC's parenthesized, colon-separated form does not parse:
@mode(freestanding)
func readMsr(msr: uint32): uint64 {
var lo: uint32 = 0
var hi: uint32 = 0
asm("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr))
return ((hi as uint64) << 32) | (lo as uint64)
}
error[E0001]: expected RParen, found Colon
--> src/main.lpfree:6:17
|
6 | asm("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr))
| ^
|
error[E0003]: expected `;`
--> src/main.lpfree:6:17
|
6 | asm("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr))
| ^
|
And the instructions come first. Putting the operands before them — as in Rust's asm!, where operands follow the template but are separated by commas — fails at the first operand:
@mode(freestanding)
func readCr2(): uint64 {
var faultAddr: uint64 = 0
asm {
out: faultAddr = "=a"
"mov rax, cr2"
}
return faultAddr
}
error[E0001]: asm block requires at least one template string literal
--> src/main.lpfree:6:9
|
6 | out: faultAddr = "=a"
| ^^^
|
error[E0001]: expected RBrace, found StringLiteral { quote: 34, is_raw: false }
--> src/main.lpfree:7:9
|
7 | "mov rax, cr2"
| ^^^^^^^^^^^^^^
|
error[E0001]: expected expression
--> src/main.lpfree:10:1
|
10 | }
| ^
|
The fixed version:
# The faulting address of the last page fault.
func readCr2(): uint64 {
var faultAddr: uint64 = 0
asm {
"mov rax, cr2"
out: faultAddr = "=a"
clobbers: []
}
return faultAddr
}
Listing 14-3: Instructions first, then operands
Which modes accept asm
asm { } is compiled to machine code, so it exists only in the modes that produce machine code: freestanding, host.native (where the Language Reference requires an @unsafe("asm: …") acknowledgment on the enclosing function), and web.compiled (a WebAssembly form). In the interpreted and dual modes it is rejected. A helper written for a hosted tool fails like this:
@mode(host.dual)
func disableInterrupts(): void {
asm { "cli" }
}
func main(): void {
disableInterrupts()
}
main.loup:4:5: error[E_ASM_NOT_COMPILED]: E_ASM_NOT_COMPILED: `asm { }` is only valid in a compiled mode (`web.compiled`, `host.native`, `freestanding`); the module mode is `host.dual` (§3-3.7)
(This listing is a host.dual program, so the message comes from loup run.) Keep hardware access in .lpfree files; a hosted tool that needs the same logic needs its own copy without the asm.
In freestanding, an asm block does not need @unsafe: inline assembly is too common in kernel code for every block to be an audit site. The Language Reference describes an asm-unchecked advisory per function instead; the 1.25 toolchain does not print it.
What the checker does not verify
loup check parses the block, resolves the operand variables and expressions, and stops there. In 1.25 it does not check:
-
Operand widths. The Language Reference makes a mismatch between an operand's type and its constraint
E_ASM_OPERAND_TYPE. It is not implemented: this compiles, althoughrdtscwrites 32 bits toeaxand the variable is auint8.This code does not do what it shouldfunc cycleCounterLow(): uint8 { var low: uint8 = 0 asm { "rdtsc" out: low = "=a" clobbers: ["rdx"] } return low }
Listing 14-4: A width mismatch the checker accepts
Give each output the width of the register it receives (
uint32foreax,uint64forrax), as Listing 14-2 does, and narrow afterwards withas. -
Mutability of outputs. An output bound to a
letis accepted. Usevar. -
The instructions themselves, and whether the constraints exist on the target. Those errors come from the assembler at build time.
-
Expired borrows used as operands (
E_ASM_EXPIRED_BORROWis specified, not raised).
Borrowed values as operands
A struct passed by borrow can be an input operand; the register receives its address. That is the natural way to hand a descriptor-table pointer to lidt:
# The 10-byte operand of lidt/lgdt: a 16-bit limit and a 64-bit base.
@repr(packed)
struct DescriptorTablePointer {
var limit: uint16;
var base: uint64;
}
func loadIdt(borrow idtr: DescriptorTablePointer): void {
asm {
"lidt [rdi]"
in: "rdi" = idtr
clobbers: ["memory"]
}
}
Listing 14-5: Passing a borrowed struct's address to lidt
When to use asm — and when not to
Inline assembly is hard to review and invisible to the checker, so use the Loup primitive where one exists:
- Memory-mapped registers:
Volatile[T]or avolatile borrowparameter (chapter 15). - Atomic operations and memory ordering: the atomic intrinsics
atomicFetchAddU64,atomicCompareExchangeU64,atomicFenceand friends (chapter 19), notlock xadd. - Disabling interrupts around a critical section:
InterruptGuardorirqSaveDisable/irqRestore, which restore the previous state correctly on every architecture (chapter 16). - Layout arithmetic:
size_of,align_of,addressOf.
Use asm for privileged and system instructions (lgdt, lidt, invlpg, wrmsr, cpuid), control and system registers (cr0–cr4, msr/mrs, csrr/csrw), I/O ports on x86, explicit hardware barriers (mfence, dsb, isb, fence), and the bodies of @naked functions — boot entries, vector tables and context switches (chapter 16).
Architecture examples
The constraint and register spellings below are the ones the QEMU-booted test kernels in the toolchain repository use.
x86_64
# The timestamp counter.
func rdtsc(): uint64 {
var lo: uint32 = 0
var hi: uint32 = 0
asm { "rdtsc" out: lo = "=a", hi = "=d" clobbers: [] }
return ((hi as uint64) << 32) | (lo as uint64)
}
# Load a new page-table root; this also flushes non-global TLB entries.
func loadCr3(root: uint64): void {
asm { "mov cr3, rax" in: "rax" = root clobbers: ["memory"] }
}
# Invalidate one page's TLB entry.
func invlpg(virt: uint64): void {
asm { "invlpg [rax]" in: "rax" = virt clobbers: ["memory"] }
}
Listing 14-6: x86_64 helpers
AArch64
# The counter frequency and the virtual counter.
func counterFrequency(): uint64 {
var f: uint64 = 0
asm { "mrs x0, cntfrq_el0" out: f = "=x0" clobbers: [] }
return f
}
func virtualCount(): uint64 {
var v: uint64 = 0
asm { "mrs x0, cntvct_el0" out: v = "=x0" clobbers: [] }
return v
}
# A 32-bit device-register store and the barriers around it.
func write32(addr: uint64, value: uint64): void {
asm { "str w1, [x0]" in: "x0" = addr, "x1" = value clobbers: ["memory"] }
}
func dataBarrier(): void { asm { "dsb sy" clobbers: ["memory"] } }
func instructionBarrier(): void { asm { "isb" clobbers: [] } }
# Mask IRQs (DAIF.I).
func maskIrq(): void { asm { "msr daifset, #2" clobbers: ["memory"] } }
Listing 14-7: AArch64 helpers
RISC-V (riscv64)
# A 64-bit load from a device or memory address.
func load64(addr: uint64): uint64 {
var v: uint64 = 0
asm { "ld a0, 0(a0)" out: v = "=a0" in: "a0" = addr clobbers: ["memory"] }
return v
}
# Machine-mode interrupt enable/disable (mstatus.MIE, bit 3).
func enableInterrupts(): void { asm { "csrsi mstatus, 8" clobbers: ["memory"] } }
func disableInterrupts(): void { asm { "csrci mstatus, 8" clobbers: ["memory"] } }
# Wait for an interrupt.
func waitForInterrupt(): void { asm { "wfi" clobbers: [] } }
Listing 14-8: RISC-V helpers
Summary
asm { "instr" … out: … in: … clobbers: [...] }— instructions first, then outputs, inputs and clobbers, all inside the braces. GCC'sasm("…" : …)form does not parse.- Constraints follow GCC/LLVM conventions and may name registers directly; clobber
"memory"around anything with memory effects. asmis valid infreestanding,host.nativeandweb.compiled; elsewhere it isE_ASM_NOT_COMPILED. In freestanding it needs no@unsafe.- The 1.25 checker does not verify operand widths, output mutability or the instructions; match register widths yourself.
- A
borrowstruct operand passes its address. - Prefer
Volatile, the atomic intrinsics,InterruptGuardand the layout queries where they fit; keepasmfor system instructions, ports, barriers and naked functions.
Next: MMIO with Volatile.